diff --git a/chrome/content/zotero/collectionTree.jsx b/chrome/content/zotero/collectionTree.jsx index c2e34ba69c..982c679b77 100644 --- a/chrome/content/zotero/collectionTree.jsx +++ b/chrome/content/zotero/collectionTree.jsx @@ -34,6 +34,10 @@ const PropTypes = require("prop-types"); const CHILD_INDENT = 16; +// Rows that aren't backed by a collection or a saved search, in the order in which they're +// displayed under a library +const VIRTUAL_ROW_TYPES = ['recentlyRead', 'publications', 'duplicates', 'unfiled', 'retracted', 'trash']; + var CollectionTree = class CollectionTree extends LibraryTree { static async init(domEl, opts) { Zotero.debug("Initializing React CollectionTree"); @@ -237,7 +241,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { || this._hiddenFocusedRow.isGroup() || this._hiddenFocusedRow.isSearch() || this._hiddenFocusedRow.isFeed()) { - if (!this._includedInTree(this._hiddenFocusedRow.ref) && treeRow.id !== this._hiddenFocusedRow.id) { + if (!this._includedInTree(this._hiddenFocusedRow) && treeRow.id !== this._hiddenFocusedRow.id) { let indexToDelete = this.getRowIndexByID(this._hiddenFocusedRow.id); if (indexToDelete) { this._removeRow(indexToDelete); @@ -332,7 +336,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { div.classList.toggle('drop', this._dropRow == index); div.classList.toggle('flashing', this._flashingRow == index); div.classList.toggle('unread', treeRow.ref && treeRow.ref.unreadCount > 0); - let { matchesFilter, hasChildMatchingFilter } = this._matchesFilter(treeRow.ref); + let { matchesFilter, hasChildMatchingFilter } = this._matchesFilter(treeRow); div.classList.toggle('context-row', !matchesFilter && hasChildMatchingFilter); // Hide currently focused but filtered out row to avoid confusing itemTree if (this._hiddenFocusedRow && this._hiddenFocusedRow.id == treeRow.id) { @@ -1072,7 +1076,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { let parentIndex = rowIndex ? this.getParentIndex(rowIndex) : -1; while (parentIndex > 0) { let parent = this.getRow(parentIndex); - this._matchesFilter(parent.ref, true); + this._matchesFilter(parent, true); parentIndex = this.getParentIndex(parentIndex); } } @@ -1401,7 +1405,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { // During filtering, context rows return an empty string to not be selectable // with key-based navigation if (!this._isFilterEmpty()) { - if (!this._matchesFilter(this.getRow(index).ref).matchesFilter) { + if (!this._matchesFilter(this.getRow(index)).matchesFilter) { return ""; } } @@ -2847,7 +2851,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { let parentRowIndex = this.getParentIndex(rowIndex); while (parentRowIndex > 0) { let parentRow = this.getRow(parentRowIndex); - if (parentRow.depth < 1 || this._matchesFilter(parentRow.ref).matchesFilter) { + if (parentRow.depth < 1 || this._matchesFilter(parentRow).matchesFilter) { break; } parentRowIndex = this.getParentIndex(parentRowIndex); @@ -2880,7 +2884,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { let willBeEmpty = filterText.length == 0; this._filter = Zotero.Utilities.Internal.normalizeForSearch(filterText); let currentRow = this.getRow(this.selection.focused) || this._hiddenFocusedRow; - let currentRowDisplayed = currentRow && this._includedInTree(currentRow.ref); + let currentRowDisplayed = currentRow && this._includedInTree(currentRow); let shouldRestoreScrollPosition = willBeEmpty && !isEmpty && !this._treeWasFocused; // Save the initial scroll position, selected row and which rows were collapsed before the filtering starts if (!willBeEmpty && isEmpty) { @@ -2917,7 +2921,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { if (!this._isFilterEmpty()) { for (let i = 0; i < this._rows.length; i++) { let row = this._rows[i]; - if (this.isContainer(i) && this._matchesFilter(row.ref).hasChildMatchingFilter && !row.isOpen) { + if (this.isContainer(i) && this._matchesFilter(row).hasChildMatchingFilter && !row.isOpen) { await this.toggleOpenState(i); } } @@ -2979,10 +2983,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { focusedRowMatchesFilter() { let row = this.getRow(this.selection.focused); - if (row.isDuplicates() || row.isUnfiled() || row.isRetracted() || row.isTrash() || row.isPublications()) { - return false; - } - return this._matchesFilter(row.ref).matchesFilter; + return this._matchesFilter(row).matchesFilter; } filterEquals(filterValue) { @@ -3014,7 +3015,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { let row = this.getRow(index); while (index < this._rows.length) { row = this.getRow(index); - if (this._matchesFilter(row.ref).matchesFilter) { + if (this._matchesFilter(row).matchesFilter) { // The matching row may not be selectable (e.g. Group Libraries header). // In that case, just keep looking for the next row let wasSelected = await this.selectByID(row.id); @@ -3045,7 +3046,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { offset = 2; } let lastRow = this.getRow(this._rows.length - offset); - while (lastRow && !lastRow.isOpen && this._matchesFilter(lastRow.ref).hasChildMatchingFilter) { + while (lastRow && !lastRow.isOpen && this._matchesFilter(lastRow).hasChildMatchingFilter) { await this.toggleOpenState(this._rows.length - 1); lastRow = this.getRow(this._rows.length - offset); // Sanity check to make sure we are not stuck in an infinite loop if something goes wrong @@ -3088,7 +3089,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { return false; } // Select the row if it's matching the filter unless it's a header or separator - if (this._matchesFilter(nextRow.ref).matchesFilter + if (this._matchesFilter(nextRow).matchesFilter && !["separator", "header"].includes(nextRow.type)) { this.tree.focus(); return this.selectByID(nextRow.id); @@ -3098,10 +3099,93 @@ var CollectionTree = class CollectionTree extends LibraryTree { return false; } + /** + * Get which virtual rows are shown for a given library + * + * @param {Integer} libraryID + * @return {Object} - Object with a boolean property for each type in VIRTUAL_ROW_TYPES + */ + _getVirtualRowVisibility(libraryID) { + // Virtual collections default to showing if not explicitly hidden + return { + recentlyRead: this._virtualCollectionLibraries.recentlyRead?.[libraryID] !== false, + publications: this.props.hideSources.indexOf('publications') == -1 + && this._virtualCollectionLibraries.publications?.[libraryID] !== false + && libraryID == Zotero.Libraries.userLibraryID, + duplicates: this.props.hideSources.indexOf('duplicates') == -1 + && this._virtualCollectionLibraries.duplicates?.[libraryID] !== false, + unfiled: this.props.hideSources.indexOf('unfiled') == -1 + && this._virtualCollectionLibraries.unfiled?.[libraryID] !== false, + retracted: this.props.hideSources.indexOf('retracted') == -1 + && this._virtualCollectionLibraries.retracted?.[libraryID] !== false + && Zotero.Retractions.libraryHasRetractedItems(libraryID), + trash: this.props.hideSources.indexOf('trash') == -1 + }; + } + + /** + * Get the display name of a virtual row + * + * @param {String} type - One of VIRTUAL_ROW_TYPES + * @return {String} + */ + _getVirtualRowName(type) { + switch (type) { + case 'recentlyRead': + return Zotero.getString('recently-read'); + + case 'publications': + return Zotero.getString('pane.collections.publications'); + + case 'duplicates': + return Zotero.getString('pane.collections.duplicate'); + + case 'unfiled': + return Zotero.getString('pane.collections.unfiled'); + + case 'retracted': + return Zotero.getString('pane.collections.retracted'); + + case 'trash': + return Zotero.getString('pane.collections.trash'); + } + throw new Error(`Unknown virtual row type '${type}'`); + } + + /** + * Get the names of the virtual rows displayed under a given library, to check whether the + * library has children matching the filter + * + * @param {Integer} libraryID + * @return {String[]} + */ + _getVisibleVirtualRowNames(libraryID) { + let visibility = this._getVirtualRowVisibility(libraryID); + // The trash is hidden when empty, which is only known once the library has been expanded + if (this._trashNotEmpty[libraryID] === false && !Zotero.Prefs.get('showTrashWhenEmpty')) { + visibility.trash = false; + } + return VIRTUAL_ROW_TYPES.filter(type => visibility[type]).map(type => this._getVirtualRowName(type)); + } + + /** + * Check if a virtual row matches the filter + * + * @param {String} type - One of VIRTUAL_ROW_TYPES + * @return {Boolean} + */ + _virtualRowMatchesFilter(type) { + if (this._isFilterEmpty()) { + return true; + } + let name = Zotero.Utilities.Internal.normalizeForSearch(this._getVirtualRowName(type)); + return name.includes(this._filter); + } + /** * Check if a given object matches filter or has children that match the filter. * - * @param {Collection|Search|Library|Group} object - Object to check + * @param {CollectionTreeRow|Collection|Search|Library|Group} object - Object or row to check * @param {Bool} resetCache - Ignore and reset existing cache value for that object * @return {Object} { matchesFilter: Bool, hasChildMatchingFilter: Bool } * matchesFilter = object itself matches the filter @@ -3112,6 +3196,17 @@ var CollectionTree = class CollectionTree extends LibraryTree { if (this._isFilterEmpty()) { return { matchesFilter: true, hasChildMatchingFilter: true }; } + // The refs of virtual rows don't identify them (e.g., the Trash's ref is just a + // libraryID), so they're matched by row + if (object instanceof Zotero.CollectionTreeRow) { + if (VIRTUAL_ROW_TYPES.includes(object.type)) { + return { + matchesFilter: this._virtualRowMatchesFilter(object.type), + hasChildMatchingFilter: false + }; + } + object = object.ref; + } // Handle separator or group headers if ((object.libraryID === undefined || object.libraryID === -1) && !object.updateFeed) { return { matchesFilter: true, hasChildMatchingFilter: false }; @@ -3142,6 +3237,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { let filterValue = this._filter; let childrenToSearch = []; + let virtualRowNames = []; if (object._ObjectType == 'Collection') { let collection = Zotero.Collections.get(object.id); childrenToSearch = collection.getChildCollections(); @@ -3149,16 +3245,20 @@ var CollectionTree = class CollectionTree extends LibraryTree { else if (object.libraryID && !["Search", "Feeds"].includes(object._ObjectType)) { childrenToSearch = Zotero.Collections.getByLibrary(object.libraryID); childrenToSearch = childrenToSearch.concat(Zotero.Searches.getByLibrary(object.libraryID)); + if (object._ObjectType != 'Feed') { + virtualRowNames = this._getVisibleVirtualRowNames(object.libraryID); + } } else if (objectID == 'feeds') { childrenToSearch = Zotero.Feeds.getAll(); } let matchesFilter = objectName.includes(filterValue); // For libraries, groups and collections, recursively check if they have any children that match the filter - let hasChildMatchingFilter = childrenToSearch.some((child) => { - let { matchesFilter, hasChildMatchingFilter } = this._matchesFilter(child); - return matchesFilter || hasChildMatchingFilter; - }); + let hasChildMatchingFilter = virtualRowNames.some(name => normalize(name).includes(filterValue)) + || childrenToSearch.some((child) => { + let { matchesFilter, hasChildMatchingFilter } = this._matchesFilter(child); + return matchesFilter || hasChildMatchingFilter; + }); // Save filter status to cache this._filterResultsCache[objectID] = { matchesFilter: matchesFilter, @@ -3172,15 +3272,16 @@ var CollectionTree = class CollectionTree extends LibraryTree { * 1. It matches the searchbox filter OR * 2. Its children match the searchbox filter AND * 3. It is allowed by filter prop - * @param {Collection|Search|Library|Group} object - Object to check + * @param {CollectionTreeRow|Collection|Search|Library|Group} object - Object or row to check * @param {Bool} resetCache - Ignore and reset existing cache value for that object * @returns {boolean} Whether the object should be included in the tree */ _includedInTree(object, resetCache) { - const notACollection = (object.libraryID === undefined || object.libraryID === -1) && !object.updateFeed; + const ref = object instanceof Zotero.CollectionTreeRow ? object.ref : object; + const notACollection = (ref.libraryID === undefined || ref.libraryID === -1) && !ref.updateFeed; const treeHasFilterProp = Array.isArray(this.props.filterLibraryIDs) && this.props.filterLibraryIDs; const isAllowedByPropFilter = notACollection || !treeHasFilterProp - || this.props.filterLibraryIDs.includes(object.libraryID) + || this.props.filterLibraryIDs.includes(ref.libraryID) ; if (!isAllowedByPropFilter) return false; @@ -3205,29 +3306,19 @@ var CollectionTree = class CollectionTree extends LibraryTree { if (isLibrary) { var savedSearches = (await Zotero.Searches.getAll(libraryID)).filter(s => !s.deleted); - // Virtual collections default to showing if not explicitly hidden - var showDuplicates = this.props.hideSources.indexOf('duplicates') == -1 - && this._virtualCollectionLibraries.duplicates[libraryID] !== false; - var showUnfiled = this.props.hideSources.indexOf('unfiled') == -1 - && this._virtualCollectionLibraries.unfiled?.[libraryID] !== false; - var showRecentlyRead = this._virtualCollectionLibraries.recentlyRead?.[libraryID] !== false; - var showRetracted = this.props.hideSources.indexOf('retracted') == -1 - && this._virtualCollectionLibraries.retracted?.[libraryID] !== false - && Zotero.Retractions.libraryHasRetractedItems(libraryID); - var showPublications = this.props.hideSources.indexOf('publications') == -1 - && this._virtualCollectionLibraries.publications?.[libraryID] !== false - && libraryID == Zotero.Libraries.userLibraryID; - var showTrash = this.props.hideSources.indexOf('trash') == -1; + var virtualRows = this._getVirtualRowVisibility(libraryID); } else { var savedSearches = []; - var showDuplicates = false; - var showUnfiled = false; - var showRecentlyRead = false; - var showRetracted = false; - var showPublications = false; - var showTrash = false; + var virtualRows = {}; } + // Virtual rows are shown while filtering if their names match the filter + var showDuplicates = virtualRows.duplicates && this._virtualRowMatchesFilter('duplicates'); + var showUnfiled = virtualRows.unfiled && this._virtualRowMatchesFilter('unfiled'); + var showRecentlyRead = virtualRows.recentlyRead && this._virtualRowMatchesFilter('recentlyRead'); + var showRetracted = virtualRows.retracted && this._virtualRowMatchesFilter('retracted'); + var showPublications = virtualRows.publications && this._virtualRowMatchesFilter('publications'); + var showTrash = virtualRows.trash && this._virtualRowMatchesFilter('trash'); // If not a manual open and either the library is set to be collapsed or this is a collection that isn't explicitly opened, // set the initial state to closed @@ -3238,7 +3329,8 @@ var CollectionTree = class CollectionTree extends LibraryTree { return 0; } - var startOpen = !!(collections.length || savedSearches.length || showDuplicates || showUnfiled || showRecentlyRead || showRetracted || showTrash); + var startOpen = !!(collections.length || savedSearches.length || virtualRows.duplicates + || virtualRows.unfiled || virtualRows.recentlyRead || virtualRows.retracted || virtualRows.trash); // If this isn't a manual open, set the initial state depending on whether // there are child nodes @@ -3253,7 +3345,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { var newRows = 0; // Recently Read - if (showRecentlyRead && this._isFilterEmpty()) { + if (showRecentlyRead) { rows.splice(row + 1 + newRows, 0, new Zotero.CollectionTreeRow(this, 'recentlyRead', @@ -3296,7 +3388,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { newRows++; } - if (showPublications && this._isFilterEmpty()) { + if (showPublications) { // Add "My Publications" rows.splice(row + 1 + newRows, 0, new Zotero.CollectionTreeRow(this, @@ -3312,7 +3404,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { } // Duplicate items - if (showDuplicates && this._isFilterEmpty()) { + if (showDuplicates) { let d = new Zotero.Duplicates(libraryID); rows.splice(row + 1 + newRows, 0, new Zotero.CollectionTreeRow(this, 'duplicates', d, level + 1)); @@ -3320,7 +3412,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { } // Unfiled items - if (showUnfiled && this._isFilterEmpty()) { + if (showUnfiled) { let s = new Zotero.Search; s.libraryID = libraryID; s.name = Zotero.getString('pane.collections.unfiled'); @@ -3332,7 +3424,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { } // Retracted items - if (showRetracted && this._isFilterEmpty()) { + if (showRetracted) { let s = new Zotero.Search; s.libraryID = libraryID; s.name = Zotero.getString('pane.collections.retracted'); @@ -3343,11 +3435,11 @@ var CollectionTree = class CollectionTree extends LibraryTree { newRows++; } - if (showTrash && this._isFilterEmpty()) { + if (showTrash) { let deletedItems = await Zotero.Items.getDeleted(libraryID, true); let deletedCollections = await Zotero.Collections.getDeleted(libraryID, true); let deletedSearches = await Zotero.Searches.getDeleted(libraryID, true); - let trashNotEmpty = deletedItems.length || deletedCollections.length || deletedSearches.length; + let trashNotEmpty = !!(deletedItems.length || deletedCollections.length || deletedSearches.length); if (trashNotEmpty || Zotero.Prefs.get("showTrashWhenEmpty")) { var ref = { libraryID: libraryID diff --git a/test/tests/collectionTreeTest.js b/test/tests/collectionTreeTest.js index 0b009d5ec8..5867434d42 100644 --- a/test/tests/collectionTreeTest.js +++ b/test/tests/collectionTreeTest.js @@ -2061,6 +2061,36 @@ describe("Zotero.CollectionTree", function () { }); } + it('should show and allow selecting a virtual row matching the filter', async function () { + await cv.setFilter(Zotero.getString('pane.collections.trash')); + + let index = cv.getRowIndexByID("T" + userLibraryID); + assert.notStrictEqual(index, false); + await cv.focusFirstMatchingRow(); + assert.isTrue(cv.getRow(cv.selection.focused).isTrash()); + }); + + it('should not show a library whose trash is empty and hidden', async function () { + Zotero.Prefs.set('showTrashWhenEmpty', false); + var group = await createGroup(); + try { + await cv.setFilter(Zotero.getString('pane.collections.trash')); + + assert.isFalse(cv.getRowIndexByID("L" + group.libraryID)); + } + finally { + Zotero.Prefs.clear('showTrashWhenEmpty'); + await group.eraseTx(); + } + }); + + it('should hide a virtual row not matching the filter', async function () { + await cv.setFilter("collection"); + + assert.isFalse(cv.getRowIndexByID("T" + userLibraryID)); + assert.isFalse(cv.getRowIndexByID("U" + userLibraryID)); + }); + it('should match an accented collection name from an unaccented filter', async function () { var collection = await createDataObject('collection', { name: "zdiacrésumé", libraryID: userLibraryID }); await cv.setFilter("zdiacresume");