mirror of
https://github.com/zotero/zotero.git
synced 2026-10-05 02:43:38 +00:00
Support multiple-collection selection
Allow selecting multiple collections, saved searches, or library roots in the collection tree -- within a library or across libraries -- and show the union of their items. The selection is threaded through the pane as an array (getCollectionTreeRows(), changeCollectionTreeRows(), etc.); the item pane, tag selector, reports, and export operate on all selected rows. Adding items (new items and notes, drag-and-drop, the attachment dialog, import, Add by Identifier) targets every selected collection. Only rows that can share an items view may be combined: collections, saved searches, and library roots mix freely, and multiple Recently Read rows can be combined across libraries, but other special views (Trash, Duplicates, etc.) and rows from different visibility groups can't be shown together, so a selection mixing them keeps only the focused row. In-window advanced search runs across all selected collections. Advanced search value autocomplete is now scoped to the searched library, fixing a long-standing TODO where suggestions were drawn from all libraries regardless of the search scope; for a cross-library selection it spans the selected libraries. For a cross-library selection, the tag selector shows the union of tags (colored tags only when a single library is in scope, since colors are per-library), and deleting a tag spans all selected libraries while rename/color/split are disabled. If advanced search is open, the collection and saved-search conditions are omitted, since each is scoped to a single library.
This commit is contained in:
parent
4d38970ef0
commit
d7770a9d11
36 changed files with 1893 additions and 597 deletions
|
|
@ -50,6 +50,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
dragAndDrop: false,
|
||||
filterLibraryIDs: false,
|
||||
hideSources: [],
|
||||
multiSelect: false,
|
||||
onContextMenu: noop,
|
||||
};
|
||||
|
||||
|
|
@ -59,13 +60,13 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
dragAndDrop: PropTypes.bool,
|
||||
filterLibraryIDs: PropTypes.array,
|
||||
hideSources: PropTypes.array,
|
||||
multiSelect: PropTypes.bool,
|
||||
onContextMenu: PropTypes.func,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.itemTreeView = null;
|
||||
this.itemToSelect = null;
|
||||
|
||||
this.type = 'collection';
|
||||
this.name = "CollectionTree";
|
||||
|
|
@ -140,6 +141,8 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
super.componentDidMount();
|
||||
|
||||
this.selection.select(0);
|
||||
this.makeVisible();
|
||||
if (this.props.dragAndDrop) {
|
||||
|
|
@ -306,6 +309,8 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
// Classes
|
||||
div.className = "row";
|
||||
div.classList.toggle('selected', selection.isSelected(index));
|
||||
div.classList.toggle('first-selected', selection.isFirstRowOfSelectionBlock(index));
|
||||
div.classList.toggle('last-selected', selection.isLastRowOfSelectionBlock(index));
|
||||
div.classList.toggle('highlighted', this._highlightedRows.has(treeRow.id));
|
||||
div.classList.toggle('drop', this._dropRow == index);
|
||||
div.classList.toggle('flashing', this._flashingRow == index);
|
||||
|
|
@ -476,6 +481,8 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
onToggleOpenState: this.toggleOpenState,
|
||||
getRowString: this.getRowString.bind(this),
|
||||
|
||||
multiSelect: this.props.multiSelect,
|
||||
|
||||
onItemContextMenu: (...args) => this.props.onContextMenu && this.props.onContextMenu(...args),
|
||||
|
||||
onKeyDown: this.handleKeyDown,
|
||||
|
|
@ -1305,17 +1312,27 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deleteSelection(deleteItems) {
|
||||
var treeRow = this.getRow(this.selection.focused);
|
||||
if (treeRow.isFeed()) {
|
||||
await treeRow.ref.eraseTx();
|
||||
return;
|
||||
let treeRows = this.getSelectedRows();
|
||||
// Feeds must be erased outside a transaction since Feed.erase()
|
||||
// starts its own transaction internally
|
||||
let feeds = treeRows.filter(r => r.isFeed());
|
||||
let others = treeRows.filter(r => !r.isFeed());
|
||||
for (let row of feeds) {
|
||||
await row.ref.eraseTx();
|
||||
}
|
||||
treeRow.ref.deleted = true;
|
||||
if (treeRow.isCollection()) {
|
||||
await treeRow.ref.saveTx({ deleteItems });
|
||||
return;
|
||||
if (others.length) {
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
for (let row of others) {
|
||||
row.ref.deleted = true;
|
||||
if (row.isCollection()) {
|
||||
await row.ref.save({ deleteItems });
|
||||
}
|
||||
else {
|
||||
await row.ref.save();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
await treeRow.ref.saveTx();
|
||||
}
|
||||
|
||||
unregister() {
|
||||
|
|
@ -1371,20 +1388,35 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
}
|
||||
|
||||
getSelectedCollection(asID) {
|
||||
var collection = this.getRow(this.selection.focused);
|
||||
if (collection && collection.isCollection()) {
|
||||
return asID ? collection.ref.id : collection.ref;
|
||||
Zotero.debug("CollectionTree#getSelectedCollection() is deprecated -- use getSelectedCollections()");
|
||||
return this.getSelectedCollections(asID)[0] || false;
|
||||
}
|
||||
|
||||
getSelectedCollections(asID) {
|
||||
var collections = [];
|
||||
for (let index of this.selection.selected) {
|
||||
let row = this.getRow(index);
|
||||
if (row && row.isCollection()) {
|
||||
collections.push(asID ? row.ref.id : row.ref);
|
||||
}
|
||||
}
|
||||
return collections;
|
||||
}
|
||||
|
||||
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;
|
||||
Zotero.debug("CollectionTree#getSelectedSearch() is deprecated -- use getSelectedSearches()");
|
||||
return this.getSelectedSearches(asID)[0] || false;
|
||||
}
|
||||
|
||||
getSelectedSearches(asID) {
|
||||
var searches = [];
|
||||
for (let index of this.selection.selected) {
|
||||
let row = this.getRow(index);
|
||||
if (row && row.isSearch()) {
|
||||
searches.push(asID ? row.ref.id : row.ref);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return searches;
|
||||
}
|
||||
|
||||
getSelectedGroup(asID) {
|
||||
|
|
@ -1448,18 +1480,27 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
onDragStart(event, index) {
|
||||
const treeRow = this.getRow(index);
|
||||
super.onDragStart(event, index);
|
||||
|
||||
// See note in #setDropEffect()
|
||||
if (Zotero.isWin || Zotero.isLinux) {
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
}
|
||||
|
||||
if (!treeRow.isCollection() && !treeRow.isSearch()) {
|
||||
let rows = this.getSelectedRows();
|
||||
// Ignore drag if all selected rows aren't the same type
|
||||
if (!rows.every(o => o.type == rows[0].type)) {
|
||||
return;
|
||||
}
|
||||
let type = treeRow.isCollection() ? "zotero/collection" : "zotero/search";
|
||||
event.dataTransfer.setData(type, treeRow.ref.id);
|
||||
Zotero.debug(`Dragging ${type} ` + treeRow.id);
|
||||
// Ignore if not all collections or searches
|
||||
if (!rows.every(o => o.isCollection() || o.isSearch())) {
|
||||
return;
|
||||
}
|
||||
let type = rows[0].isCollection() ? "zotero/collection" : "zotero/search";
|
||||
let ids = rows.map(o => o.ref.id);
|
||||
event.dataTransfer.setDragImage(this._dragImageContainer, 0, 0);
|
||||
event.dataTransfer.setData(type, ids);
|
||||
Zotero.debug(`Dragging ${type} ` + (ids.length > 1 ? '[' + ids.join(', ') + ']' : ids[0]));
|
||||
}
|
||||
|
||||
onDragOver(event, index) {
|
||||
|
|
@ -1603,7 +1644,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
}
|
||||
}
|
||||
|
||||
onDragEnd = () => {
|
||||
onDragEnd = (event, index) => {
|
||||
super.onDragEnd(event, index);
|
||||
|
||||
let dropRow = this._dropRow;
|
||||
this._dropRow = null;
|
||||
this.tree.invalidateRow(dropRow);
|
||||
|
|
@ -1775,19 +1818,20 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
return false;
|
||||
}
|
||||
|
||||
let draggedCollectionID = data[0];
|
||||
let draggedCollection = Zotero.Collections.get(draggedCollectionID);
|
||||
for (let id of data) {
|
||||
let draggedCollection = Zotero.Collections.get(id);
|
||||
|
||||
// Dragging within same library
|
||||
if (treeRow.ref.libraryID == draggedCollection.libraryID) {
|
||||
// Collections cannot be dropped on themselves
|
||||
if (draggedCollectionID == treeRow.ref.id) {
|
||||
return false;
|
||||
}
|
||||
// Dragging within same library
|
||||
if (treeRow.ref.libraryID == draggedCollection.libraryID) {
|
||||
// Collections cannot be dropped on themselves
|
||||
if (id == treeRow.ref.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Nor in their children
|
||||
if (draggedCollection.hasDescendent('collection', treeRow.ref.id)) {
|
||||
return false;
|
||||
// Nor in their children
|
||||
if (draggedCollection.hasDescendent('collection', treeRow.ref.id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2214,21 +2258,31 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
var targetCollectionID = targetTreeRow.isCollection() ? targetTreeRow.ref.id : false;
|
||||
|
||||
if (dataType == 'zotero/collection') {
|
||||
var droppedCollection = await Zotero.Collections.getAsync(data[0]);
|
||||
let droppedCollections = await Zotero.Collections.getAsync(data);
|
||||
if (droppedCollections.some(c => c.id == targetCollectionID)) {
|
||||
throw new Error("Can't drop onto source row");
|
||||
}
|
||||
|
||||
// Collection drag between libraries
|
||||
if (targetLibraryID != droppedCollection.libraryID) {
|
||||
await this.executeCollectionCopy({
|
||||
collection: droppedCollection,
|
||||
targetCollectionID,
|
||||
targetLibraryID,
|
||||
targetTreeRow,
|
||||
copyOptions
|
||||
});
|
||||
if (targetLibraryID != droppedCollections[0].libraryID) {
|
||||
for (let droppedCollection of droppedCollections) {
|
||||
await this.executeCollectionCopy({
|
||||
collection: droppedCollection,
|
||||
targetCollectionID,
|
||||
targetLibraryID,
|
||||
targetTreeRow,
|
||||
copyOptions
|
||||
});
|
||||
}
|
||||
}
|
||||
// Collection drag within a library
|
||||
else {
|
||||
droppedCollection.parentID = targetCollectionID;
|
||||
await droppedCollection.saveTx();
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
for (let droppedCollection of droppedCollections) {
|
||||
droppedCollection.parentID = targetCollectionID;
|
||||
await droppedCollection.save();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (dataType == 'zotero/item') {
|
||||
|
|
|
|||
|
|
@ -90,7 +90,15 @@ const STUB_COLLECTION_TREE_ROW = {
|
|||
class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
||||
constructor(itemTree) {
|
||||
super(itemTree);
|
||||
this.collectionTreeRow = null;
|
||||
this.collectionTreeRows = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The first selected collection tree row, for behaviors where the first row
|
||||
* determines view-wide semantics (view type, trash mode, etc.)
|
||||
*/
|
||||
get collectionTreeRow() {
|
||||
return this.collectionTreeRows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -102,34 +110,44 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
}
|
||||
|
||||
/**
|
||||
* Set a new collectionTreeRow and refresh items.
|
||||
* Set new collectionTreeRows and refresh items.
|
||||
* This handles the data/model logic; UI orchestration stays in ItemTree.
|
||||
* @param {Object} collectionTreeRow - The collection tree row to set
|
||||
* @param {Object[]} collectionTreeRows - The collection tree rows to set
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setCollectionTreeRow(collectionTreeRow) {
|
||||
async setCollectionTreeRows(collectionTreeRows) {
|
||||
// Normalize stub objects to include default CollectionTreeRow methods
|
||||
if (collectionTreeRow.constructor.name == "Object") {
|
||||
collectionTreeRow = Object.assign({}, STUB_COLLECTION_TREE_ROW, collectionTreeRow);
|
||||
}
|
||||
// No-op if same collection
|
||||
if (this.collectionTreeRow && this.collectionTreeRow.id === collectionTreeRow.id) {
|
||||
collectionTreeRows = collectionTreeRows.map((row) => {
|
||||
if (row.constructor.name == "Object") {
|
||||
row = Object.assign({}, STUB_COLLECTION_TREE_ROW, row);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
// No-op if same set of collections
|
||||
if (this.collectionTreeRows.length
|
||||
&& Zotero.Utilities.arrayEquals(
|
||||
this.collectionTreeRows.map(row => row.id).sort(),
|
||||
collectionTreeRows.map(row => row.id).sort()
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
if (!collectionTreeRows.slice(1).every(row => row.visibilityGroup == collectionTreeRows[0].visibilityGroup)) {
|
||||
throw new Error("Selected collection tree rows belong to different visibility groups");
|
||||
}
|
||||
let resetColumns = false;
|
||||
if (this.collectionTreeRow?.type != collectionTreeRow.type) {
|
||||
if (this.collectionTreeRow?.type != collectionTreeRows[0].type) {
|
||||
resetColumns = true;
|
||||
}
|
||||
this.collectionTreeRow = collectionTreeRow;
|
||||
this.collectionTreeRows = collectionTreeRows;
|
||||
|
||||
// Set ID based on visibilityGroup
|
||||
const visibilityGroup = collectionTreeRow.visibilityGroup || 'default';
|
||||
const visibilityGroup = collectionTreeRows[0].visibilityGroup || 'default';
|
||||
let treeID = "item-tree-" + this.itemTree.props.id + "-" + visibilityGroup;
|
||||
// Needs to be called after this.collectionTreeRow is set so that this.itemTree.visibilityGroup is correct
|
||||
// Needs to be called after this.collectionTreeRows is set so that this.itemTree.visibilityGroup is correct
|
||||
let idChanged = await this.itemTree.setId(treeID);
|
||||
|
||||
this._includeTrashed = collectionTreeRow.isTrash();
|
||||
// Emit loading state - only setCollectionTreeRow shows loading UI
|
||||
this._includeTrashed = collectionTreeRows[0].isTrash();
|
||||
// Emit loading state - only setCollectionTreeRows shows loading UI
|
||||
await this.runListeners('update', null, { loading: true });
|
||||
await this.itemTree._ensureSortContextReady();
|
||||
resetColumns && await this.itemTree._resetColumns();
|
||||
|
|
@ -144,22 +162,26 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setFilter(type, data) {
|
||||
let changed;
|
||||
switch (type) {
|
||||
case 'search':
|
||||
changed = this.collectionTreeRow.setSearch(data);
|
||||
break;
|
||||
case 'citation-search':
|
||||
changed = this.collectionTreeRow.setSearch(data, 'fields');
|
||||
break;
|
||||
case 'advanced-search':
|
||||
changed = this.collectionTreeRow.setAdvancedSearch(data);
|
||||
break;
|
||||
case 'tags':
|
||||
changed = this.collectionTreeRow.setTags(data);
|
||||
break;
|
||||
default:
|
||||
throw ('Invalid filter type in setFilter');
|
||||
let changed = false;
|
||||
for (let collectionTreeRow of this.collectionTreeRows) {
|
||||
let rowChanged;
|
||||
switch (type) {
|
||||
case 'search':
|
||||
rowChanged = collectionTreeRow.setSearch(data);
|
||||
break;
|
||||
case 'citation-search':
|
||||
rowChanged = collectionTreeRow.setSearch(data, 'fields');
|
||||
break;
|
||||
case 'advanced-search':
|
||||
rowChanged = collectionTreeRow.setAdvancedSearch(data);
|
||||
break;
|
||||
case 'tags':
|
||||
rowChanged = collectionTreeRow.setTags(data);
|
||||
break;
|
||||
default:
|
||||
throw ('Invalid filter type in setFilter');
|
||||
}
|
||||
changed = changed || rowChanged;
|
||||
}
|
||||
if (changed) {
|
||||
await this.refresh({ restoreSelection: true });
|
||||
|
|
@ -180,9 +202,15 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
this.itemTree._refreshPromise = deferred.promise;
|
||||
|
||||
try {
|
||||
this.collectionTreeRow.clearCache();
|
||||
// Get the full set of items we want to show
|
||||
let newSearchItems = await this.collectionTreeRow.getItems();
|
||||
this.collectionTreeRows.forEach(row => row.clearCache());
|
||||
// Get the full set of items we want to show, merged across all selected rows
|
||||
let newSearchItemSet = new Set();
|
||||
for (let arr of await Promise.all(this.collectionTreeRows.map(row => row.getItems()))) {
|
||||
for (let item of arr) {
|
||||
newSearchItemSet.add(item);
|
||||
}
|
||||
}
|
||||
let newSearchItems = [...newSearchItemSet];
|
||||
if (this.collectionTreeRow.isTrash()) {
|
||||
// When in trash, also fetch trashed collections and searched
|
||||
// So that they are displayed among deleted items
|
||||
|
|
@ -403,6 +431,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
|
||||
const cachedSelection = this.itemTree._cachedSelection;
|
||||
const collectionTreeRow = this.collectionTreeRow;
|
||||
const collectionTreeRows = this.collectionTreeRows;
|
||||
|
||||
var madeChanges = false;
|
||||
var refresh = false;
|
||||
|
|
@ -428,15 +457,15 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
}
|
||||
|
||||
var visibleSubcollections = Zotero.Prefs.get('recursiveCollections')
|
||||
? collectionTreeRow.ref.getDescendents(false, 'collection')
|
||||
? collectionTreeRows.map(row => row.ref.getDescendents(false, 'collection')).flat()
|
||||
: [];
|
||||
var splitIDs = [];
|
||||
for (let id of ids) {
|
||||
var split = id.split('-');
|
||||
// Include if an item in this collection or a visible subcollection
|
||||
if (split[0] == collectionTreeRow.ref.id
|
||||
|| visibleSubcollections.some(c => split[0] == c.id)) {
|
||||
splitIDs.push(split[1]);
|
||||
let [collectionID, itemID] = id.split('-');
|
||||
// Include if an item in one of the selected collections or a visible subcollection
|
||||
if (collectionTreeRows.some(row => row.ref.id == collectionID)
|
||||
|| visibleSubcollections.some(c => collectionID == c.id)) {
|
||||
splitIDs.push(itemID);
|
||||
}
|
||||
}
|
||||
ids = splitIDs;
|
||||
|
|
@ -498,7 +527,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
return;
|
||||
}
|
||||
|
||||
if ((action == 'remove' && !collectionTreeRow.isLibrary(true))
|
||||
if ((action == 'remove' && !collectionTreeRows.some(row => row.isLibrary(true)))
|
||||
|| action == 'delete' || action == 'trash'
|
||||
|| (action == 'removeDuplicatesMaster' && collectionTreeRow.isDuplicates())) {
|
||||
// Since a remove involves shifting of rows, we have to do it in order,
|
||||
|
|
@ -507,7 +536,8 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
let push = action == 'delete' || action == 'trash' || action == 'removeDuplicatesMaster';
|
||||
for (var i = 0, len = ids.length; i < len; i++) {
|
||||
if (!push) {
|
||||
push = !collectionTreeRow.ref.hasItem(ids[i]);
|
||||
let id = ids[i];
|
||||
push = !collectionTreeRows.some(row => row.ref.hasItem(id));
|
||||
}
|
||||
// Row might already be gone (e.g. if this is a child and
|
||||
// 'modify' was sent to parent)
|
||||
|
|
@ -533,7 +563,8 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
madeChanges = true;
|
||||
}
|
||||
}
|
||||
else if (collectionTreeRow.isSearchMode() && ['item', 'collection', 'search'].includes(type) && ['add', 'modify'].includes(action)) {
|
||||
else if ((collectionTreeRows.length > 1 || collectionTreeRow.isSearchMode())
|
||||
&& ['item', 'collection', 'search'].includes(type) && ['add', 'modify'].includes(action)) {
|
||||
// If search mode, just re-run search
|
||||
if (action == 'add' && this.hasQuickSearch()) {
|
||||
// For item adds, clear the quick search, unless all the new items have
|
||||
|
|
@ -551,7 +582,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
if (search) {
|
||||
search.searchTextbox.value = '';
|
||||
}
|
||||
this.collectionTreeRow.setSearch('');
|
||||
this.collectionTreeRows.forEach(row => row.setSearch(''));
|
||||
}
|
||||
}
|
||||
this.itemTree.invalidateRowCache(ids);
|
||||
|
|
@ -621,11 +652,14 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
// containing the item, the item has to be added
|
||||
else if (item.isTopLevelItem()) {
|
||||
// Root view
|
||||
let add = collectionTreeRow.isLibrary(true)
|
||||
&& collectionTreeRow.ref.libraryID == item.libraryID;
|
||||
let add = collectionTreeRows.some(
|
||||
row => row.isLibrary(true) && row.ref.libraryID == item.libraryID
|
||||
);
|
||||
// Collection containing item
|
||||
if (!add && collectionTreeRow.isCollection()) {
|
||||
add = item.inCollection(collectionTreeRow.ref.id);
|
||||
if (!add) {
|
||||
add = collectionTreeRows.some(
|
||||
row => row.isCollection() && item.inCollection(row.ref.id)
|
||||
);
|
||||
}
|
||||
if (add) {
|
||||
// Most likely, the note or attachment's parent was removed.
|
||||
|
|
@ -655,10 +689,9 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
else if (type == 'item' && action == 'add') {
|
||||
if (!items) items = Zotero.Items.get(ids);
|
||||
for (let item of items) {
|
||||
// if the item belongs in this collection
|
||||
if (((collectionTreeRow.isLibrary(true)
|
||||
&& collectionTreeRow.ref.libraryID == item.libraryID)
|
||||
|| (collectionTreeRow.isCollection() && item.inCollection(collectionTreeRow.ref.id)))
|
||||
// if the item belongs in one of the selected views
|
||||
if ((collectionTreeRows.some(row => row.isLibrary(true) && row.ref.libraryID == item.libraryID)
|
||||
|| collectionTreeRows.some(row => row.isCollection() && item.inCollection(row.ref.id)))
|
||||
// if we haven't already added it to our hash map
|
||||
&& !this._rowMap[item.id]
|
||||
// Regular item or standalone note/attachment
|
||||
|
|
@ -721,7 +754,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
|
||||
// If we refreshed, we have to clear the cache
|
||||
if (!refresh) {
|
||||
this.collectionTreeRow.clearCache();
|
||||
this.collectionTreeRows.forEach(row => row.clearCache());
|
||||
}
|
||||
|
||||
var singleSelect = false;
|
||||
|
|
@ -853,6 +886,8 @@ class CollectionViewItemTree extends ItemTree {
|
|||
|
||||
get collectionTreeRow() { return this.rowProvider.collectionTreeRow; }
|
||||
|
||||
get collectionTreeRows() { return this.rowProvider.collectionTreeRows; }
|
||||
|
||||
get visibilityGroup() {
|
||||
return this.collectionTreeRow?.visibilityGroup ?? 'default';
|
||||
}
|
||||
|
|
@ -878,18 +913,24 @@ class CollectionViewItemTree extends ItemTree {
|
|||
}
|
||||
|
||||
async changeCollectionTreeRow(collectionTreeRow) {
|
||||
return this.changeCollectionTreeRows(collectionTreeRow ? [collectionTreeRow] : []);
|
||||
}
|
||||
|
||||
async changeCollectionTreeRows(collectionTreeRows) {
|
||||
if (this._locked) return;
|
||||
if (!collectionTreeRow) {
|
||||
if (!collectionTreeRows || !collectionTreeRows.length) {
|
||||
this.tree = null;
|
||||
this._treebox = null;
|
||||
return this.clearItemsPaneMessage();
|
||||
}
|
||||
Zotero.debug(`CollectionViewItemTree.changeCollectionTreeRow(): ${collectionTreeRow.id}`);
|
||||
Zotero.debug(`CollectionViewItemTree.changeCollectionTreeRows(): [${collectionTreeRows.map(row => row.id).join(', ')}]`);
|
||||
|
||||
if (collectionTreeRow.view) {
|
||||
collectionTreeRow.view.itemTreeView = this;
|
||||
for (let collectionTreeRow of collectionTreeRows) {
|
||||
if (collectionTreeRow.view) {
|
||||
collectionTreeRow.view.itemTreeView = this;
|
||||
}
|
||||
}
|
||||
await this.rowProvider.setCollectionTreeRow(collectionTreeRow);
|
||||
await this.rowProvider.setCollectionTreeRows(collectionTreeRows);
|
||||
return this.waitForLoad();
|
||||
}
|
||||
|
||||
|
|
@ -1167,14 +1208,22 @@ class CollectionViewItemTree extends ItemTree {
|
|||
|
||||
var collectionTreeRow = this.collectionTreeRow;
|
||||
|
||||
var rowItem = null;
|
||||
if (row != -1 && orient == 0) {
|
||||
var rowItem = this.getRow(row).ref; // the item we are dragging over
|
||||
rowItem = this.getRow(row).ref; // the item we are dragging over
|
||||
// Cannot drop anything on attachments/notes
|
||||
if (!rowItem.isRegularItem()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// When the selection spans multiple libraries, only allow drops directly onto a
|
||||
// specific item, which identifies the target library. Drops into blank space or
|
||||
// between rows are ambiguous across libraries, so disallow them.
|
||||
if (!rowItem && new Set(this.collectionTreeRows.map(r => r.ref.libraryID)).size > 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dataType == 'zotero/item') {
|
||||
let items = Zotero.Items.get(data);
|
||||
|
||||
|
|
@ -1193,8 +1242,9 @@ class CollectionViewItemTree extends ItemTree {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Disallow cross-library child drag
|
||||
if (item.libraryID != collectionTreeRow.ref.libraryID) {
|
||||
// Disallow cross-library child drag (the target item's library, which
|
||||
// may differ from the focused row's in a cross-library selection)
|
||||
if (item.libraryID != rowItem.libraryID) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -1208,7 +1258,7 @@ class CollectionViewItemTree extends ItemTree {
|
|||
}
|
||||
|
||||
// In library, allow children to be dragged out of parent
|
||||
else if (collectionTreeRow.isLibrary(true) || collectionTreeRow.isCollection()) {
|
||||
else if (this.collectionTreeRows.some(r => r.isLibrary(true) || r.isCollection())) {
|
||||
let targetRow = row != -1 ? this.getRow(row) : null;
|
||||
for (let item of items) {
|
||||
// Don't allow drag if any top-level items
|
||||
|
|
@ -1267,8 +1317,11 @@ class CollectionViewItemTree extends ItemTree {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
// Don't allow drop into searches or publications
|
||||
else if (collectionTreeRow.isSearch() || collectionTreeRow.isPublications()) {
|
||||
// Into blank space, require a row that can receive items -- a collection or a
|
||||
// library root. A saved search or My Publications can't, and checking all
|
||||
// selected rows keeps this order-independent for a mixed collection + search
|
||||
// selection.
|
||||
else if (!this.collectionTreeRows.some(r => r.isCollection() || r.isLibrary(true))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -1343,8 +1396,27 @@ class CollectionViewItemTree extends ItemTree {
|
|||
|
||||
// Dropped outside of a row
|
||||
else {
|
||||
// Remove from parent and make top-level
|
||||
if (collectionTreeRow.isLibrary(true)) {
|
||||
let collectionRows = this.collectionTreeRows.filter(r => r.isCollection());
|
||||
// Add to all selected collections
|
||||
if (collectionRows.length) {
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let item = items[i];
|
||||
var source = item.isRegularItem() ? false : item.parentItemID;
|
||||
// Child item -- make top-level
|
||||
if (source) {
|
||||
item.parentID = false;
|
||||
}
|
||||
for (let collectionRow of collectionRows) {
|
||||
item.addToCollection(collectionRow.ref.id);
|
||||
}
|
||||
await item.save();
|
||||
toMove.push(item.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Only library roots selected -- remove from parent and make top-level
|
||||
else if (collectionTreeRow.isLibrary(true)) {
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let item = items[i];
|
||||
|
|
@ -1355,31 +1427,13 @@ class CollectionViewItemTree extends ItemTree {
|
|||
}
|
||||
});
|
||||
}
|
||||
// Add to collection
|
||||
else {
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let item = items[i];
|
||||
var source = item.isRegularItem() ? false : item.parentItemID;
|
||||
// Top-level item
|
||||
if (source) {
|
||||
item.parentID = false;
|
||||
item.addToCollection(collectionTreeRow.ref.id);
|
||||
await item.save();
|
||||
}
|
||||
else {
|
||||
item.addToCollection(collectionTreeRow.ref.id);
|
||||
await item.save();
|
||||
}
|
||||
toMove.push(item.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (dataType == 'application/x-moz-file') {
|
||||
// Disallow drop into read-only libraries
|
||||
if (!collectionTreeRow.editable) {
|
||||
// Disallow drop into read-only libraries. For a drop directly on an item,
|
||||
// that's the item's library (which can differ in a cross-library selection).
|
||||
let dropLibraryID = orient == 0 ? this.getRow(row).ref.libraryID : collectionTreeRow.ref.libraryID;
|
||||
if (!Zotero.Libraries.get(dropLibraryID).editable) {
|
||||
window.ZoteroPane.displayCannotEditLibraryMessage();
|
||||
return;
|
||||
}
|
||||
|
|
@ -1402,14 +1456,23 @@ class CollectionViewItemTree extends ItemTree {
|
|||
var targetLibraryID = collectionTreeRow.ref.libraryID;
|
||||
|
||||
var parentItemID = false;
|
||||
var parentCollectionID = false;
|
||||
var parentCollectionIDs = false;
|
||||
|
||||
if (orient == 0) {
|
||||
// Dropped directly on an item: attach to it, in its library (which can
|
||||
// differ from the focused row's in a cross-library selection)
|
||||
let treerow = this.getRow(row);
|
||||
parentItemID = treerow.ref.id;
|
||||
targetLibraryID = treerow.ref.libraryID;
|
||||
}
|
||||
else if (collectionTreeRow.isCollection()) {
|
||||
var parentCollectionID = collectionTreeRow.ref.id;
|
||||
else {
|
||||
// Add to all selected collections. Adding to a collection already implies
|
||||
// the item is in that collection's library, so when a library root is also
|
||||
// selected the collection(s) are the right (and sufficient) target.
|
||||
let collectionRows = this.collectionTreeRows.filter(r => r.isCollection());
|
||||
if (collectionRows.length) {
|
||||
parentCollectionIDs = collectionRows.map(r => r.ref.id);
|
||||
}
|
||||
}
|
||||
|
||||
let addedItems = [];
|
||||
|
|
@ -1470,7 +1533,7 @@ class CollectionViewItemTree extends ItemTree {
|
|||
file,
|
||||
title: delaySetAutoAttachmentTitle ? '' : undefined,
|
||||
parentItemID,
|
||||
collections: parentCollectionID ? [parentCollectionID] : undefined,
|
||||
collections: parentCollectionIDs || undefined,
|
||||
saveOptions: {
|
||||
notifierQueue
|
||||
}
|
||||
|
|
@ -1488,7 +1551,7 @@ class CollectionViewItemTree extends ItemTree {
|
|||
fileBaseName,
|
||||
libraryID: targetLibraryID,
|
||||
parentItemID,
|
||||
collections: parentCollectionID ? [parentCollectionID] : undefined,
|
||||
collections: parentCollectionIDs || undefined,
|
||||
saveOptions: {
|
||||
notifierQueue
|
||||
}
|
||||
|
|
@ -1621,6 +1684,12 @@ class CollectionViewItemTree extends ItemTree {
|
|||
|
||||
// Colored tag handling (ZoteroPane-specific)
|
||||
if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey && COLORED_TAGS_RE.test(event.code)) {
|
||||
// Colored-tag number shortcuts map positions to a single library's colored
|
||||
// tags, which aren't shown in the tag selector across a multi-library
|
||||
// selection, so disable them there
|
||||
if (new Set(this.collectionTreeRows.map(r => r.ref?.libraryID)).size > 1) {
|
||||
return true;
|
||||
}
|
||||
let libraryID = this.collectionTreeRow?.ref?.libraryID;
|
||||
if (!libraryID) {
|
||||
return true;
|
||||
|
|
@ -1720,6 +1789,7 @@ class CollectionViewItemTree extends ItemTree {
|
|||
let selectedItemIDs = selectedItems.map(o => o.id);
|
||||
|
||||
let collectionTreeRow = this.collectionTreeRow;
|
||||
let collectionTreeRows = this.collectionTreeRows;
|
||||
|
||||
// If all selected items are annotations, for now erase them skipping trash
|
||||
if (selectedItems.length && selectedItems.every(item => item.isAnnotation())) {
|
||||
|
|
@ -1774,8 +1844,8 @@ class CollectionViewItemTree extends ItemTree {
|
|||
}
|
||||
});
|
||||
}
|
||||
else if (collectionTreeRow.isLibrary(true)
|
||||
|| collectionTreeRow.isSearch()
|
||||
else if (collectionTreeRows.some(row => row.isLibrary(true))
|
||||
|| collectionTreeRows.some(row => row.isSearch())
|
||||
|| collectionTreeRow.isUnfiled()
|
||||
|| collectionTreeRow.isRecentlyRead()
|
||||
|| collectionTreeRow.isRetracted()
|
||||
|
|
@ -1783,10 +1853,12 @@ class CollectionViewItemTree extends ItemTree {
|
|||
|| force) {
|
||||
await Zotero.Items.trashTx(selectedItemIDs);
|
||||
}
|
||||
else if (collectionTreeRow.isCollection()) {
|
||||
let collectionIDs = [collectionTreeRow.ref.id];
|
||||
else if (collectionTreeRows.every(row => row.isCollection())) {
|
||||
let collectionIDs = collectionTreeRows.map(row => row.ref.id);
|
||||
if (Zotero.Prefs.get('recursiveCollections')) {
|
||||
collectionIDs.push(...collectionTreeRow.ref.getDescendents(false, 'collection').map(c => c.id));
|
||||
for (let row of collectionTreeRows) {
|
||||
collectionIDs.push(...row.ref.getDescendents(false, 'collection').map(c => c.id));
|
||||
}
|
||||
}
|
||||
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
this.searchBoxRef = React.createRef();
|
||||
|
||||
this.displayAllTags = Zotero.Prefs.get('tagSelector.displayAllTags');
|
||||
// Library IDs of the selected collection tree rows, in collections-list order.
|
||||
// Usually one, but a cross-library selection scopes the tag selector to several.
|
||||
this.libraryIDs = [];
|
||||
// Not stored in state to avoid an unnecessary refresh. Instead, when a tag is selected, we
|
||||
// trigger the selection handler, which updates the visible items, which triggers
|
||||
// onItemViewChanged(), which triggers a refresh with the new tags.
|
||||
|
|
@ -101,10 +104,13 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
componentDidUpdate(_prevProps, _prevState) {
|
||||
Zotero.debug("Tag selector updated");
|
||||
|
||||
// If we changed collections, scroll to top
|
||||
if (this.collectionTreeRow && this.collectionTreeRow.id != this.prevTreeViewID) {
|
||||
this.tagListRef.current.scrollToTop();
|
||||
this.prevTreeViewID = this.collectionTreeRow.id;
|
||||
// If we changed the set of collections, scroll to top
|
||||
if (this.collectionTreeRows) {
|
||||
let treeViewIDs = this.collectionTreeRows.map(row => row.id).sort();
|
||||
if (!Zotero.Utilities.arrayEquals(treeViewIDs, this.prevTreeViewIDs)) {
|
||||
this.tagListRef.current.scrollToTop();
|
||||
this.prevTreeViewIDs = treeViewIDs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,7 +121,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
}
|
||||
|
||||
/**
|
||||
* Safely fetch tags from the current collection tree row, returning [] on search error.
|
||||
* Safely fetch tags from the selected collection tree rows, returning [] on search error.
|
||||
* CollectionTreeRow.getTags() calls getSearchResults() under the hood, which throws
|
||||
* Zotero.CollectionTreeRow.SearchError if the underlying search query fails (e.g., a
|
||||
* saved search with invalid conditions). The tag selector should degrade gracefully in
|
||||
|
|
@ -124,7 +130,12 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
*/
|
||||
async _safeGetTags(...args) {
|
||||
try {
|
||||
return await this.collectionTreeRow.getTags(...args);
|
||||
let tags = (await Promise.all(
|
||||
this.collectionTreeRows.map(row => row.getTags(...args))
|
||||
)).flat();
|
||||
// Multiple rows (collections, or collections across libraries) can return
|
||||
// the same tag, so dedupe by name
|
||||
return this._dedupeTags(tags);
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof Zotero.CollectionTreeRow.SearchError) {
|
||||
|
|
@ -135,24 +146,78 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether more than one library is currently in scope (cross-library selection)
|
||||
*/
|
||||
get multiLibrary() {
|
||||
return this.libraryIDs.length > 1;
|
||||
}
|
||||
|
||||
_dedupeTags(tags) {
|
||||
let seen = new Set();
|
||||
let result = [];
|
||||
for (let tag of tags) {
|
||||
if (!seen.has(tag.tag)) {
|
||||
seen.add(tag.tag);
|
||||
result.push(tag);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags across the selected libraries (for "Display All Tags"), deduped by name
|
||||
*
|
||||
* @param {Number[]} [tagIDs] - Limit to the given tag IDs
|
||||
*/
|
||||
async _getAllTagsInLibraries(tagIDs) {
|
||||
let results = await Promise.all(
|
||||
this.libraryIDs.map(libraryID => Zotero.Tags.getAllWithin({ libraryID, tagIDs }))
|
||||
);
|
||||
return this._dedupeTags(results.flat());
|
||||
}
|
||||
|
||||
_getSelectedLibraryIDs(collectionTreeRows, fallbackLibraryID) {
|
||||
let ids = [];
|
||||
for (let row of collectionTreeRows || []) {
|
||||
let id = row.ref && row.ref.libraryID;
|
||||
if (id !== undefined && id !== null && !ids.includes(id)) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
if (!ids.length && fallbackLibraryID) {
|
||||
ids.push(fallbackLibraryID);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Colored tags are a per-library synced setting, so a unified cross-library list
|
||||
* can't coherently show them (the same color can mean different tags in different
|
||||
* libraries). Only show colored tags when a single library is in scope. The items
|
||||
* list still shows each item's own library's swatches via Item.getItemsListTags().
|
||||
*/
|
||||
_getScopeColors() {
|
||||
if (this.libraryIDs.length !== 1) {
|
||||
return new Map();
|
||||
}
|
||||
return Zotero.Tags.getColors(this.libraryIDs[0]);
|
||||
}
|
||||
|
||||
// Update trigger #1 (triggered by ZoteroPane)
|
||||
async onItemViewChanged({ collectionTreeRow, libraryID }) {
|
||||
async onItemViewChanged({ collectionTreeRows, libraryID }) {
|
||||
Zotero.debug('Updating tag selector from current view');
|
||||
|
||||
var prevLibraryID = this.libraryID;
|
||||
this.collectionTreeRow = collectionTreeRow;
|
||||
this.libraryID = libraryID;
|
||||
|
||||
|
||||
var prevLibraryIDs = this.libraryIDs;
|
||||
this.collectionTreeRows = collectionTreeRows;
|
||||
this.libraryIDs = this._getSelectedLibraryIDs(collectionTreeRows, libraryID);
|
||||
this.libraryID = this.libraryIDs[0];
|
||||
|
||||
var newState = {
|
||||
loaded: true
|
||||
};
|
||||
if (prevLibraryID != libraryID) {
|
||||
if (libraryID) {
|
||||
newState.tagColors = Zotero.Tags.getColors(libraryID);
|
||||
}
|
||||
else {
|
||||
newState.tagColors = new Map();
|
||||
}
|
||||
if (!Zotero.Utilities.arrayEquals(prevLibraryIDs, this.libraryIDs)) {
|
||||
newState.tagColors = this._getScopeColors();
|
||||
}
|
||||
var { tags, scope } = await this.getTagsAndScope();
|
||||
newState.tags = tags;
|
||||
|
|
@ -166,14 +231,14 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
if (ids.some(val => val.split('/')[1] == 'tagColors')) {
|
||||
Zotero.debug("Updating tag selector after tag color change");
|
||||
this.setState({
|
||||
tagColors: Zotero.Tags.getColors(this.libraryID)
|
||||
tagColors: this._getScopeColors()
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore anything other than deletes in duplicates view
|
||||
if (this.collectionTreeRow && this.collectionTreeRow.isDuplicates()) {
|
||||
if (this.collectionTreeRows?.[0]?.isDuplicates()) {
|
||||
switch (event) {
|
||||
case 'delete':
|
||||
case 'trash':
|
||||
|
|
@ -215,9 +280,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
for (let [type, tagIDs] of tagsByType) {
|
||||
changedTagsInScope.push(...await this._safeGetTags([type], tagIDs));
|
||||
if (this.displayAllTags) {
|
||||
changedTagsInView.push(
|
||||
...await Zotero.Tags.getAllWithin({ libraryID: this.libraryID, tagIDs })
|
||||
);
|
||||
changedTagsInView.push(...await this._getAllTagsInLibraries(tagIDs));
|
||||
}
|
||||
}
|
||||
if (!this.displayAllTags) {
|
||||
|
|
@ -340,7 +403,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
// The scope is all visible tags, not all tags in the library
|
||||
var scope = new Set(tags.map(t => t.tag));
|
||||
if (this.displayAllTags) {
|
||||
tags = await Zotero.Tags.getAll(this.libraryID);
|
||||
tags = await this._getAllTagsInLibraries();
|
||||
}
|
||||
|
||||
// If tags haven't changed, return previous array without sorting again
|
||||
|
|
@ -584,6 +647,14 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
for (let i = 0; i < tagContextMenu.childNodes.length; i++) {
|
||||
tagContextMenu.childNodes[i].disabled = this.state.viewOnly;
|
||||
}
|
||||
// In a cross-library selection, disable per-library actions that would only
|
||||
// affect one of the selected libraries. Deleting still works -- it removes the
|
||||
// tag from all selected libraries (see openDeletePrompt())
|
||||
if (this.multiLibrary) {
|
||||
for (let id of ['assign-color-tag', 'rename-tag', 'split-tag']) {
|
||||
document.getElementById(id).disabled = true;
|
||||
}
|
||||
}
|
||||
ev.preventDefault();
|
||||
|
||||
tagContextMenu.openPopupAtScreen(
|
||||
|
|
@ -804,12 +875,16 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
|
||||
var tagID = Zotero.Tags.getID(this.contextTag.name);
|
||||
|
||||
if (tagID) {
|
||||
await Zotero.Tags.removeFromLibrary(this.libraryID, tagID);
|
||||
}
|
||||
// If only a tag color setting, remove that
|
||||
else {
|
||||
await Zotero.Tags.setColor(this.libraryID, this.contextTag.name, false);
|
||||
// Delete from every selected library. removeFromLibrary() only clears the color
|
||||
// for tags that have items in the library, so also clear any remaining color-only
|
||||
// setting (e.g. a tag that's a real tag in one library but only colored in another)
|
||||
for (let libraryID of this.libraryIDs) {
|
||||
if (tagID) {
|
||||
await Zotero.Tags.removeFromLibrary(libraryID, tagID);
|
||||
}
|
||||
if (Zotero.Tags.getColors(libraryID).has(this.contextTag.name)) {
|
||||
await Zotero.Tags.setColor(libraryID, this.contextTag.name, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -140,7 +140,11 @@
|
|||
// editable library or group root (but not a feed) and not within a collection.
|
||||
// Revisit when/if we support nested condition sets in the UI.
|
||||
_canSaveInCurrentRow() {
|
||||
let collectionTreeRow = ZoteroPane.getCollectionTreeRow();
|
||||
let collectionTreeRows = ZoteroPane.getCollectionTreeRows();
|
||||
if (collectionTreeRows.length != 1) {
|
||||
return false;
|
||||
}
|
||||
let collectionTreeRow = collectionTreeRows[0];
|
||||
return collectionTreeRow.isLibrary(true)
|
||||
&& !collectionTreeRow.isFeed()
|
||||
&& collectionTreeRow.editable;
|
||||
|
|
@ -153,6 +157,23 @@
|
|||
if (libraryID) {
|
||||
this._search.libraryID = libraryID;
|
||||
}
|
||||
// Set the libraries the search applies to: a temporary search spans all
|
||||
// selected libraries, a saved search just its own. Used to scope value
|
||||
// autocomplete and, when more than one library is involved, to drop the
|
||||
// Collection/Saved Search condition, which can only resolve within one library.
|
||||
if (this.type === 'temporary') {
|
||||
let libraryIDs = [];
|
||||
for (let row of ZoteroPane.getCollectionTreeRows()) {
|
||||
let id = row.ref && row.ref.libraryID;
|
||||
if (id !== undefined && id !== null && !libraryIDs.includes(id)) {
|
||||
libraryIDs.push(id);
|
||||
}
|
||||
}
|
||||
this._searchElem.scopeLibraryIDs = libraryIDs;
|
||||
}
|
||||
else {
|
||||
this._searchElem.scopeLibraryIDs = [this._search.libraryID];
|
||||
}
|
||||
this._searchElem.search = this._search;
|
||||
this._saveButton.disabled = this.type === 'temporary' && !this._canSaveInCurrentRow();
|
||||
}
|
||||
|
|
@ -197,13 +218,13 @@
|
|||
return;
|
||||
}
|
||||
|
||||
let collectionTreeRow = ZoteroPane.getCollectionTreeRow();
|
||||
let collectionTreeRows = ZoteroPane.getCollectionTreeRows();
|
||||
if (!this._canSaveInCurrentRow()) {
|
||||
throw new Error('Can only save in an editable library root');
|
||||
}
|
||||
this._ensureSearch();
|
||||
|
||||
let libraryID = collectionTreeRow.ref.libraryID;
|
||||
|
||||
let libraryID = collectionTreeRows[0].ref.libraryID;
|
||||
let searches = await Zotero.Searches.getAll(libraryID);
|
||||
let prefix = Zotero.getString('pane.collections.untitled');
|
||||
let defaultName = Zotero.Utilities.Internal.getNextName(
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@
|
|||
}
|
||||
|
||||
get _renderDependencies() {
|
||||
return [...super._renderDependencies, this.collectionTreeRow?.id];
|
||||
return [...super._renderDependencies, this.collectionTreeRows?.map(o => o.id).join(',')];
|
||||
}
|
||||
|
||||
init() {
|
||||
|
|
@ -488,7 +488,7 @@
|
|||
}
|
||||
|
||||
if (this.item instanceof Zotero.FeedItem) {
|
||||
let row = ZoteroPane.getCollectionTreeRow();
|
||||
let row = ZoteroPane.getCollectionTreeRows()[0];
|
||||
if (row && row.isFeeds()) {
|
||||
fieldNames.unshift("feed");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,12 +96,12 @@
|
|||
this._tabID = tabID;
|
||||
}
|
||||
|
||||
get collectionTreeRow() {
|
||||
return this._collectionTreeRow;
|
||||
get collectionTreeRows() {
|
||||
return this._collectionTreeRows;
|
||||
}
|
||||
|
||||
set collectionTreeRow(collectionTreeRow) {
|
||||
this._collectionTreeRow = collectionTreeRow;
|
||||
set collectionTreeRows(collectionTreeRows) {
|
||||
this._collectionTreeRows = collectionTreeRows;
|
||||
}
|
||||
|
||||
get supportsPinning() {
|
||||
|
|
@ -238,7 +238,7 @@
|
|||
box.tabID = this.tabID;
|
||||
box.tabType = this.tabType;
|
||||
box.item = item;
|
||||
box.collectionTreeRow = this.collectionTreeRow;
|
||||
box.collectionTreeRows = this.collectionTreeRows;
|
||||
// Discard hidden panes
|
||||
if (box.hidden && box.discard) {
|
||||
box.discard();
|
||||
|
|
|
|||
|
|
@ -74,12 +74,12 @@
|
|||
this._data = data;
|
||||
}
|
||||
|
||||
get collectionTreeRow() {
|
||||
return this._collectionTreeRow;
|
||||
get collectionTreeRows() {
|
||||
return this._collectionTreeRows;
|
||||
}
|
||||
|
||||
set collectionTreeRow(val) {
|
||||
this._collectionTreeRow = val;
|
||||
set collectionTreeRows(val) {
|
||||
this._collectionTreeRows = val;
|
||||
}
|
||||
|
||||
get itemsView() {
|
||||
|
|
@ -150,7 +150,7 @@
|
|||
|
||||
notify(action, type) {
|
||||
if (type == 'item' && action == 'modify') {
|
||||
if (this.collectionTreeRow && this.collectionTreeRow.isFeedsOrFeed()) {
|
||||
if (this.collectionTreeRows?.[0]?.isFeedsOrFeed()) {
|
||||
this.updateReadLabel();
|
||||
}
|
||||
}
|
||||
|
|
@ -191,7 +191,7 @@
|
|||
this._itemDetails.tabID = "zotero-pane";
|
||||
this._itemDetails.tabType = "library";
|
||||
this._itemDetails.item = item;
|
||||
this._itemDetails.collectionTreeRow = this.collectionTreeRow;
|
||||
this._itemDetails.collectionTreeRows = this.collectionTreeRows;
|
||||
|
||||
this._itemDetails.render();
|
||||
|
||||
|
|
@ -229,7 +229,7 @@
|
|||
let count = this.data.length;
|
||||
|
||||
// Display duplicates merge interface in item pane
|
||||
if (this.collectionTreeRow.isDuplicates()) {
|
||||
if (this.collectionTreeRows[0].isDuplicates()) {
|
||||
if (!this.editable) {
|
||||
if (count) {
|
||||
msg = Zotero.getString('pane.item.duplicates.writeAccessRequired');
|
||||
|
|
@ -259,7 +259,7 @@
|
|||
if (count) {
|
||||
let key;
|
||||
// In the trash, we have to check the object type
|
||||
if (this.collectionTreeRow.isTrash()) {
|
||||
if (this.collectionTreeRows[0].isTrash()) {
|
||||
if (this.data.every(x => x instanceof Zotero.Collection)) {
|
||||
key = 'item-pane-message-collections-selected';
|
||||
}
|
||||
|
|
@ -280,7 +280,7 @@
|
|||
}
|
||||
else {
|
||||
let count = this.itemsView.rowCount;
|
||||
if (this.collectionTreeRow.isTrash()
|
||||
if (this.collectionTreeRows[0].isTrash()
|
||||
&& this.itemsView._rows?.some(
|
||||
x => x.ref instanceof Zotero.Collection || x.ref instanceof Zotero.Search
|
||||
)) {
|
||||
|
|
@ -315,7 +315,7 @@
|
|||
}
|
||||
|
||||
// My Publications buttons
|
||||
var isPublications = this.collectionTreeRow.isPublications();
|
||||
var isPublications = this.collectionTreeRows[0].isPublications();
|
||||
// Show in My Publications view if selected items are all notes or non-linked-file attachments
|
||||
var showMyPublicationsButtons = isPublications
|
||||
&& this.data.every((item) => {
|
||||
|
|
@ -331,13 +331,13 @@
|
|||
|
||||
// Trash button
|
||||
let nonDeletedItemsSelected = this.data.some(item => !item.deleted);
|
||||
if (this.collectionTreeRow.isTrash() && !nonDeletedItemsSelected) {
|
||||
if (this.collectionTreeRows[0].isTrash() && !nonDeletedItemsSelected) {
|
||||
container.renderCustomHead(this.renderTrashHead.bind(this));
|
||||
return;
|
||||
}
|
||||
|
||||
// Feed buttons
|
||||
if (this.collectionTreeRow.isFeedsOrFeed()) {
|
||||
if (this.collectionTreeRows[0].isFeedsOrFeed()) {
|
||||
container.renderCustomHead(this.renderFeedHead.bind(this));
|
||||
this.updateReadLabel();
|
||||
return;
|
||||
|
|
@ -413,7 +413,7 @@
|
|||
renderAnnotationsHead(data) {
|
||||
let { doc, append } = data;
|
||||
let button = doc.createXULElement("button");
|
||||
button.disabled = !this.collectionTreeRow.editable;
|
||||
button.disabled = !this.collectionTreeRows.every(o => o.editable);
|
||||
button.id = 'zotero-item-pane-note-from-annotations';
|
||||
if (Zotero.Items.getTopLevel(this.data).length == 1) {
|
||||
button.label = Zotero.getString('pane.items.menu.addNoteFromAnnotations');
|
||||
|
|
|
|||
|
|
@ -59,12 +59,12 @@ export class ItemPaneSectionElementBase extends XULElementBase {
|
|||
this.setAttribute('tabType', tabType);
|
||||
}
|
||||
|
||||
get collectionTreeRow() {
|
||||
return this._collectionTreeRow;
|
||||
get collectionTreeRows() {
|
||||
return this._collectionTreeRows;
|
||||
}
|
||||
|
||||
set collectionTreeRow(collectionTreeRow) {
|
||||
this._collectionTreeRow = collectionTreeRow;
|
||||
set collectionTreeRows(collectionTreeRows) {
|
||||
this._collectionTreeRows = collectionTreeRows;
|
||||
}
|
||||
|
||||
_syncRenderPending = false;
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ import { getCSSIcon } from 'components/icons';
|
|||
}
|
||||
|
||||
get _renderDependencies() {
|
||||
return [...super._renderDependencies, this.collectionTreeRow?.id];
|
||||
return [...super._renderDependencies, this.collectionTreeRows?.map(o => o.id).join(',')];
|
||||
}
|
||||
|
||||
init() {
|
||||
|
|
@ -155,7 +155,7 @@ import { getCSSIcon } from 'components/icons';
|
|||
}
|
||||
|
||||
let isCurrent = this.tabType === 'library'
|
||||
&& this.collectionTreeRow?.id == obj.treeViewID;
|
||||
&& this.collectionTreeRows.map(o => o.id).includes(obj.treeViewID);
|
||||
box.classList.toggle('current', isCurrent);
|
||||
|
||||
// Disable clicky if this is a context row or we're already in the library/collection it points to
|
||||
|
|
|
|||
|
|
@ -432,9 +432,9 @@
|
|||
case 'collection':
|
||||
{
|
||||
let rows = [];
|
||||
|
||||
|
||||
var libraryID = this.parent.search.libraryID;
|
||||
|
||||
|
||||
// Add collections
|
||||
let cols = Zotero.Collections.getByLibrary(libraryID, true);
|
||||
for (let col of cols) {
|
||||
|
|
@ -452,7 +452,7 @@
|
|||
image: Zotero.Collection.prototype.treeViewImage
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Add saved searches
|
||||
let searches = Zotero.Searches.getByLibrary(libraryID);
|
||||
for (let search of searches) {
|
||||
|
|
@ -513,7 +513,9 @@
|
|||
}
|
||||
|
||||
// Update field drop-down if applicable
|
||||
this.querySelector('#valuefield').update(conditionName, this.mode);
|
||||
this.querySelector('#valuefield').update(
|
||||
conditionName, this.mode, this.parent && this.parent.scopeLibraryIDs
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -594,7 +596,17 @@
|
|||
this.parent = parent;
|
||||
this.conditionID = condition.id;
|
||||
var menu = this.querySelector('#conditionsmenu');
|
||||
|
||||
|
||||
// Collection and saved search conditions resolve within a single library, so
|
||||
// remove the Collection condition (which also covers saved searches) when the
|
||||
// selection spans multiple libraries
|
||||
if (this.parent.scopeLibraryIDs && this.parent.scopeLibraryIDs.length > 1) {
|
||||
let collectionItem = menu.querySelector('menuitem[value="collection"]');
|
||||
if (collectionItem) {
|
||||
collectionItem.remove();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.parent.search) {
|
||||
this.dontupdate = true; //so that the search doesn't get updated while we are creating controls.
|
||||
var prefix = '';
|
||||
|
|
@ -863,7 +875,7 @@
|
|||
throw new Error('Invalid search textbox popup');
|
||||
}
|
||||
|
||||
update(condition, mode) {
|
||||
update(condition, mode, scopeLibraryIDs) {
|
||||
var textbox = this.querySelector('#search-textbox');
|
||||
var button = this.querySelector('#textbox-button');
|
||||
|
||||
|
|
@ -912,11 +924,15 @@
|
|||
|
||||
default:
|
||||
textbox.setAttribute('disableautocomplete', 'false');
|
||||
|
||||
// TODO: Provide current libraryID
|
||||
|
||||
var autocompleteParams = {
|
||||
fieldName: condition
|
||||
};
|
||||
// Scope suggestions to the selected libraries (the same set the
|
||||
// collection condition menu uses). Empty/unset falls back to all libraries.
|
||||
if (scopeLibraryIDs && scopeLibraryIDs.length) {
|
||||
autocompleteParams.libraryIDs = scopeLibraryIDs;
|
||||
}
|
||||
switch (condition) {
|
||||
case 'creator':
|
||||
case 'author':
|
||||
|
|
|
|||
|
|
@ -131,9 +131,10 @@ Zotero_File_Exporter.prototype.save = async function () {
|
|||
return;
|
||||
}
|
||||
|
||||
if(this.collection) {
|
||||
if (this.collection) {
|
||||
translation.setCollection(this.collection);
|
||||
} else if(this.items) {
|
||||
}
|
||||
else if (this.items) {
|
||||
translation.setItems(this.items);
|
||||
} else if(this.libraryID === undefined) {
|
||||
throw new Error('No export configured');
|
||||
|
|
@ -201,21 +202,27 @@ var Zotero_File_Interface = new function () {
|
|||
/*
|
||||
* exports a collection or saved search
|
||||
*/
|
||||
function exportCollection() {
|
||||
async function exportCollection() {
|
||||
var exporter = new Zotero_File_Exporter();
|
||||
|
||||
var collection = ZoteroPane_Local.getSelectedCollection();
|
||||
if(collection) {
|
||||
exporter.name = collection.getName();
|
||||
exporter.collection = collection;
|
||||
} else {
|
||||
var collections = ZoteroPane_Local.getSelectedCollections();
|
||||
if (collections.length == 1) {
|
||||
exporter.name = collections[0].getName();
|
||||
exporter.collection = collections[0];
|
||||
}
|
||||
else if (collections.length > 1) {
|
||||
exporter.name = collections.map(c => c.getName()).join(', ');
|
||||
exporter.items = await ZoteroPane.getUnfilteredItems();
|
||||
if (!exporter.items.length) throw ("No items to save");
|
||||
}
|
||||
else {
|
||||
// find sorted items
|
||||
exporter.items = ZoteroPane_Local.getSortedItems();
|
||||
if(!exporter.items) throw ("No items to save");
|
||||
if (!exporter.items) throw ("No items to save");
|
||||
|
||||
// find name
|
||||
var search = ZoteroPane_Local.getSelectedSavedSearch();
|
||||
if(search) {
|
||||
if (search) {
|
||||
exporter.name = search.name;
|
||||
}
|
||||
}
|
||||
|
|
@ -581,7 +588,7 @@ var Zotero_File_Interface = new function () {
|
|||
}
|
||||
|
||||
var libraryID = Zotero.Libraries.userLibraryID;
|
||||
var importCollection = null;
|
||||
var importCollections = [];
|
||||
try {
|
||||
let zp = Zotero.getActiveZoteroPane();
|
||||
libraryID = zp.getSelectedLibraryID();
|
||||
|
|
@ -589,7 +596,7 @@ var Zotero_File_Interface = new function () {
|
|||
await zp.collectionsView.selectLibrary(libraryID);
|
||||
}
|
||||
else if (!createNewCollection) {
|
||||
importCollection = zp.getSelectedCollection();
|
||||
importCollections = zp.getSelectedCollections();
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
|
|
@ -614,10 +621,11 @@ var Zotero_File_Interface = new function () {
|
|||
else {
|
||||
collectionName = defaultNewCollectionPrefix + " " + (new Date()).toLocaleString();
|
||||
}
|
||||
importCollection = new Zotero.Collection;
|
||||
let importCollection = new Zotero.Collection;
|
||||
importCollection.libraryID = libraryID;
|
||||
importCollection.name = collectionName;
|
||||
await importCollection.saveTx();
|
||||
importCollections = [importCollection];
|
||||
}
|
||||
|
||||
translation.setTranslator(translators[0]);
|
||||
|
|
@ -650,7 +658,7 @@ var Zotero_File_Interface = new function () {
|
|||
try {
|
||||
await translation.translate({
|
||||
libraryID,
|
||||
collections: importCollection ? [importCollection.id] : null,
|
||||
collections: importCollections.length ? importCollections.map(c => c.id) : null,
|
||||
linkFiles,
|
||||
saveOptions: {
|
||||
notifierQueue
|
||||
|
|
@ -726,13 +734,13 @@ var Zotero_File_Interface = new function () {
|
|||
* Creates a bibliography from a collection or saved search
|
||||
*/
|
||||
this.bibliographyFromCollection = async function () {
|
||||
var items = ZoteroPane.getSortedItems();
|
||||
var items = await ZoteroPane.getUnfilteredItems();
|
||||
|
||||
// Find collection name
|
||||
var name = false;
|
||||
var collection = ZoteroPane.getSelectedCollection();
|
||||
if (collection) {
|
||||
name = collection.name;
|
||||
var collections = ZoteroPane.getSelectedCollections();
|
||||
if (collections.length) {
|
||||
name = collections.map(c => c.name).join(', ');
|
||||
}
|
||||
else {
|
||||
let search = ZoteroPane.getSelectedSavedSearch();
|
||||
|
|
|
|||
|
|
@ -454,7 +454,7 @@ class Layout {
|
|||
else {
|
||||
// Make sure the collectionTreeRow is defined to
|
||||
// avoid errors thrown when filter is set on first load
|
||||
while (!this.itemsView.collectionTreeRow) {
|
||||
while (!this.itemsView.collectionTreeRows) {
|
||||
await Zotero.Promise.delay(10);
|
||||
}
|
||||
await this.refreshItemsList();
|
||||
|
|
@ -784,7 +784,8 @@ class LibraryLayout extends Layout {
|
|||
hideSources: ['duplicates', 'trash', 'feeds'],
|
||||
initialFolder: Zotero.Prefs.get("integration.citationDialogCollectionLastSelected"),
|
||||
onActivate: () => {},
|
||||
filterLibraryIDs: io.filterLibraryIDs
|
||||
filterLibraryIDs: io.filterLibraryIDs,
|
||||
multiSelect: true
|
||||
});
|
||||
// Add aria-description with instructions on what this collection tree is for
|
||||
// Voiceover announces the description placed on the actual tree when focus enters it
|
||||
|
|
@ -877,10 +878,18 @@ class LibraryLayout extends Layout {
|
|||
}
|
||||
|
||||
async _onCollectionSelection() {
|
||||
var collectionTreeRow = this.collectionsView.getRow(this.collectionsView.selection.focused);
|
||||
if (!this.collectionsView.selection.count) return;
|
||||
// Collection not changed
|
||||
if (this.itemsView && this.itemsView.collectionTreeRow && this.itemsView.collectionTreeRow.id == collectionTreeRow.id) {
|
||||
// Show the union of all selected collections (cross-library selections are
|
||||
// grouped by library in the items view, as in List mode)
|
||||
let selectedRows = [...this.collectionsView.selection.selected]
|
||||
.sort((a, b) => a - b)
|
||||
.map(index => this.collectionsView.getRow(index));
|
||||
// Collection selection not changed
|
||||
if (this.itemsView
|
||||
&& Zotero.Utilities.arrayEquals(
|
||||
selectedRows.map(row => row.id).sort(),
|
||||
this.itemsView.collectionTreeRows.map(row => row.id).sort()
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
// _onCollectionSelection will be called during initiation. It can take a while
|
||||
|
|
@ -888,41 +897,45 @@ class LibraryLayout extends Layout {
|
|||
if (currentLayout?.type !== "library") return;
|
||||
|
||||
this.itemsView.setItemsPaneMessage(Zotero.getString('pane.items.loading'));
|
||||
|
||||
// Load library data if necessary
|
||||
var library = Zotero.Libraries.get(collectionTreeRow.ref.libraryID);
|
||||
if (!library.getDataLoaded('item')) {
|
||||
Zotero.debug("Waiting for items to load for library " + library.libraryID);
|
||||
await library.waitForDataLoad('item');
|
||||
|
||||
// Load item data for each selected library if necessary
|
||||
for (let libraryID of new Set(selectedRows.map(row => row.ref.libraryID))) {
|
||||
let library = Zotero.Libraries.get(libraryID);
|
||||
if (!library.getDataLoaded('item')) {
|
||||
Zotero.debug("Waiting for items to load for library " + library.libraryID);
|
||||
await library.waitForDataLoad('item');
|
||||
}
|
||||
}
|
||||
|
||||
await this.itemsView.changeCollectionTreeRow({
|
||||
|
||||
// Restrict each collection's items to those relevant to the current citation mode
|
||||
let filterItemsForMode = async (items) => {
|
||||
// In add-note mode, note parent checks call item.getNotes(), which requires childItems
|
||||
if (DIALOG_STATE.isAddingNote()) {
|
||||
let regularItems = items.filter(item => SearchHandler.isItemWithNotes(item));
|
||||
if (regularItems.length) {
|
||||
await Zotero.Items.loadDataTypes(regularItems, ['childItems']);
|
||||
}
|
||||
// when citing notes, only keep notes or note parents
|
||||
items = items.filter(item => item.isNote() || item.getNotes().length);
|
||||
}
|
||||
// when adding annotations, only keep annotations, their attachments, and their top-level items
|
||||
if (DIALOG_STATE.isAddingAnnotations()) {
|
||||
return SearchHandler.keepItemsWithAnnotations(items);
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
await this.itemsView.changeCollectionTreeRows(selectedRows.map(collectionTreeRow => ({
|
||||
id: collectionTreeRow.id,
|
||||
getItems: async () => {
|
||||
let items = await collectionTreeRow.getItems();
|
||||
// In add-note mode, note parent checks call item.getNotes(), which requires childItems
|
||||
if (DIALOG_STATE.isAddingNote()) {
|
||||
let regularItems = items.filter(item => SearchHandler.isItemWithNotes(item));
|
||||
if (regularItems.length) {
|
||||
await Zotero.Items.loadDataTypes(regularItems, ['childItems']);
|
||||
}
|
||||
// when citing notes, only keep notes or note parents
|
||||
items = items.filter(item => item.isNote() || item.getNotes().length);
|
||||
}
|
||||
// when adding annotations, only keep annotations, their attachments, and their top-level items
|
||||
if (DIALOG_STATE.isAddingAnnotations()) {
|
||||
return SearchHandler.keepItemsWithAnnotations(items);
|
||||
}
|
||||
return items;
|
||||
},
|
||||
getItems: async () => filterItemsForMode(await collectionTreeRow.getItems()),
|
||||
isSearch: () => true,
|
||||
isSearchMode: () => true,
|
||||
setSearch: (searchText, mode) => collectionTreeRow.setSearch(searchText, mode),
|
||||
clearCache: () => collectionTreeRow.clearCache(),
|
||||
ref: collectionTreeRow.ref
|
||||
});
|
||||
})));
|
||||
await this.itemsView.setFilter('citation-search', SearchHandler.searchValue);
|
||||
|
||||
|
||||
this.itemsView.clearItemsPaneMessage();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1497,12 +1497,14 @@ var ItemTree = class ItemTree extends LibraryTree {
|
|||
let hasQuickSearch = !!this.collectionTreeRow.searchText;
|
||||
let hasTagFilters = this.collectionTreeRow.tags?.size > 0;
|
||||
if (hasQuickSearch || hasTagFilters) {
|
||||
// Clear all searches set on the collection tree row directly on
|
||||
// collectionTreeRow (vs using ZoteroPane functions) to avoid
|
||||
// Clear all searches set on the collection tree rows directly on
|
||||
// the rows (vs using ZoteroPane functions) to avoid
|
||||
// refreshing the itemTree multiple times at the same time, which can lead
|
||||
// to tag selector not showing all tags after quickSearch is cleared
|
||||
this.collectionTreeRow.setTags(new Set());
|
||||
this.collectionTreeRow.setSearch('');
|
||||
for (let collectionTreeRow of this.collectionTreeRows) {
|
||||
collectionTreeRow.setTags(new Set());
|
||||
collectionTreeRow.setSearch('');
|
||||
}
|
||||
// Clear quickSearch text field and tag selection without
|
||||
// rerunning search
|
||||
if (window.ZoteroPane.tagSelector) {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,22 @@ var LibraryTree = class LibraryTree extends React.Component {
|
|||
return this._waitForEvent('select');
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Create an element where we can create drag images to be displayed next to the cursor while dragging
|
||||
// since for multiple item drags we need to display all the elements
|
||||
let elem = this._dragImageContainer = document.createElement("div");
|
||||
elem.style.width = "100%";
|
||||
elem.style.height = "2000px";
|
||||
elem.style.position = "absolute";
|
||||
elem.style.top = "-10000px";
|
||||
elem.className = "drag-image-container";
|
||||
this.domEl.appendChild(elem);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.domEl.removeChild(this._dragImageContainer);
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
// Async operations might attempt to update the react components
|
||||
// after window close in tests, which will cause unnecessary crashing
|
||||
|
|
@ -118,6 +134,28 @@ var LibraryTree = class LibraryTree extends React.Component {
|
|||
return this._rowMap[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected tree rows
|
||||
*/
|
||||
getSelectedRows() {
|
||||
var indexes = this.selection ? Array.from(this.selection.selected) : [];
|
||||
indexes = indexes.filter(index => index < this._rows.length);
|
||||
try {
|
||||
return indexes.map(index => this.getRow(index));
|
||||
}
|
||||
catch (e) {
|
||||
Zotero.debug(indexes);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected objects, including collections and searches in the trash in item trees
|
||||
*/
|
||||
getSelectedObjects() {
|
||||
return this.getSelectedRows().map(row => row.ref);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a tree row to the main array, update the row count, tell the treebox that the row
|
||||
* count changed, and update the row map
|
||||
|
|
@ -253,6 +291,27 @@ var LibraryTree = class LibraryTree extends React.Component {
|
|||
}
|
||||
event.dataTransfer.dropEffect = effect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a drag using HTML 5 Drag and Drop
|
||||
*/
|
||||
onDragStart(event, index) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
onDragEnd(_event, _index) {
|
||||
this._dragImageContainer.innerHTML = "";
|
||||
}
|
||||
};
|
||||
|
||||
Zotero.Utilities.Internal.makeClassEventDispatcher(LibraryTree);
|
||||
|
|
|
|||
|
|
@ -73,8 +73,8 @@ var Zotero_Lookup = new function () {
|
|||
else {
|
||||
try {
|
||||
libraryID = ZoteroPane.getSelectedLibraryID();
|
||||
let collection = ZoteroPane.getSelectedCollection();
|
||||
collections = collection ? [collection.id] : false;
|
||||
let selectedCollections = ZoteroPane.getSelectedCollections();
|
||||
collections = selectedCollections.length ? selectedCollections.map(c => c.id) : false;
|
||||
}
|
||||
catch (e) {
|
||||
/** TODO: handle this **/
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ async function onLoad() {
|
|||
}
|
||||
|
||||
let itemID = parseInt(io.itemID);
|
||||
let collectionID = parseInt(io.collectionID);
|
||||
let collectionIDs = io.collectionIDs.split(",").filter(x => x).map(x => parseInt(x));
|
||||
let parentItemKey = io.parentItemKey;
|
||||
let ref;
|
||||
|
||||
|
|
@ -65,8 +65,9 @@ async function onLoad() {
|
|||
ref = Zotero.Items.getByLibraryAndKey(parentItemKey);
|
||||
noteEditor.parentItem = ref;
|
||||
}
|
||||
else if (collectionID && collectionID != 'undefined') {
|
||||
noteEditor.collection = Zotero.Collections.get(collectionID);
|
||||
else if (collectionIDs.length) {
|
||||
// DEBUG: Was 'collection', but seemingly unused?
|
||||
noteEditor.collections = Zotero.Collections.get(collectionIDs);
|
||||
}
|
||||
noteEditor.refresh();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,52 +25,69 @@
|
|||
|
||||
|
||||
var Zotero_Report_Interface = new function() {
|
||||
/*
|
||||
* Load a report for the currently selected collection
|
||||
/**
|
||||
* Load a report for the selected collections/searches
|
||||
*/
|
||||
this.loadCollectionReport = function () {
|
||||
var libraryID = ZoteroPane_Local.getSelectedLibraryID();
|
||||
var collections = ZoteroPane_Local.getSelectedCollections();
|
||||
var items = collections.length
|
||||
? _getCollectionItems(collections)
|
||||
: ZoteroPane_Local.getSortedItems();
|
||||
if (!items.length) {
|
||||
throw new Error('No items in selected collections');
|
||||
}
|
||||
|
||||
var sortColumn = ZoteroPane_Local.getSortField();
|
||||
var queryString = '?sort=' + sortColumn
|
||||
+ '&direction=' + (ZoteroPane.getSortDirection() == 1 ? 'asc' : 'desc');
|
||||
|
||||
var url = 'zotero://report/';
|
||||
|
||||
var source = ZoteroPane_Local.getSelectedCollection();
|
||||
if (!source) {
|
||||
source = ZoteroPane_Local.getSelectedSavedSearch();
|
||||
}
|
||||
if (!source) {
|
||||
throw new Error('No collection currently selected');
|
||||
}
|
||||
|
||||
url += Zotero.API.getLibraryPrefix(source.libraryID) + '/';
|
||||
|
||||
if (source instanceof Zotero.Collection) {
|
||||
url += 'collections/' + source.key;
|
||||
}
|
||||
else {
|
||||
url += 'searches/' + source.key;
|
||||
}
|
||||
|
||||
url += '/items' + queryString;
|
||||
var url = 'zotero://report/'
|
||||
+ Zotero.API.getLibraryPrefix(libraryID) + '/'
|
||||
+ 'items?sort=' + sortColumn
|
||||
+ '&direction=' + (ZoteroPane.getSortDirection() == 1 ? 'asc' : 'desc')
|
||||
+ '&itemKey=' + items.map(item => item.key).join(',');
|
||||
|
||||
Zotero.openInViewer(url, { allowJavaScript: false });
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
/**
|
||||
* Load a report for the currently selected items
|
||||
*/
|
||||
this.loadItemReport = function () {
|
||||
var libraryID = ZoteroPane_Local.getSelectedLibraryID();
|
||||
var items = ZoteroPane_Local.getSelectedItems();
|
||||
|
||||
if (!items || !items.length) {
|
||||
throw new Error('No items currently selected');
|
||||
}
|
||||
|
||||
var url = 'zotero://report/' + Zotero.API.getLibraryPrefix(libraryID) + '/items'
|
||||
+ '?itemKey=' + items.map(item => item.key).join(',');
|
||||
var libraryID = items[0].libraryID;
|
||||
var url = 'zotero://report/'
|
||||
+ Zotero.API.getLibraryPrefix(libraryID) + '/'
|
||||
+ 'items?itemKey=' + items.map(item => item.key).join(',');
|
||||
|
||||
Zotero.openInViewer(url, { allowJavaScript: false });
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all items from the given collections, respecting recursiveCollections
|
||||
*/
|
||||
function _getCollectionItems(collections) {
|
||||
var items = new Set();
|
||||
var recursive = Zotero.Prefs.get('recursiveCollections');
|
||||
for (let collection of collections) {
|
||||
for (let item of collection.getChildItems()) {
|
||||
items.add(item);
|
||||
}
|
||||
if (recursive) {
|
||||
for (let desc of collection.getDescendents(false, 'collection')) {
|
||||
let col = Zotero.Collections.get(desc.id);
|
||||
if (col) {
|
||||
for (let item of col.getChildItems()) {
|
||||
items.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...items];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ var onCollectionSelected = async function () {
|
|||
await library.waitForDataLoad('item');
|
||||
}
|
||||
|
||||
await itemsView.changeCollectionTreeRow(collectionTreeRow);
|
||||
await itemsView.changeCollectionTreeRows([collectionTreeRow]);
|
||||
|
||||
itemsView.clearItemsPaneMessage();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -324,7 +324,12 @@ Zotero.CollectionTreeRow.prototype.getTrashedCollections = async function () {
|
|||
};
|
||||
|
||||
|
||||
Zotero.CollectionTreeRow.prototype.getItems = async function () {
|
||||
/**
|
||||
* @param {Object} [options]
|
||||
* @param {Boolean} [options.unfiltered=false] - If true, ignore quicksearch, tag, and
|
||||
* advanced search filters
|
||||
*/
|
||||
Zotero.CollectionTreeRow.prototype.getItems = async function (options = {}) {
|
||||
switch (this.type) {
|
||||
// Fake results if this is a shared library
|
||||
case 'share':
|
||||
|
|
@ -334,7 +339,7 @@ Zotero.CollectionTreeRow.prototype.getItems = async function () {
|
|||
return this.ref.getItems();
|
||||
}
|
||||
|
||||
var ids = await this.getSearchResults();
|
||||
var ids = await this.getSearchResults(false, { unfiltered: options.unfiltered });
|
||||
|
||||
// Filter out items that exist in the items table (where search results come from) but that haven't
|
||||
// yet been registered. This helps prevent unloaded-data crashes when switching collections while
|
||||
|
|
@ -353,7 +358,22 @@ Zotero.CollectionTreeRow.prototype.getItems = async function () {
|
|||
return Zotero.Items.getAsync(ids);
|
||||
};
|
||||
|
||||
Zotero.CollectionTreeRow.prototype.getSearchResults = async function (asTempTable) {
|
||||
/**
|
||||
* @param {Boolean} [asTempTable=false]
|
||||
* @param {Object} [options]
|
||||
* @param {Boolean} [options.unfiltered=false] - If true, ignore quicksearch, tag, and
|
||||
* advanced search filters and bypass the cache
|
||||
*/
|
||||
Zotero.CollectionTreeRow.prototype.getSearchResults = async function (asTempTable, options = {}) {
|
||||
if (options.unfiltered) {
|
||||
let s = await this.getSearchObject({ unfiltered: true });
|
||||
let ids = await s.search();
|
||||
if (asTempTable) {
|
||||
return Zotero.Search.idsToTempTable(ids);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
if (!this._cachedResults) {
|
||||
let s = await this.getSearchObject();
|
||||
try {
|
||||
|
|
@ -378,9 +398,13 @@ Zotero.CollectionTreeRow.prototype.getSearchResults = async function (asTempTabl
|
|||
* Returns the search object for the currently display
|
||||
*
|
||||
* This accounts for the collection, saved search, quicksearch, tags, etc.
|
||||
*
|
||||
* @param {Object} [options]
|
||||
* @param {Boolean} [options.unfiltered=false] - If true, ignore quicksearch, tag, and
|
||||
* advanced search filters and bypass the cache
|
||||
*/
|
||||
Zotero.CollectionTreeRow.prototype.getSearchObject = async function () {
|
||||
if (this._cachedSearch) {
|
||||
Zotero.CollectionTreeRow.prototype.getSearchObject = async function (options = {}) {
|
||||
if (!options.unfiltered && this._cachedSearch) {
|
||||
return this._cachedSearch;
|
||||
}
|
||||
|
||||
|
|
@ -403,18 +427,20 @@ Zotero.CollectionTreeRow.prototype.getSearchObject = async function () {
|
|||
}
|
||||
else if (this.isDuplicates()) {
|
||||
s = await this.ref.getSearchObject();
|
||||
let tmpTable;
|
||||
for (let id in s.conditions) {
|
||||
let c = s.conditions[id];
|
||||
if (c.condition == 'tempTable') {
|
||||
tmpTable = c.value;
|
||||
break;
|
||||
if (!options.unfiltered) {
|
||||
let tmpTable;
|
||||
for (let id in s.conditions) {
|
||||
let c = s.conditions[id];
|
||||
if (c.condition == 'tempTable') {
|
||||
tmpTable = c.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Called by ItemTreeView::unregister()
|
||||
this.onUnload = async function () {
|
||||
await Zotero.DB.queryAsync(`DROP TABLE IF EXISTS ${tmpTable}`, false, { noCache: true });
|
||||
};
|
||||
}
|
||||
// Called by ItemTreeView::unregister()
|
||||
this.onUnload = async function () {
|
||||
await Zotero.DB.queryAsync(`DROP TABLE IF EXISTS ${tmpTable}`, false, { noCache: true });
|
||||
};
|
||||
}
|
||||
else {
|
||||
s = new Zotero.Search();
|
||||
|
|
@ -464,21 +490,23 @@ Zotero.CollectionTreeRow.prototype.getSearchObject = async function () {
|
|||
}
|
||||
s2.setScope(s, includeScopeChildren);
|
||||
|
||||
// Add Quick Search unless advanced search is enabled
|
||||
if (this.searchText && !this.advancedSearch) {
|
||||
let cond = 'quicksearch-'
|
||||
+ (this.searchMode || Zotero.Prefs.get('search.quicksearch-mode'));
|
||||
s2.addCondition(cond, 'contains', this.searchText);
|
||||
}
|
||||
if (!options.unfiltered) {
|
||||
// Add Quick Search unless advanced search is enabled
|
||||
if (this.searchText && !this.advancedSearch) {
|
||||
let cond = 'quicksearch-'
|
||||
+ (this.searchMode || Zotero.Prefs.get('search.quicksearch-mode'));
|
||||
s2.addCondition(cond, 'contains', this.searchText);
|
||||
}
|
||||
|
||||
if (this.tags){
|
||||
for (let tag of this.tags) {
|
||||
s2.addCondition('tag', 'is', tag);
|
||||
if (this.tags) {
|
||||
for (let tag of this.tags) {
|
||||
s2.addCondition('tag', 'is', tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let s3;
|
||||
if (this.advancedSearch) {
|
||||
if (!options.unfiltered && this.advancedSearch) {
|
||||
if (this.advancedSearch.libraryID === null) {
|
||||
// A library-less search (Feeds pseudo-library) can't be clone()d
|
||||
s3 = new Zotero.Search();
|
||||
|
|
@ -506,7 +534,9 @@ Zotero.CollectionTreeRow.prototype.getSearchObject = async function () {
|
|||
s3 = s2;
|
||||
}
|
||||
|
||||
this._cachedSearch = s3;
|
||||
if (!options.unfiltered) {
|
||||
this._cachedSearch = s3;
|
||||
}
|
||||
return s3;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ Zotero.Notes = new function () {
|
|||
* @returns {Promise<Zotero.EditorInstance | null>} Instance of Zotero.EditorInstance for the note.
|
||||
* If the note tab is opened in background (unloaded), returns null.
|
||||
*/
|
||||
this.open = async function (itemID, location, { title, tabIndex, tabID, openInBackground, openInWindow, allowDuplicate, preventJumpback, parentItemKey } = {}) {
|
||||
this.open = async function (itemID, location, { title, tabIndex, tabID, openInBackground, openInWindow, allowDuplicate, preventJumpback, parentItemKey, collections } = {}) {
|
||||
let { libraryID } = Zotero.Items.getLibraryAndKeyFromID(itemID);
|
||||
let library = Zotero.Libraries.get(libraryID);
|
||||
let win = Zotero.getMainWindow();
|
||||
|
|
@ -123,7 +123,8 @@ Zotero.Notes = new function () {
|
|||
name = 'zotero-note-' + itemID;
|
||||
}
|
||||
|
||||
let io = { itemID, parentItemKey, location, _initPromise: Zotero.Promise.defer() };
|
||||
let collectionIDs = collections ? collections.map(c => c.id).join(',') : '';
|
||||
let io = { itemID, parentItemKey, collectionIDs, location, _initPromise: Zotero.Promise.defer() };
|
||||
Services.ww.openWindow(
|
||||
win,
|
||||
'chrome://zotero/content/note.xhtml',
|
||||
|
|
|
|||
|
|
@ -35,8 +35,13 @@ Zotero.Server.Connector = {
|
|||
|
||||
if (zp && zp.collectionsView) {
|
||||
if (allowReadOnly || zp.collectionsView.editable && allowFilesReadOnly || zp.collectionsView.filesEditable) {
|
||||
// The Connector saves to a single target, so derive both the library and the
|
||||
// collection from the focused row. A multiple-collection selection in the pane
|
||||
// isn't expressible here yet, and getSelectedCollection() could otherwise return
|
||||
// a collection from a different library than the focused row.
|
||||
let treeRow = zp.collectionsView.selectedTreeRow;
|
||||
library = Zotero.Libraries.get(zp.getSelectedLibraryID());
|
||||
collection = zp.getSelectedCollection();
|
||||
collection = treeRow && treeRow.isCollection() ? treeRow.ref : null;
|
||||
editable = zp.collectionsView.editable;
|
||||
}
|
||||
// If not editable, switch to My Library if it exists and is editable
|
||||
|
|
|
|||
|
|
@ -1086,11 +1086,11 @@ Zotero.Translate.ItemGetter.prototype = {
|
|||
setCollection: function (collection, getChildCollections) {
|
||||
// get items in this collection
|
||||
var items = new Set(collection.getChildItems());
|
||||
|
||||
|
||||
if (getChildCollections) {
|
||||
// Get child collections
|
||||
this._collectionsLeft = Zotero.Collections.getByParent(collection.id);
|
||||
|
||||
|
||||
// Get items in all descendant collections
|
||||
let descendantCollections = Zotero.Collections.getByParent(collection.id, true);
|
||||
for (let collection of descendantCollections) {
|
||||
|
|
@ -1098,7 +1098,7 @@ Zotero.Translate.ItemGetter.prototype = {
|
|||
childItems.forEach(item => items.add(item));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this._itemsLeft = Array.from(items.values());
|
||||
this._itemsLeft.sort((a, b) => a.id - b.id);
|
||||
this.numItems = this._itemsLeft.length;
|
||||
|
|
|
|||
|
|
@ -58,7 +58,15 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
|
|||
throw new Error("Invalid JSON passed to autocomplete");
|
||||
}
|
||||
var [fieldName, , subField] = searchParams.fieldName.split("-");
|
||||
|
||||
|
||||
// Library scope: accept a single libraryID (e.g. the item pane and tags box) or an
|
||||
// array of libraryIDs (advanced search across a multi-library selection). An empty
|
||||
// array means no library filter (suggest values from all libraries).
|
||||
var libraryIDs = searchParams.libraryIDs
|
||||
|| (searchParams.libraryID ? [searchParams.libraryID] : []);
|
||||
// Bare '?' placeholder list, e.g. "?, ?, ?", for sites that push params sequentially
|
||||
var libraryPlaceholders = libraryIDs.map(() => '?').join(', ');
|
||||
|
||||
var resultsCallback;
|
||||
|
||||
switch (fieldName) {
|
||||
|
|
@ -68,10 +76,10 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
|
|||
case 'tag':
|
||||
var sql = "SELECT DISTINCT name AS val, NULL AS id FROM tags WHERE name LIKE ? ESCAPE '\\'";
|
||||
var sqlParams = [Zotero.DB.escapeSQLExpression(searchString) + '%'];
|
||||
if (searchParams.libraryID) {
|
||||
if (libraryIDs.length) {
|
||||
sql += " AND tagID IN (SELECT tagID FROM itemTags JOIN items USING (itemID) "
|
||||
+ "WHERE libraryID=?)";
|
||||
sqlParams.push(searchParams.libraryID);
|
||||
+ `WHERE libraryID IN (${libraryPlaceholders}))`;
|
||||
sqlParams.push(...libraryIDs);
|
||||
}
|
||||
if (searchParams.itemID) {
|
||||
sql += " AND name NOT IN (SELECT name FROM tags WHERE tagID IN ("
|
||||
|
|
@ -93,10 +101,10 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
|
|||
var sql = "SELECT DISTINCT CASE fieldMode WHEN 1 THEN lastName "
|
||||
+ "WHEN 0 THEN firstName || ' ' || lastName END AS val, NULL AS id "
|
||||
+ "FROM creators ";
|
||||
if (fieldName != 'creator' || searchParams.libraryID) {
|
||||
if (fieldName != 'creator' || libraryIDs.length) {
|
||||
sql += "JOIN itemCreators USING (creatorID) ";
|
||||
}
|
||||
if (searchParams.libraryID) {
|
||||
if (libraryIDs.length) {
|
||||
sql += "JOIN items USING (itemID) ";
|
||||
}
|
||||
sql += "WHERE CASE fieldMode "
|
||||
|
|
@ -108,9 +116,9 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
|
|||
sql += "AND creatorTypeID=? ";
|
||||
sqlParams.push(Zotero.CreatorTypes.getID(fieldName));
|
||||
}
|
||||
if (searchParams.libraryID) {
|
||||
sql += ` AND libraryID=? `;
|
||||
sqlParams.push(searchParams.libraryID);
|
||||
if (libraryIDs.length) {
|
||||
sql += ` AND libraryID IN (${libraryPlaceholders}) `;
|
||||
sqlParams.push(...libraryIDs);
|
||||
}
|
||||
sql += "ORDER BY val";
|
||||
}
|
||||
|
|
@ -137,7 +145,7 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
|
|||
}
|
||||
|
||||
var fromSQL = " FROM creators "
|
||||
if (searchParams.libraryID) {
|
||||
if (libraryIDs.length) {
|
||||
fromSQL += "JOIN itemCreators USING (creatorID) JOIN items USING (itemID) ";
|
||||
}
|
||||
fromSQL += "WHERE " + subField + " LIKE ?1 AND fieldMode=?2";
|
||||
|
|
@ -155,11 +163,11 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
|
|||
}
|
||||
fromSQL += ")";
|
||||
}
|
||||
if (searchParams.libraryID) {
|
||||
fromSQL += ` AND libraryID=?${sqlParams.length + 1}`;
|
||||
sqlParams.push(searchParams.libraryID);
|
||||
if (libraryIDs.length) {
|
||||
fromSQL += ` AND libraryID IN (${libraryIDs.map((_, i) => '?' + (sqlParams.length + 1 + i)).join(', ')})`;
|
||||
sqlParams.push(...libraryIDs);
|
||||
}
|
||||
|
||||
|
||||
sql += fromSQL;
|
||||
|
||||
// If double-field mode, include matches for just this field
|
||||
|
|
@ -179,9 +187,9 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
|
|||
var sql = "SELECT DISTINCT DATE(" + fieldName + ", 'localtime') AS val, NULL AS id "
|
||||
+ "FROM items WHERE " + fieldName + " LIKE ? ";
|
||||
var sqlParams = [searchString + '%'];
|
||||
if (searchParams.libraryID) {
|
||||
sql += "AND libraryID=? ";
|
||||
sqlParams.push(searchParams.libraryID);
|
||||
if (libraryIDs.length) {
|
||||
sql += `AND libraryID IN (${libraryPlaceholders}) `;
|
||||
sqlParams.push(...libraryIDs);
|
||||
}
|
||||
sql += "ORDER BY " + fieldName;
|
||||
|
||||
|
|
@ -191,14 +199,14 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
|
|||
var fieldID = Zotero.ItemFields.getID('accessDate');
|
||||
|
||||
var sql = "SELECT DISTINCT DATE(value, 'localtime') AS val, NULL AS id FROM itemData ";
|
||||
if (searchParams.libraryID) {
|
||||
if (libraryIDs.length) {
|
||||
sql += "JOIN items USING (itemID) ";
|
||||
}
|
||||
sql += "WHERE fieldID=? AND value LIKE ? ";
|
||||
var sqlParams = [fieldID, searchString + '%'];
|
||||
if (searchParams.libraryID) {
|
||||
sql += "AND libraryID=? ";
|
||||
sqlParams.push(searchParams.libraryID);
|
||||
if (libraryIDs.length) {
|
||||
sql += `AND libraryID IN (${libraryPlaceholders}) `;
|
||||
sqlParams.push(...libraryIDs);
|
||||
}
|
||||
sql += "ORDER BY value";
|
||||
|
||||
|
|
@ -218,7 +226,7 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
|
|||
var valueField = fieldName == 'date' ? 'SUBSTR(value, 12, 100)' : 'value';
|
||||
|
||||
var sql = "SELECT DISTINCT " + valueField + " AS val, NULL AS id FROM itemData ";
|
||||
if (searchParams.libraryID) {
|
||||
if (libraryIDs.length) {
|
||||
sql += "JOIN items USING (itemID) ";
|
||||
}
|
||||
sql += "JOIN itemDataValues USING (valueID) "
|
||||
|
|
@ -230,10 +238,10 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
|
|||
+ "NATURAL JOIN itemDataValues WHERE fieldID=?1 AND itemID=?3) ";
|
||||
sqlParams.push(searchParams.itemID);
|
||||
}
|
||||
// Limit to specific library
|
||||
if (searchParams.libraryID) {
|
||||
sql += `AND libraryID=?${sqlParams.length + 1} `;
|
||||
sqlParams.push(searchParams.libraryID);
|
||||
// Limit to specific libraries
|
||||
if (libraryIDs.length) {
|
||||
sql += `AND libraryID IN (${libraryIDs.map((_, i) => '?' + (sqlParams.length + 1 + i)).join(', ')}) `;
|
||||
sqlParams.push(...libraryIDs);
|
||||
}
|
||||
sql += "ORDER BY value";
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1030,13 +1030,13 @@
|
|||
|
||||
<!-- Tag Selector -->
|
||||
<menupopup id="tag-menu">
|
||||
<menuitem label="&zotero.tagSelector.assignColor;"
|
||||
<menuitem id="assign-color-tag" label="&zotero.tagSelector.assignColor;"
|
||||
oncommand="ZoteroPane.tagSelector.openColorPickerWindow(); event.stopPropagation();"/>
|
||||
<menuitem label="&zotero.tagSelector.renameTag;"
|
||||
<menuitem id="rename-tag" label="&zotero.tagSelector.renameTag;"
|
||||
oncommand="ZoteroPane.tagSelector.openRenamePrompt(); event.stopPropagation();"/>
|
||||
<menuitem id="split-tag" label="&zotero.tagSelector.splitTag;"
|
||||
oncommand="ZoteroPane.tagSelector.openTagSplitterWindow(event)"/>
|
||||
<menuitem label="&zotero.tagSelector.deleteTag;"
|
||||
<menuitem id="delete-tag" label="&zotero.tagSelector.deleteTag;"
|
||||
oncommand="ZoteroPane.tagSelector.openDeletePrompt(); event.stopPropagation();"/>
|
||||
</menupopup>
|
||||
<menupopup id="tag-selector-view-settings-menu"
|
||||
|
|
@ -1048,9 +1048,14 @@
|
|||
var libraryID = ZoteroPane.tagSelector.libraryID;
|
||||
var library = Zotero.Libraries.get(libraryID);
|
||||
var enabled = false;
|
||||
if (library.editable) {
|
||||
if ((await Zotero.Tags.getAutomaticInLibrary(libraryID)).length) {
|
||||
enabled = true;
|
||||
// 'Delete Automatic Tags in This Library' is per-library, so disable it
|
||||
// when the selection spans multiple libraries (nested ifs to avoid '&&'
|
||||
// in this XML attribute)
|
||||
if (!ZoteroPane.tagSelector.multiLibrary) {
|
||||
if (library.editable) {
|
||||
if ((await Zotero.Tags.getAutomaticInLibrary(libraryID)).length) {
|
||||
enabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
document.getElementById('delete-automatic-tags').disabled = !enabled;
|
||||
|
|
|
|||
|
|
@ -338,6 +338,8 @@ import-online-relink-only =
|
|||
import-online-relink-kb = { general-more-information }
|
||||
import-online-connection-error = { -app-name } could not connect to { $targetApp }. Please check your internet connection and try again.
|
||||
|
||||
tab-title-multiple-collections = Multiple
|
||||
|
||||
items-table-cell-notes =
|
||||
.aria-label = { $count ->
|
||||
[one] { $count } Note
|
||||
|
|
|
|||
|
|
@ -99,6 +99,20 @@ $universal-icons: (
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-radius: 0;
|
||||
|
||||
&.first-selected {
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
}
|
||||
|
||||
&.last-selected {
|
||||
border-bottom-left-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.row.flashing:not(.drop) {
|
||||
|
|
|
|||
|
|
@ -135,6 +135,44 @@ describe("Advanced Search", function () {
|
|||
await saved.eraseTx();
|
||||
});
|
||||
|
||||
it("should scope results to multiple selected collections", async function () {
|
||||
var collection1 = await createDataObject('collection');
|
||||
var collection2 = await createDataObject('collection');
|
||||
var inFirst = await createDataObject('item', { title: "foo bar", collections: [collection1.id] });
|
||||
var inSecond = await createDataObject('item', { title: "foo baz", collections: [collection2.id] });
|
||||
var noMatch = await createDataObject('item', { title: "qux", collections: [collection2.id] });
|
||||
var notInCollections = await createDataObject('item', { title: "foo qux" });
|
||||
|
||||
var cv = zp.collectionsView;
|
||||
await cv.selectByID("C" + collection1.id);
|
||||
await waitForItemsLoad(win);
|
||||
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collection2.id));
|
||||
await zp.onCollectionSelected();
|
||||
await zp.itemsView.waitForLoad();
|
||||
|
||||
await zp.toggleAdvancedSearchState('open');
|
||||
var s = new Zotero.Search();
|
||||
s.libraryID = Zotero.Libraries.userLibraryID;
|
||||
s.addCondition('title', 'contains', 'foo');
|
||||
deck.pane.search = s;
|
||||
|
||||
var iv = zp.itemsView;
|
||||
await deck.pane.submit();
|
||||
await iv.waitForLoad();
|
||||
|
||||
// Matching items from both collections, but not the matching item
|
||||
// outside the selected collections
|
||||
assert.equal(iv.rowCount, 2);
|
||||
assert.isNumber(iv.getRowIndexByID(inFirst.id));
|
||||
assert.isNumber(iv.getRowIndexByID(inSecond.id));
|
||||
|
||||
await zp.setAdvancedSearchState('closed');
|
||||
await selectLibrary(win);
|
||||
|
||||
await Zotero.Items.erase([inFirst.id, inSecond.id, noMatch.id, notInCollections.id]);
|
||||
await Zotero.Collections.erase([collection1.id, collection2.id]);
|
||||
});
|
||||
|
||||
it("should search across feeds in Feeds view", async function () {
|
||||
let feed = await createFeed();
|
||||
let feedItem = await createDataObject('feedItem', { libraryID: feed.libraryID, setTitle: true }, { skipSelect: true });
|
||||
|
|
@ -158,9 +196,59 @@ describe("Advanced Search", function () {
|
|||
|
||||
await zp.setAdvancedSearchState('closed');
|
||||
await selectLibrary(win);
|
||||
|
||||
|
||||
await feed.eraseTx();
|
||||
});
|
||||
|
||||
it("should scope value autocomplete to the given libraries", async function () {
|
||||
// Run the 'zotero' autocomplete provider directly with given params
|
||||
function autocomplete(searchString, params) {
|
||||
return new Promise((resolve) => {
|
||||
let search = Cc["@mozilla.org/autocomplete/search;1?name=zotero"]
|
||||
.createInstance(Ci.nsIAutoCompleteSearch);
|
||||
let listener = {
|
||||
onSearchResult(_search, result) {
|
||||
// Ignore intermediate (ongoing) updates
|
||||
if (result.searchResult == Ci.nsIAutoCompleteResult.RESULT_SUCCESS_ONGOING) {
|
||||
return;
|
||||
}
|
||||
let values = [];
|
||||
for (let i = 0; i < result.matchCount; i++) {
|
||||
values.push(result.getValueAt(i));
|
||||
}
|
||||
resolve(values);
|
||||
}
|
||||
};
|
||||
search.startSearch(searchString, JSON.stringify(params), null, listener);
|
||||
});
|
||||
}
|
||||
|
||||
var group = await getGroup();
|
||||
var groupLibraryID = group.libraryID;
|
||||
// Autocomplete matches a value prefix, so both values start with the token
|
||||
var token = Zotero.Utilities.randomString() + ' ';
|
||||
var userPublisher = token + 'User';
|
||||
var groupPublisher = token + 'Group';
|
||||
var userItem = await createDataObject('item', { itemType: 'book' });
|
||||
userItem.setField('publisher', userPublisher);
|
||||
await userItem.saveTx();
|
||||
var groupItem = await createDataObject('item', { itemType: 'book', libraryID: groupLibraryID });
|
||||
groupItem.setField('publisher', groupPublisher);
|
||||
await groupItem.saveTx();
|
||||
|
||||
// Scoped to the user library: only its value
|
||||
var userOnly = await autocomplete(token, { fieldName: 'publisher', libraryIDs: [Zotero.Libraries.userLibraryID] });
|
||||
assert.deepEqual(userOnly, [userPublisher]);
|
||||
|
||||
// Scoped to both libraries: both values
|
||||
var both = await autocomplete(token, {
|
||||
fieldName: 'publisher',
|
||||
libraryIDs: [Zotero.Libraries.userLibraryID, groupLibraryID]
|
||||
});
|
||||
assert.includeMembers(both, [userPublisher, groupPublisher]);
|
||||
|
||||
await Zotero.Items.erase([userItem.id, groupItem.id]);
|
||||
});
|
||||
|
||||
it("should save a search in an editable group library root but not a collection", async function () {
|
||||
var group = await getGroup();
|
||||
|
|
@ -463,10 +551,36 @@ describe("Advanced Search", function () {
|
|||
assert.include(values, "S" + search2.key);
|
||||
|
||||
await selectLibrary(win);
|
||||
|
||||
|
||||
await Zotero.Collections.erase([collection1.id, collection2.id]);
|
||||
await Zotero.Searches.erase([search1.id, search2.id]);
|
||||
});
|
||||
|
||||
it("shouldn't appear in a cross-library scope", async function () {
|
||||
var group = await getGroup();
|
||||
var groupLibraryID = group.libraryID;
|
||||
|
||||
// Simulate the cross-library scope that advancedSearchPane.refresh() sets
|
||||
// from a multi-library collection selection
|
||||
searchBox.scopeLibraryIDs = [Zotero.Libraries.userLibraryID, groupLibraryID];
|
||||
try {
|
||||
var s = new Zotero.Search();
|
||||
s.libraryID = Zotero.Libraries.userLibraryID;
|
||||
s.addCondition('title', 'is', '');
|
||||
pane.search = s;
|
||||
|
||||
var searchCondition = conditions.firstChild;
|
||||
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
|
||||
|
||||
// Collection condition (which also covers saved searches) shouldn't be offered
|
||||
for (let i = 0; i < conditionsMenu.itemCount; i++) {
|
||||
assert.notEqual(conditionsMenu.getItemAtIndex(i).value, 'collection');
|
||||
}
|
||||
}
|
||||
finally {
|
||||
searchBox.scopeLibraryIDs = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Saved Search", function () {
|
||||
|
|
|
|||
|
|
@ -482,6 +482,33 @@ describe("Citation Dialog", function () {
|
|||
let rowNode = dialog.document.getElementById(rowID);
|
||||
assert.isTrue(rowNode.classList.contains("highlighted"));
|
||||
});
|
||||
|
||||
it("should show the union of items from multiple selected collections", async function () {
|
||||
let collectionOne = await createDataObject('collection');
|
||||
let collectionTwo = await createDataObject('collection');
|
||||
let itemOne = await createDataObject('item', { collections: [collectionOne.id] });
|
||||
let itemTwo = await createDataObject('item', { collections: [collectionTwo.id] });
|
||||
|
||||
await IOManager.toggleDialogMode("library");
|
||||
let cv = dialog.libraryLayout.collectionsView;
|
||||
let itemsView = dialog.libraryLayout.itemsView;
|
||||
|
||||
// Select both collections
|
||||
await cv.selectByID("C" + collectionOne.id);
|
||||
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collectionTwo.id));
|
||||
await dialog.libraryLayout._onCollectionSelection();
|
||||
await itemsView.waitForLoad();
|
||||
|
||||
assert.isNumber(itemsView.getRowIndexByID(itemOne.id),
|
||||
"Item from first selected collection should be shown");
|
||||
assert.isNumber(itemsView.getRowIndexByID(itemTwo.id),
|
||||
"Item from second selected collection should be shown");
|
||||
|
||||
// Restore the default single-row selection so later tests that rely on the
|
||||
// library root being selected aren't affected by the leftover multi-selection
|
||||
await cv.selectByID("L" + Zotero.Libraries.userLibraryID);
|
||||
await itemsView.waitForLoad();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Search", function () {
|
||||
|
|
|
|||
|
|
@ -673,7 +673,6 @@ describe("Zotero.CollectionTree", function () {
|
|||
it("should switch to library root if item isn't in collection", async function () {
|
||||
var item = await createDataObject('item');
|
||||
var collection = await createDataObject('collection');
|
||||
Zotero.debug(zp.itemsView._rows);
|
||||
await cv.selectItem(item.id);
|
||||
await waitForItemsLoad(win);
|
||||
assert.equal(cv.selection.focused, 0);
|
||||
|
|
@ -731,7 +730,7 @@ describe("Zotero.CollectionTree", function () {
|
|||
}
|
||||
|
||||
Zotero.DragDrop.currentDragSource = objectType == "item"
|
||||
? zp.itemsView.collectionTreeRow
|
||||
? zp.itemsView.collectionTreeRows[0]
|
||||
: null;
|
||||
|
||||
if (!promise) {
|
||||
|
|
@ -763,7 +762,7 @@ describe("Zotero.CollectionTree", function () {
|
|||
var row = cv.getRowIndexByID(targetRowID);
|
||||
|
||||
Zotero.DragDrop.currentDragSource = objectType == "item"
|
||||
? zp.itemsView.collectionTreeRow
|
||||
? zp.itemsView.collectionTreeRows[0]
|
||||
: null;
|
||||
var dt = {
|
||||
dropEffect: 'copy',
|
||||
|
|
|
|||
|
|
@ -1962,6 +1962,120 @@ describe("CollectionViewItemTree", function () {
|
|||
);
|
||||
});
|
||||
|
||||
it("should add a dragged file to all selected collections", async function () {
|
||||
var collection1 = await createDataObject('collection');
|
||||
var collection2 = await createDataObject('collection');
|
||||
|
||||
// Select both collections
|
||||
await cv.selectByID("C" + collection1.id);
|
||||
await waitForItemsLoad(win);
|
||||
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collection2.id));
|
||||
await zp.onCollectionSelected();
|
||||
await zp.itemsView.waitForLoad();
|
||||
itemsView = zp.itemsView;
|
||||
|
||||
var file = getTestDataDirectory();
|
||||
file.append('test.png');
|
||||
|
||||
var idsPromise = waitForItemEvent('add');
|
||||
|
||||
drop(0, -1, {
|
||||
dropEffect: 'copy',
|
||||
effectAllowed: 'copy',
|
||||
types: ['application/x-moz-file'],
|
||||
mozItemCount: 1,
|
||||
mozGetDataAt: function (type, i) {
|
||||
if (type == 'application/x-moz-file' && i == 0) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
var ids = await idsPromise;
|
||||
var item = Zotero.Items.get(ids[0]);
|
||||
assert.isTrue(item.inCollection(collection1.id));
|
||||
assert.isTrue(item.inCollection(collection2.id));
|
||||
});
|
||||
|
||||
it("should allow a file drop onto a specific item but not blank space for a cross-library selection", async function () {
|
||||
var group = await createGroup();
|
||||
var c1 = await createDataObject('collection');
|
||||
var c2 = await createDataObject('collection', { libraryID: group.libraryID });
|
||||
var userItem = await createDataObject('item', { collections: [c1.id] });
|
||||
var groupItem = await createDataObject('item', { libraryID: group.libraryID, collections: [c2.id] });
|
||||
|
||||
await cv.expandLibrary(group.libraryID);
|
||||
await cv.selectByID("C" + c1.id);
|
||||
await waitForItemsLoad(win);
|
||||
cv.selection.toggleSelect(cv.getRowIndexByID("C" + c2.id));
|
||||
await zp.onCollectionSelected();
|
||||
await zp.itemsView.waitForLoad();
|
||||
itemsView = zp.itemsView;
|
||||
|
||||
var file = getTestDataDirectory();
|
||||
file.append('test.png');
|
||||
var fileDataTransfer = {
|
||||
dropEffect: 'copy',
|
||||
effectAllowed: 'copy',
|
||||
types: ['application/x-moz-file'],
|
||||
mozItemCount: 1,
|
||||
mozGetDataAt: function (type, i) {
|
||||
if (type == 'application/x-moz-file' && i == 0) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Directly onto a specific item (which identifies the target library): allowed
|
||||
assert.isTrue(itemsView.canDropCheck(itemsView.getRowIndexByID(userItem.id), 0, fileDataTransfer));
|
||||
// Into blank space (ambiguous across libraries): rejected
|
||||
assert.isFalse(itemsView.canDropCheck(-1, -1, fileDataTransfer));
|
||||
|
||||
// Dropping onto the group item attaches the file in the group library
|
||||
var idsPromise = waitForItemEvent('add');
|
||||
await drop(itemsView.getRowIndexByID(groupItem.id), 0, fileDataTransfer);
|
||||
var ids = await idsPromise;
|
||||
var attachment = Zotero.Items.get(ids[0]);
|
||||
assert.equal(attachment.libraryID, group.libraryID);
|
||||
assert.equal(attachment.parentItemID, groupItem.id);
|
||||
|
||||
await selectLibrary(win);
|
||||
await group.eraseTx();
|
||||
});
|
||||
|
||||
it("should allow a blank-space file drop for a mixed collection and saved-search selection regardless of order", async function () {
|
||||
var collection = await createDataObject('collection');
|
||||
var search = await createDataObject('search');
|
||||
|
||||
var file = getTestDataDirectory();
|
||||
file.append('test.png');
|
||||
var fileDataTransfer = {
|
||||
dropEffect: 'copy',
|
||||
effectAllowed: 'copy',
|
||||
types: ['application/x-moz-file'],
|
||||
mozItemCount: 1,
|
||||
mozGetDataAt: function (type, i) {
|
||||
if (type == 'application/x-moz-file' && i == 0) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Select the saved search first (so it's focused), then add the collection
|
||||
await cv.selectByID("S" + search.id);
|
||||
await waitForItemsLoad(win);
|
||||
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collection.id));
|
||||
await zp.onCollectionSelected();
|
||||
await zp.itemsView.waitForLoad();
|
||||
itemsView = zp.itemsView;
|
||||
|
||||
// Allowed even though the focused row is a search, since a selected
|
||||
// collection can receive the file
|
||||
assert.isTrue(itemsView.canDropCheck(-1, -1, fileDataTransfer));
|
||||
|
||||
await selectLibrary(win);
|
||||
});
|
||||
|
||||
it("should create a stored top-level attachment when a file URI is dragged", async function () {
|
||||
var promise = itemsView.waitForSelect();
|
||||
var pdfFile = getTestDataDirectory();
|
||||
|
|
@ -2449,16 +2563,16 @@ describe("CollectionViewItemTree", function () {
|
|||
});
|
||||
});
|
||||
|
||||
describe("#setCollectionTreeRow()", function () {
|
||||
it("should no-op when setting the same row", async function () {
|
||||
describe("#setCollectionTreeRows()", function () {
|
||||
it("should no-op when setting the same rows", async function () {
|
||||
let rowProvider = itemsView.rowProvider;
|
||||
let currentRow = rowProvider.collectionTreeRow;
|
||||
assert.ok(currentRow);
|
||||
|
||||
|
||||
let refreshSpy = sinon.spy(rowProvider, 'refresh');
|
||||
|
||||
|
||||
try {
|
||||
await rowProvider.setCollectionTreeRow(currentRow);
|
||||
await rowProvider.setCollectionTreeRows([currentRow]);
|
||||
assert.equal(refreshSpy.callCount, 0);
|
||||
}
|
||||
finally {
|
||||
|
|
|
|||
|
|
@ -768,6 +768,37 @@ describe("Item pane", function () {
|
|||
rowIDs = [...section.querySelectorAll(".row")].map(node => node.dataset.id);
|
||||
assert.deepEqual(rowIDs, [`L${item.libraryID}`, `C${collectionParent.id}`, `C${collectionChild.id}`]);
|
||||
});
|
||||
|
||||
it("should mark every selected collection as current for a multiple-collection selection", async function () {
|
||||
// Select both collections the item belongs to. Select the (sub)collection
|
||||
// first so its row is revealed, then toggle the parent into the selection.
|
||||
let cv = ZoteroPane.collectionsView;
|
||||
await cv.selectByID("C" + collectionChild.id);
|
||||
await waitForItemsLoad(win);
|
||||
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collectionParent.id));
|
||||
await ZoteroPane.onCollectionSelected();
|
||||
await ZoteroPane.itemsView.waitForLoad();
|
||||
// Select within the current (multi-collection) view rather than
|
||||
// ZoteroPane.selectItem(), which would navigate and drop the selection
|
||||
await ZoteroPane.itemsView.selectItem(item.id);
|
||||
// itemSelected() pushes the current selection into the item pane; force a
|
||||
// render so the section reflects the multi-collection selection (item-pane
|
||||
// render is skipped when the selected item itself hasn't changed)
|
||||
await ZoteroPane.itemSelected();
|
||||
await ZoteroPane.itemPane.render();
|
||||
|
||||
section = ZoteroPane.itemPane._itemDetails.getPane("libraries-collections");
|
||||
let parentBox = section.querySelector(`.row[data-id="C${collectionParent.id}"] .box`);
|
||||
let childBox = section.querySelector(`.row[data-id="C${collectionChild.id}"] .box`);
|
||||
let libraryBox = section.querySelector(`.row[data-id="L${item.libraryID}"] .box`);
|
||||
|
||||
// Both selected collections are bolded (.current); the unselected library row is not
|
||||
assert.isTrue(parentBox.classList.contains('current'));
|
||||
assert.isTrue(childBox.classList.contains('current'));
|
||||
assert.isFalse(libraryBox.classList.contains('current'));
|
||||
|
||||
await selectLibrary(win);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Attachments pane", function () {
|
||||
|
|
|
|||
|
|
@ -944,6 +944,7 @@ describe("Plugin API", function () {
|
|||
...defaultContextKeys,
|
||||
"items",
|
||||
"collectionTreeRow",
|
||||
"collectionTreeRows",
|
||||
]
|
||||
},
|
||||
"main/library/collection": {
|
||||
|
|
@ -954,6 +955,7 @@ describe("Plugin API", function () {
|
|||
contextKeys: [
|
||||
...defaultContextKeys,
|
||||
"collectionTreeRow",
|
||||
"collectionTreeRows",
|
||||
]
|
||||
},
|
||||
"main/library/addAttachment": {
|
||||
|
|
|
|||
|
|
@ -246,7 +246,32 @@ describe("Connector Server", function () {
|
|||
var req = await reqPromise;
|
||||
assert.equal(req.status, 201);
|
||||
});
|
||||
|
||||
|
||||
it("should target only the focused row for a cross-library multiple-collection selection", async function () {
|
||||
// A collection in My Library plus a group library root, with the group focused.
|
||||
// The Connector saves to a single target, so it should use the focused row (the
|
||||
// group root) and not the collection from the other library.
|
||||
var group = await createGroup();
|
||||
var collection = await createDataObject('collection');
|
||||
var cv = win.ZoteroPane.collectionsView;
|
||||
|
||||
await select(win, collection);
|
||||
var groupRow = cv.getRowIndexByID(group.treeViewID);
|
||||
cv.selection.toggleSelect(groupRow);
|
||||
await waitForItemsLoad(win);
|
||||
|
||||
// Sanity check: both rows selected, with the group focused
|
||||
assert.equal(cv.selection.focused, groupRow);
|
||||
assert.sameMembers(
|
||||
win.ZoteroPane.getCollectionTreeRows().map(r => r.ref.libraryID),
|
||||
[Zotero.Libraries.userLibraryID, group.libraryID]
|
||||
);
|
||||
|
||||
var target = Zotero.Server.Connector.getSaveTarget();
|
||||
assert.equal(target.library.libraryID, group.libraryID);
|
||||
assert.isNull(target.collection);
|
||||
});
|
||||
|
||||
it("should use the provided proxy to deproxify item url", async function () {
|
||||
await selectLibrary(win, Zotero.Libraries.userLibraryID);
|
||||
await waitForItemsLoad(win);
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ describe("Tag Selector", function () {
|
|||
tagSelector.selectedTags = new Set();
|
||||
tagSelector.handleSearch('');
|
||||
tagSelector.onItemViewChanged({
|
||||
collectionTreeRow: win.ZoteroPane.getCollectionTreeRow(),
|
||||
collectionTreeRows: win.ZoteroPane.getCollectionTreeRows(),
|
||||
libraryID
|
||||
});
|
||||
await waitForTagSelector(win);
|
||||
|
|
@ -783,6 +783,128 @@ describe("Tag Selector", function () {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Cross-library selection", function () {
|
||||
var group, groupLibraryID;
|
||||
|
||||
before(async function () {
|
||||
group = await createGroup();
|
||||
groupLibraryID = group.libraryID;
|
||||
await collectionsView.expandLibrary(groupLibraryID);
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
await group.eraseTx();
|
||||
});
|
||||
|
||||
async function selectCrossLibrary(userCollection, groupCollection) {
|
||||
let cv = collectionsView;
|
||||
await cv.selectByID("C" + userCollection.id);
|
||||
await waitForItemsLoad(win);
|
||||
cv.selection.toggleSelect(cv.getRowIndexByID("C" + groupCollection.id));
|
||||
await win.ZoteroPane.onCollectionSelected();
|
||||
await win.ZoteroPane.itemsView.waitForLoad();
|
||||
tagSelector.onItemViewChanged({
|
||||
collectionTreeRows: win.ZoteroPane.getCollectionTreeRows(),
|
||||
libraryID: win.ZoteroPane.getSelectedLibraryID()
|
||||
});
|
||||
await waitForTagSelector(win);
|
||||
}
|
||||
|
||||
it("should show tags from all selected libraries", async function () {
|
||||
let userTag = 'U ' + Zotero.Utilities.randomString();
|
||||
let groupTag = 'G ' + Zotero.Utilities.randomString();
|
||||
let userCollection = await createDataObject('collection');
|
||||
let groupCollection = await createDataObject('collection', { libraryID: groupLibraryID });
|
||||
let userItem = createUnsavedDataObject('item', { collections: [userCollection.id] });
|
||||
userItem.setTags([userTag]);
|
||||
let groupItem = createUnsavedDataObject(
|
||||
'item', { libraryID: groupLibraryID, collections: [groupCollection.id] }
|
||||
);
|
||||
groupItem.setTags([groupTag]);
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
await userItem.save();
|
||||
await groupItem.save();
|
||||
});
|
||||
|
||||
await selectCrossLibrary(userCollection, groupCollection);
|
||||
|
||||
assert.isTrue(tagSelector.multiLibrary);
|
||||
let tags = getRegularTags();
|
||||
assert.includeMembers(tags, [userTag, groupTag]);
|
||||
});
|
||||
|
||||
it("shouldn't show colored tags in the selector across libraries", async function () {
|
||||
let coloredTag = 'C ' + Zotero.Utilities.randomString();
|
||||
let userCollection = await createDataObject('collection');
|
||||
let groupCollection = await createDataObject('collection', { libraryID: groupLibraryID });
|
||||
// Color the tag in the user library and apply it to an item there
|
||||
await Zotero.Tags.setColor(libraryID, coloredTag, '#990000', 0);
|
||||
let userItem = createUnsavedDataObject('item', { collections: [userCollection.id] });
|
||||
userItem.setTags([coloredTag]);
|
||||
let groupItem = createUnsavedDataObject(
|
||||
'item', { libraryID: groupLibraryID, collections: [groupCollection.id] }
|
||||
);
|
||||
groupItem.setTags(['G ' + Zotero.Utilities.randomString()]);
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
await userItem.save();
|
||||
await groupItem.save();
|
||||
});
|
||||
|
||||
await selectCrossLibrary(userCollection, groupCollection);
|
||||
|
||||
// The tag is still listed, just not as a colored tag
|
||||
assert.include(getRegularTags(), coloredTag);
|
||||
assert.notInclude(getColoredTags(), coloredTag);
|
||||
|
||||
await Zotero.Tags.setColor(libraryID, coloredTag, false);
|
||||
});
|
||||
|
||||
it("should disable rename, color, and split but allow delete", async function () {
|
||||
let userCollection = await createDataObject('collection');
|
||||
let groupCollection = await createDataObject('collection', { libraryID: groupLibraryID });
|
||||
await selectCrossLibrary(userCollection, groupCollection);
|
||||
|
||||
let menu = doc.getElementById('tag-menu');
|
||||
let mockEvent = { preventDefault() {}, screenX: 0, screenY: 0 };
|
||||
try {
|
||||
tagSelector.handleTagContext({ name: 'whatever' }, mockEvent);
|
||||
assert.isTrue(doc.getElementById('assign-color-tag').disabled);
|
||||
assert.isTrue(doc.getElementById('rename-tag').disabled);
|
||||
assert.isTrue(doc.getElementById('split-tag').disabled);
|
||||
assert.isFalse(doc.getElementById('delete-tag').disabled);
|
||||
}
|
||||
finally {
|
||||
menu.hidePopup();
|
||||
}
|
||||
});
|
||||
|
||||
it("should delete a tag from all selected libraries", async function () {
|
||||
let sharedTag = 'shared ' + Zotero.Utilities.randomString();
|
||||
let userCollection = await createDataObject('collection');
|
||||
let groupCollection = await createDataObject('collection', { libraryID: groupLibraryID });
|
||||
let userItem = createUnsavedDataObject('item', { collections: [userCollection.id] });
|
||||
userItem.setTags([sharedTag]);
|
||||
let groupItem = createUnsavedDataObject(
|
||||
'item', { libraryID: groupLibraryID, collections: [groupCollection.id] }
|
||||
);
|
||||
groupItem.setTags([sharedTag]);
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
await userItem.save();
|
||||
await groupItem.save();
|
||||
});
|
||||
|
||||
await selectCrossLibrary(userCollection, groupCollection);
|
||||
|
||||
tagSelector.contextTag = { name: sharedTag };
|
||||
let dialogPromise = waitForDialog();
|
||||
await tagSelector.openDeletePrompt();
|
||||
await dialogPromise;
|
||||
|
||||
assert.isFalse(userItem.hasTag(sharedTag), "Tag removed from user library item");
|
||||
assert.isFalse(groupItem.hasTag(sharedTag), "Tag removed from group library item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Search error handling", function () {
|
||||
it("should degrade gracefully when getTags() throws SearchError", async function () {
|
||||
// _safeGetTags wraps collectionTreeRow.getTags(), which calls getSearchResults().
|
||||
|
|
|
|||
|
|
@ -1040,6 +1040,55 @@ describe("ZoteroPane", function () {
|
|||
});
|
||||
});
|
||||
|
||||
describe("#buildCollectionContextMenu()", function () {
|
||||
async function selectCollectionRows(ids) {
|
||||
let cv = zp.collectionsView;
|
||||
await cv.selectByID(ids[0]);
|
||||
await waitForItemsLoad(win);
|
||||
for (let id of ids.slice(1)) {
|
||||
cv.selection.toggleSelect(cv.getRowIndexByID(id));
|
||||
}
|
||||
await zp.onCollectionSelected();
|
||||
await zp.itemsView.waitForLoad();
|
||||
}
|
||||
|
||||
afterEach(async function () {
|
||||
await selectLibrary(win);
|
||||
});
|
||||
|
||||
it("should hide New Subcollection and Rename for a multiple-collection selection", async function () {
|
||||
let c1 = await createDataObject('collection');
|
||||
let c2 = await createDataObject('collection');
|
||||
|
||||
// A single collection shows both single-target actions
|
||||
await selectCollectionRows(["C" + c1.id]);
|
||||
await zp.buildCollectionContextMenu();
|
||||
assert.isFalse(win.document.getElementById('newSubcollection').hidden);
|
||||
assert.isFalse(win.document.getElementById('editSelectedCollection').hidden);
|
||||
|
||||
// Two collections hides them
|
||||
await selectCollectionRows(["C" + c1.id, "C" + c2.id]);
|
||||
await zp.buildCollectionContextMenu();
|
||||
assert.isTrue(win.document.getElementById('newSubcollection').hidden);
|
||||
assert.isTrue(win.document.getElementById('editSelectedCollection').hidden);
|
||||
});
|
||||
|
||||
it("should disable the report for a cross-library collection selection", async function () {
|
||||
let group = await createGroup();
|
||||
let c1 = await createDataObject('collection');
|
||||
let c2 = await createDataObject('collection', { libraryID: group.libraryID });
|
||||
await createDataObject('item', { collections: [c1.id] });
|
||||
await createDataObject('item', { libraryID: group.libraryID, collections: [c2.id] });
|
||||
|
||||
await zp.collectionsView.expandLibrary(group.libraryID);
|
||||
await selectCollectionRows(["C" + c1.id, "C" + c2.id]);
|
||||
await zp.buildCollectionContextMenu();
|
||||
assert.isTrue(win.document.getElementById('loadReport').disabled);
|
||||
|
||||
await group.eraseTx();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#buildItemContextMenu()", function () {
|
||||
it("shouldn't show export or bib options for multiple standalone file attachments without notes", async function () {
|
||||
var item1 = await importFileAttachment('test.png');
|
||||
|
|
@ -1052,6 +1101,42 @@ describe("ZoteroPane", function () {
|
|||
assert.isTrue(menu.querySelector('.zotero-menuitem-export').hidden);
|
||||
assert.isTrue(menu.querySelector('.zotero-menuitem-create-bibliography').hidden);
|
||||
});
|
||||
|
||||
it("should disable the report when selected items span libraries", async function () {
|
||||
let group = await createGroup();
|
||||
let c1 = await createDataObject('collection');
|
||||
let c2 = await createDataObject('collection', { libraryID: group.libraryID });
|
||||
let item1 = await createDataObject('item', { collections: [c1.id] });
|
||||
let item2 = await createDataObject('item', { libraryID: group.libraryID, collections: [c2.id] });
|
||||
|
||||
// Cross-library multiple-collection selection -> grouped items view
|
||||
await zp.collectionsView.expandLibrary(group.libraryID);
|
||||
let cv = zp.collectionsView;
|
||||
await cv.selectByID("C" + c1.id);
|
||||
await waitForItemsLoad(win);
|
||||
cv.selection.toggleSelect(cv.getRowIndexByID("C" + c2.id));
|
||||
await zp.onCollectionSelected();
|
||||
await zp.itemsView.waitForLoad();
|
||||
|
||||
let menu = win.document.getElementById('zotero-itemmenu');
|
||||
let report = menu.querySelector('.zotero-menuitem-create-report');
|
||||
|
||||
// Select within the current (grouped) view rather than ZoteroPane.selectItems(),
|
||||
// which would navigate the collection tree and drop the multi-selection
|
||||
|
||||
// Items from a single library: report enabled
|
||||
await zp.itemsView.selectItems([item1.id]);
|
||||
await zp.buildItemContextMenu();
|
||||
assert.isFalse(report.disabled);
|
||||
|
||||
// Items spanning libraries: report disabled
|
||||
await zp.itemsView.selectItems([item1.id, item2.id]);
|
||||
await zp.buildItemContextMenu();
|
||||
assert.isTrue(report.disabled);
|
||||
|
||||
await selectLibrary(win);
|
||||
await group.eraseTx();
|
||||
});
|
||||
|
||||
it("should show “Export Note…” for standalone file attachment with note", async function () {
|
||||
var item1 = await importFileAttachment('test.png');
|
||||
|
|
@ -1970,4 +2055,221 @@ describe("ZoteroPane", function () {
|
|||
assert.includeMembers(topLevelCollections, [collectionChild]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("Multi-Collection Selection", function () {
|
||||
afterEach(async function () {
|
||||
Zotero.Prefs.clear('recursiveCollections');
|
||||
await selectLibrary(win);
|
||||
});
|
||||
|
||||
async function selectMultipleCollections(collections) {
|
||||
let cv = zp.collectionsView;
|
||||
// Select the first collection
|
||||
await cv.selectByID("C" + collections[0].id);
|
||||
await waitForItemsLoad(win);
|
||||
// Toggle-select additional collections
|
||||
for (let i = 1; i < collections.length; i++) {
|
||||
let row = cv.getRowIndexByID("C" + collections[i].id);
|
||||
cv.selection.toggleSelect(row);
|
||||
}
|
||||
// Wait for the items view to reload with the combined set. The
|
||||
// selection-triggered call is queued ahead of this one in Zotero.serial,
|
||||
// and this one no-ops once the view matches the selection.
|
||||
await zp.onCollectionSelected();
|
||||
await zp.itemsView.waitForLoad();
|
||||
}
|
||||
|
||||
describe("Uncombinable selections", function () {
|
||||
it("should keep only the focused row when Recently Read and a collection are selected together", async function () {
|
||||
let collection = await createDataObject('collection');
|
||||
let cv = zp.collectionsView;
|
||||
// Show and select Recently Read
|
||||
await zp.setVirtual(Zotero.Libraries.userLibraryID, 'recentlyRead', true, true);
|
||||
await waitForItemsLoad(win);
|
||||
// Toggle-select the collection, forming an uncombinable two-row selection
|
||||
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collection.id));
|
||||
await zp.onCollectionSelected();
|
||||
|
||||
// The selection should have been reduced to the focused (collection) row
|
||||
assert.equal(cv.selection.count, 1);
|
||||
let rows = zp.getCollectionTreeRows();
|
||||
assert.lengthOf(rows, 1);
|
||||
assert.isTrue(rows[0].isCollection());
|
||||
|
||||
await zp.setVirtual(Zotero.Libraries.userLibraryID, 'recentlyRead', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Combinable special views", function () {
|
||||
it("should combine Recently Read across libraries without reducing the selection", async function () {
|
||||
let userLibraryID = Zotero.Libraries.userLibraryID;
|
||||
let groupLibraryID = (await createGroup()).libraryID;
|
||||
let cv = zp.collectionsView;
|
||||
|
||||
// Seed a recently-read item in each library
|
||||
let userItem = await createDataObject('item', { libraryID: userLibraryID });
|
||||
let userAttachment = await importPDFAttachment(userItem);
|
||||
userAttachment.attachmentLastRead = Math.round(Date.now() / 1000);
|
||||
await userAttachment.saveTx();
|
||||
let groupItem = await createDataObject('item', { libraryID: groupLibraryID });
|
||||
let groupAttachment = await importPDFAttachment(groupItem);
|
||||
groupAttachment.attachmentLastRead = Math.round(Date.now() / 1000);
|
||||
await groupAttachment.saveTx();
|
||||
|
||||
// Show Recently Read in both libraries, then select both rows
|
||||
await zp.setVirtual(userLibraryID, 'recentlyRead', true, false);
|
||||
await zp.setVirtual(groupLibraryID, 'recentlyRead', true, false);
|
||||
await cv.selectByID('Y' + userLibraryID);
|
||||
await waitForItemsLoad(win);
|
||||
cv.selection.toggleSelect(cv.getRowIndexByID('Y' + groupLibraryID));
|
||||
await zp.onCollectionSelected();
|
||||
await zp.itemsView.waitForLoad();
|
||||
|
||||
// Both Recently Read rows should remain selected
|
||||
assert.equal(cv.selection.count, 2);
|
||||
let rows = zp.getCollectionTreeRows();
|
||||
assert.lengthOf(rows, 2);
|
||||
assert.isTrue(rows.every(r => r.isRecentlyRead()));
|
||||
|
||||
// The items list should show the recently-read item from each library
|
||||
assert.isNumber(zp.itemsView.getRowIndexByID(userItem.id));
|
||||
assert.isNumber(zp.itemsView.getRowIndexByID(groupItem.id));
|
||||
|
||||
await zp.setVirtual(userLibraryID, 'recentlyRead', false);
|
||||
await zp.setVirtual(groupLibraryID, 'recentlyRead', false);
|
||||
Zotero.Items._lastReadCutoffs.clear();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Items display", function () {
|
||||
it("should show items from multiple selected collections", async function () {
|
||||
let collection1 = await createDataObject('collection');
|
||||
let collection2 = await createDataObject('collection');
|
||||
let item1 = await createDataObject('item', { collections: [collection1.id] });
|
||||
let item2 = await createDataObject('item', { collections: [collection2.id] });
|
||||
|
||||
await selectMultipleCollections([collection1, collection2]);
|
||||
|
||||
let itemsView = zp.itemsView;
|
||||
assert.ok(itemsView.getRowIndexByID(item1.id) !== false, "Item from first collection should be shown");
|
||||
assert.ok(itemsView.getRowIndexByID(item2.id) !== false, "Item from second collection should be shown");
|
||||
});
|
||||
|
||||
it("should show items from subcollections with recursiveCollections", async function () {
|
||||
Zotero.Prefs.set('recursiveCollections', true);
|
||||
|
||||
let collection1 = await createDataObject('collection');
|
||||
let subCollection1 = await createDataObject('collection', { parentID: collection1.id });
|
||||
let collection2 = await createDataObject('collection');
|
||||
let subCollection2 = await createDataObject('collection', { parentID: collection2.id });
|
||||
|
||||
let item1 = await createDataObject('item', { collections: [subCollection1.id] });
|
||||
let item2 = await createDataObject('item', { collections: [subCollection2.id] });
|
||||
let item3 = await createDataObject('item', { collections: [collection1.id] });
|
||||
|
||||
await selectMultipleCollections([collection1, collection2]);
|
||||
|
||||
let itemsView = zp.itemsView;
|
||||
assert.ok(itemsView.getRowIndexByID(item1.id) !== false,
|
||||
"Item from subcollection of first collection should be shown");
|
||||
assert.ok(itemsView.getRowIndexByID(item2.id) !== false,
|
||||
"Item from subcollection of second collection should be shown");
|
||||
assert.ok(itemsView.getRowIndexByID(item3.id) !== false,
|
||||
"Item directly in first collection should be shown");
|
||||
});
|
||||
|
||||
it("should not duplicate items that exist in both selected collections", async function () {
|
||||
let collection1 = await createDataObject('collection');
|
||||
let collection2 = await createDataObject('collection');
|
||||
let item = await createDataObject('item', { collections: [collection1.id, collection2.id] });
|
||||
|
||||
await selectMultipleCollections([collection1, collection2]);
|
||||
|
||||
let itemsView = zp.itemsView;
|
||||
// Count how many times the item appears
|
||||
let count = 0;
|
||||
for (let i = 0; i < itemsView.rowCount; i++) {
|
||||
if (itemsView.getRow(i).ref.id === item.id) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
assert.equal(count, 1, "Item in both collections should appear only once");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#newItem()", function () {
|
||||
it("should add a new item to all selected collections", async function () {
|
||||
let collection1 = await createDataObject('collection');
|
||||
let collection2 = await createDataObject('collection');
|
||||
|
||||
await selectMultipleCollections([collection1, collection2]);
|
||||
|
||||
let item = await zp.newItem(Zotero.ItemTypes.getID('book'), {}, null, true);
|
||||
|
||||
assert.isTrue(item.inCollection(collection1.id),
|
||||
"New item should be in first selected collection");
|
||||
assert.isTrue(item.inCollection(collection2.id),
|
||||
"New item should be in second selected collection");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#duplicateSelectedItem()", function () {
|
||||
it("should add duplicate to the subset of selected collections the original belongs to", async function () {
|
||||
let collection1 = await createDataObject('collection');
|
||||
let collection2 = await createDataObject('collection');
|
||||
let collection3 = await createDataObject('collection');
|
||||
// Item is in collections 1 and 3, not 2
|
||||
let item = await createDataObject('item', { collections: [collection1.id, collection3.id] });
|
||||
|
||||
// Select collection1, which contains the item
|
||||
await select(win, collection1);
|
||||
await zp.itemsView.selectItem(item.id);
|
||||
|
||||
// Temporarily override getCollectionTreeRows to simulate multi-collection selection
|
||||
let cv = zp.collectionsView;
|
||||
let origGetRows = zp.getCollectionTreeRows;
|
||||
let row1 = cv.getRowIndexByID("C" + collection1.id);
|
||||
let row2 = cv.getRowIndexByID("C" + collection2.id);
|
||||
let row3 = cv.getRowIndexByID("C" + collection3.id);
|
||||
zp.getCollectionTreeRows = function () {
|
||||
return [row1, row2, row3].map(index => cv.getRow(index));
|
||||
};
|
||||
|
||||
try {
|
||||
let duplicate = await zp.duplicateSelectedItem();
|
||||
|
||||
// Duplicate should be in collections 1 and 3 (selected + original is there)
|
||||
// but not in collection 2 (selected but original isn't there)
|
||||
assert.isTrue(duplicate.inCollection(collection1.id),
|
||||
"Duplicate should be in collection 1 (selected and original exists)");
|
||||
assert.isFalse(duplicate.inCollection(collection2.id),
|
||||
"Duplicate should not be in collection 2 (selected but original doesn't exist)");
|
||||
assert.isTrue(duplicate.inCollection(collection3.id),
|
||||
"Duplicate should be in collection 3 (selected and original exists)");
|
||||
}
|
||||
finally {
|
||||
zp.getCollectionTreeRows = origGetRows;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("#deleteSelectedCollection()", function () {
|
||||
it("should move all selected collections to trash", async function () {
|
||||
let collection1 = await createDataObject('collection');
|
||||
let collection2 = await createDataObject('collection');
|
||||
|
||||
await selectMultipleCollections([collection1, collection2]);
|
||||
|
||||
let promise = waitForDialog();
|
||||
await zp.deleteSelectedCollection();
|
||||
await promise;
|
||||
|
||||
assert.isTrue(collection1.deleted,
|
||||
"First collection should be in trash");
|
||||
assert.isTrue(collection2.deleted,
|
||||
"Second collection should be in trash");
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue