diff --git a/chrome/content/zotero/advancedSearch.js b/chrome/content/zotero/advancedSearch.js index 6aff0c6686..06634fbfd9 100644 --- a/chrome/content/zotero/advancedSearch.js +++ b/chrome/content/zotero/advancedSearch.js @@ -83,6 +83,8 @@ var ZoteroAdvancedSearch = new function () { isFeed: () => false, isFeeds: () => false, isFeedsOrFeed: () => false, + isRecentlyRead: () => false, + isSortable: () => true, isShare: () => false, isTrash: () => false, isSearch: () => true @@ -101,7 +103,7 @@ var ZoteroAdvancedSearch = new function () { _searchBox.updateSearch(); _searchBox.active = true; - return this.itemsView.changeCollectionTreeRow({ + var collectionTreeRow = { ref: _searchBox.search, visibilityGroup: 'default', isSearchMode: () => true, @@ -113,8 +115,21 @@ var ZoteroAdvancedSearch = new function () { search.libraryID = _libraryID; var ids = await search.search(); return Zotero.Items.get(ids); - } - }); + }, + isLibrary: () => false, + isCollection: () => false, + isPublications: () => false, + isDuplicates: () => false, + isFeed: () => false, + isFeeds: () => false, + isFeedsOrFeed: () => false, + isRecentlyRead: () => false, + isSortable: () => true, + isShare: () => false, + isTrash: () => false + }; + + return this.itemsView.changeCollectionTreeRow(collectionTreeRow); } diff --git a/chrome/content/zotero/collectionTree.jsx b/chrome/content/zotero/collectionTree.jsx index f3278f3933..c1d47d7009 100644 --- a/chrome/content/zotero/collectionTree.jsx +++ b/chrome/content/zotero/collectionTree.jsx @@ -513,6 +513,8 @@ var CollectionTree = class CollectionTree extends LibraryTree { } this._virtualCollectionLibraries.unfiled = Zotero.Prefs.getVirtualCollectionState('unfiled'); + this._virtualCollectionLibraries.recentlyRead = + Zotero.Prefs.getVirtualCollectionState('recentlyRead'); this._virtualCollectionLibraries.retracted = Zotero.Prefs.getVirtualCollectionState('retracted'); this._virtualCollectionLibraries.publications = Zotero.Prefs.getVirtualCollectionState('publications'); @@ -1258,7 +1260,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { } /** - * Toggle virtual collection (duplicates/unfiled) visibility + * Toggle virtual collection (duplicates/unfiled/recently read/retracted) visibility * * @param libraryID {Number} * @param type {String} @@ -1269,6 +1271,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { const types = { duplicates: 'D', unfiled: 'U', + recentlyRead: 'Y', retracted: 'R', publications: 'P' }; @@ -1427,6 +1430,10 @@ var CollectionTree = class CollectionTree extends LibraryTree { case 'feeds': icon = 'feed-library'; break; + + case 'recentlyRead': + icon = 'recent'; + break; case 'header': if (treeRow.ref.id == 'group-libraries-header') { @@ -2971,6 +2978,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { && 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); @@ -2983,6 +2991,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { var savedSearches = []; var showDuplicates = false; var showUnfiled = false; + var showRecentlyRead = false; var showRetracted = false; var showPublications = false; var showTrash = false; @@ -2997,7 +3006,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { return 0; } - var startOpen = !!(collections.length || savedSearches.length || showDuplicates || showUnfiled || showRetracted || showTrash); + var startOpen = !!(collections.length || savedSearches.length || showDuplicates || showUnfiled || showRecentlyRead || showRetracted || showTrash); // If this isn't a manual open, set the initial state depending on whether // there are child nodes @@ -3011,6 +3020,18 @@ var CollectionTree = class CollectionTree extends LibraryTree { var newRows = 0; + // Recently Read + if (showRecentlyRead) { + let s = new Zotero.Search(); + s.libraryID = libraryID; + s.name = Zotero.getString('pane.collections.recentlyRead'); + s.addCondition('libraryID', 'is', libraryID); + s.addCondition('lastRead', 'isInTheLast', '14 days'); + rows.splice(row + 1 + newRows, 0, + new Zotero.CollectionTreeRow(this, 'recentlyRead', s, level + 1)); + newRows++; + } + // Add collections for (var i = 0, len = collections.length; i < len; i++) { // Skip collections in trash @@ -3146,13 +3167,19 @@ var CollectionTree = class CollectionTree extends LibraryTree { else { // Get all collections at the same level that don't have a different parent startRow++; + // Skip past virtual collections (e.g., Recently Read) that come + // before collections in the tree + while (startRow < this._rows.length + && this.getLevel(startRow) == level + && this.getRow(startRow).isRecentlyRead()) { + startRow++; + } loop: for (let i = startRow; i < this._rows.length; i++) { let treeRow = this.getRow(i); beforeRow = i; - - // Since collections come first, if we reach something that's not a collection, - // stop + + // If we reach something that's not a collection, stop if (!treeRow.isCollection()) { break; } @@ -3215,7 +3242,11 @@ var CollectionTree = class CollectionTree extends LibraryTree { for (let i = startRow; i < this._rows.length; i++) { let treeRow = this.getRow(i); beforeRow = i; - + + // Skip forward to first collection + if (treeRow.isRecentlyRead()) { + continue; + } // If we've reached something other than collections, stop if (treeRow.isSearch()) { // If current search sorts after, stop diff --git a/chrome/content/zotero/elements/zoteroSearch.js b/chrome/content/zotero/elements/zoteroSearch.js index 2007c60a8c..7a34c15c38 100644 --- a/chrome/content/zotero/elements/zoteroSearch.js +++ b/chrome/content/zotero/elements/zoteroSearch.js @@ -360,6 +360,7 @@ case 'date': case 'dateAdded': case 'dateModified': + case 'lastRead': case 'itemType': case 'fileTypeID': case 'publicationTitle': diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx index a3b29eb5ee..ca63cafda2 100644 --- a/chrome/content/zotero/itemTree.jsx +++ b/chrome/content/zotero/itemTree.jsx @@ -67,6 +67,8 @@ const STUB_COLLECTION_TREE_ROW = { isFeed: () => false, isFeeds: () => false, isFeedsOrFeed: () => false, + isRecentlyRead: () => false, + isSortable: () => true, isShare: () => false, isTrash: () => false }; @@ -261,8 +263,12 @@ var ItemTree = class ItemTree extends LibraryTree { this._itemsPaneMessage = null; return shouldRerender && new Promise(resolve => this.forceUpdate(resolve)); } - - refresh = Zotero.serial(async function (skipExpandMatchParents) { + + /** + * @param {Boolean} [options.forceSortAll] Sort all items instead of only added items + * @return {Promise} + */ + refresh = Zotero.serial(async function (options = {}) { Zotero.debug('Refreshing items list for ' + this.id); var resolve, reject; @@ -393,7 +399,7 @@ var ItemTree = class ItemTree extends LibraryTree { // This still results in a lot of extra work (e.g., when clearing a quick search, we have to // re-sort all items that didn't match the search), so as a further optimization we could keep // a sorted list of items for a given column configuration and restore items from that. - await this.sort([...addedItemIDs]); + await this.sort(options.forceSortAll ? [...allItemIDs] : [...addedItemIDs]); // Update search results before collapse/expand of containers so that // if hideContextAnnotationRows pref is true, child rows appear/disappear properly @@ -771,6 +777,21 @@ var ItemTree = class ItemTree extends LibraryTree { await this.toggleOpenState(parentItemRowIndex); } } + + if (item.parentItemID && this._getColumns().some(col => !col.hidden && col.dependsOnChildren)) { + delete this._rowCache[item.parentItemID]; + if (this._rowMap[item.parentItemID] !== undefined) { + this.tree.invalidateRow(this._rowMap[item.parentItemID]); + // If we're sorting by a dependsOnChildren column, also re-sort + // (We don't look at secondary sort here because no dependsOnParent columns can be secondary sorts. + // If that changes, we need to be more thorough here.) + let sortField = this.getSortField(); + if (this._getColumns().find(col => col.dataKey == sortField)?.dependsOnChildren) { + madeChanges = true; + sort = true; + } + } + } } if (sort && ids.length != 1) { @@ -794,6 +815,7 @@ var ItemTree = class ItemTree extends LibraryTree { || collectionTreeRow.isPublications() || collectionTreeRow.isTrash() || collectionTreeRow.isUnfiled() + || collectionTreeRow.isRecentlyRead() || hasQuickSearch) { if (hasQuickSearch) { // For item adds, clear the quick search, unless all the new items have @@ -1125,7 +1147,7 @@ var ItemTree = class ItemTree extends LibraryTree { showHeader: true, columns: this._getColumns(), onColumnPickerMenu: this._displayColumnPickerMenu, - onColumnSort: this.collectionTreeRow.isFeedsOrFeed() ? null : this._handleColumnSort, + onColumnSort: this.collectionTreeRow.isSortable() ? this._handleColumnSort : null, getColumnPrefs: this._getColumnPrefs, storeColumnPrefs: this._storeColumnPrefs, getDefaultColumnOrder: this._getDefaultColumnOrder, @@ -1189,7 +1211,8 @@ var ItemTree = class ItemTree extends LibraryTree { if (collectionTreeRow.visibilityGroup) { newId += "-" + collectionTreeRow.visibilityGroup; } - if (this.id != newId && this.props.persistColumns) { + let idChanged = this.id != newId; + if (idChanged && this.props.persistColumns) { await this._writeColumnPrefsToFile(true); this.id = newId; await this._loadColumnPrefsFromFile(); @@ -1203,7 +1226,7 @@ var ItemTree = class ItemTree extends LibraryTree { this.selection.clearSelection(); this.selection.focused = 0; - await this.refresh(); + await this.refresh({ forceSortAll: idChanged }); if (Zotero.CollectionTreeCache.error) { return this.setItemsPaneMessage(Zotero.getString('pane.items.loadError')); } @@ -1487,6 +1510,9 @@ var ItemTree = class ItemTree extends LibraryTree { case 'feed': return (row.ref.isFeedItem && Zotero.Feeds.get(row.ref.libraryID).name) || ""; + + case 'lastRead': + return item.getItemLastRead(); default: let extraField = this.props.getExtraField(row.ref, field); @@ -1976,6 +2002,7 @@ var ItemTree = class ItemTree extends LibraryTree { else if (collectionTreeRow.isLibrary(true) || collectionTreeRow.isSearch() || collectionTreeRow.isUnfiled() + || collectionTreeRow.isRecentlyRead() || collectionTreeRow.isRetracted() || collectionTreeRow.isDuplicates() || force) { @@ -2046,10 +2073,13 @@ var ItemTree = class ItemTree extends LibraryTree { * @return {Number} - -1 for descending, 1 for ascending */ getSortDirection(sortFields) { - sortFields = sortFields || this.getSortFields(); if (this.collectionTreeRow.isFeedsOrFeed()) { return Zotero.Prefs.get('feeds.sortAscending') ? 1 : -1; } + if (this.collectionTreeRow.isRecentlyRead()) { + return -1; + } + sortFields = sortFields || this.getSortFields(); const columns = this._getColumns(); for (const field of sortFields) { const col = columns.find(c => c.dataKey == field); @@ -2064,6 +2094,9 @@ var ItemTree = class ItemTree extends LibraryTree { if (this.collectionTreeRow.isFeedsOrFeed()) { return 'id'; } + if (this.collectionTreeRow.isRecentlyRead()) { + return 'lastRead'; + } var column = this._sortedColumn; if (!column) { column = this._getColumns().find(col => !col.hidden); @@ -2789,7 +2822,7 @@ var ItemTree = class ItemTree extends LibraryTree { // // Secondary Sort menu // - if (!this.collectionTreeRow.isFeedsOrFeed()) { + if (this.collectionTreeRow.isSortable()) { try { const id = prefix + 'sort-menu'; const primaryField = this.getSortField(); @@ -3445,7 +3478,8 @@ var ItemTree = class ItemTree extends LibraryTree { } row.numNotes = treeRow.numNotes() || ""; row.feed = (treeRow.ref.isFeedItem && Zotero.Feeds.get(treeRow.ref.libraryID).name) || ""; - + row.lastRead = row.isItem ? treeRow.ref.getItemLastRead() : ""; + if (treeRow.ref.isFileAttachment() // TODO: Adjust this if we localize "Snapshot" && !(treeRow.ref.isSnapshotAttachment() && /snapshot/i.test(treeRow.ref.getField('title'))) @@ -3503,6 +3537,13 @@ var ItemTree = class ItemTree extends LibraryTree { val = ''; } } + break; + case 'lastRead': + if (val) { + let date = new Date(val * 1000); + val = date.toLocaleString(); + } + break; } row[key] = val; } @@ -3671,6 +3712,15 @@ var ItemTree = class ItemTree extends LibraryTree { } } + // Force sort indicator for views with a fixed sort order + if (this.collectionTreeRow?.isRecentlyRead()) { + let col = this._columns.find(c => c.dataKey === 'lastRead'); + if (col) { + col.sortDirection = -1; + this._sortedColumn = col; + } + } + return this._columns.sort((a, b) => a.ordinal - b.ordinal); } diff --git a/chrome/content/zotero/itemTreeColumns.jsx b/chrome/content/zotero/itemTreeColumns.jsx index 850f9266ed..4833c5f2ba 100644 --- a/chrome/content/zotero/itemTreeColumns.jsx +++ b/chrome/content/zotero/itemTreeColumns.jsx @@ -35,6 +35,7 @@ const Icons = require('components/icons'); * @property {string[]} [enabledTreeIDs=[]] - Which tree ids the column should be enabled in. If undefined, enabled in main tree. If ["*"], enabled in all trees. * @property {string[]} [defaultIn] - Will be deprecated. Types of trees the column is default in. Can be [default, feed]; * @property {string[]} [disabledIn] - Will be deprecated. Types of trees where the column is not available + * @property {boolean} [dependsOnChildren=false] - Set to true if the column depends on child item data (e.g. numNotes, lastRead) * @property {boolean} [sortReverse=false] - Default: false. Set to true to reverse the sort order * @property {number} [flex=1] - Default: 1. When the column is added to the tree how much space it should occupy as a flex ratio * @property {string} [width] - A column width instead of flex ratio. See above. @@ -60,7 +61,7 @@ const COLUMNS = [ { dataKey: "title", primary: true, - defaultIn: ["default", "feeds", "feed"], + defaultIn: ["default", "feeds", "feed", "recentlyRead"], label: "itemFields.title", showInColumnPicker: false, flex: 4, @@ -68,7 +69,7 @@ const COLUMNS = [ }, { dataKey: "firstCreator", - defaultIn: ["default", "feeds", "feed"], + defaultIn: ["default", "feeds", "feed", "recentlyRead"], label: "zotero.items.creator_column", showInColumnPicker: true, flex: 1, @@ -185,6 +186,16 @@ const COLUMNS = [ flex: 1, zoteroPersist: ["width", "hidden", "sortDirection"] }, + { + dataKey: "lastRead", + defaultSort: -1, + defaultIn: ["recentlyRead"], + disabledIn: ["feeds", "feed"], + dependsOnChildren: true, + label: "pane.items.columns.lastRead", + flex: 2, + zoteroPersist: ["width", "hidden", "sortDirection"] + }, { dataKey: "archive", disabledIn: ["feeds", "feed"], @@ -342,6 +353,7 @@ const COLUMNS = [ defaultIn: ["default"], disabledIn: ["feeds", "feed"], showInColumnPicker: true, + dependsOnChildren: true, label: "zotero.tabs.attachments.label", iconLabel: , fixedWidth: true, @@ -352,6 +364,7 @@ const COLUMNS = [ dataKey: "numNotes", disabledIn: ["feeds", "feed"], showInColumnPicker: true, + dependsOnChildren: true, label: "zotero.tabs.notes.label", iconLabel: , width: "26", diff --git a/chrome/content/zotero/xpcom/attachmentReadObserver.js b/chrome/content/zotero/xpcom/attachmentReadObserver.js new file mode 100644 index 0000000000..33ba36589a --- /dev/null +++ b/chrome/content/zotero/xpcom/attachmentReadObserver.js @@ -0,0 +1,113 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2022 Corporation for Digital Scholarship + Vienna, Virginia, USA + http://digitalscholar.org/ + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +Zotero.AttachmentReadObserver = { + init() { + this._observerID = Zotero.Notifier.registerObserver(this, ['file', 'setting'], 'attachmentReadObserver'); + }, + + unregister() { + if (this._observerID) { + Zotero.Notifier.unregisterObserver(this._observerID); + this._observerID = null; + } + }, + + /** + * To make the date mockable in tests + * @return {Date} + */ + _getCurrentDate() { + return new Date(); + }, + + /** + * @param {Zotero.Item} item + */ + async updateAttachmentLastRead(item) { + // Limit to My Library and groups + if (item.libraryID != Zotero.Libraries.userLibraryID && !item.library.isGroup) { + return; + } + + item.attachmentLastRead = Math.round(this._getCurrentDate().getTime() / 1000); + await item.saveTx({ skipDateModifiedUpdate: true }); + }, + + async notify(action, type, ids, extraData) { + if (type == 'file') { + if (!['pageChange', 'open'].includes(action)) { + return; + } + let items = await Zotero.Items.getAsync(ids); + switch (action) { + case 'open': + for (let item of items) { + await this.updateAttachmentLastRead(item); + } + break; + case 'pageChange': { + let fiveMinutesAgo = this._getCurrentDate(); + fiveMinutesAgo.setMinutes(fiveMinutesAgo.getMinutes() - 5); + for (let item of items) { + if (item.library.lastReadItemInSession !== item.id + || new Date(item.attachmentLastRead * 1000) < fiveMinutesAgo) { + await this.updateAttachmentLastRead(item); + } + } + break; + } + } + } + else if (type == 'setting') { + for (let id of ids) { + let [settingLibraryID, settingKey] = id.split('/'); + settingLibraryID = parseInt(settingLibraryID); + if (settingLibraryID != Zotero.Libraries.userLibraryID) { + continue; + } + if (settingKey.startsWith('lastRead_')) { + let [, librarySlug, itemKey] = settingKey.split('_'); + let libraryID; + if (librarySlug == 'u') { + continue; // lastRead_ synced settings are only used for group items + } + else if (librarySlug.startsWith('g')) { + libraryID = Zotero.Groups.getLibraryIDFromGroupID(parseInt(librarySlug.substring(1))); + } + else { + Zotero.debug('Invalid library slug in key: ' + settingKey); + continue; + } + let item = await Zotero.Items.getByLibraryAndKeyAsync(libraryID, itemKey); + if (item.isAttachment()) { + item.attachmentLastRead = Zotero.SyncedSettings.get(settingLibraryID, settingKey); + await item.saveTx({ skipDateModifiedUpdate: true }); + } + } + } + } + } +}; diff --git a/chrome/content/zotero/xpcom/collectionTreeRow.js b/chrome/content/zotero/xpcom/collectionTreeRow.js index aeb7c354b1..e5a826c760 100644 --- a/chrome/content/zotero/xpcom/collectionTreeRow.js +++ b/chrome/content/zotero/xpcom/collectionTreeRow.js @@ -56,6 +56,9 @@ Zotero.CollectionTreeRow.prototype.__defineGetter__('id', function () { case 'unfiled': return 'U' + this.ref.libraryID; + case 'recentlyRead': + return 'Y' + this.ref.libraryID; + case 'retracted': return 'R' + this.ref.libraryID; @@ -109,6 +112,10 @@ Zotero.CollectionTreeRow.prototype.isUnfiled = function () { return this.type == 'unfiled'; } +Zotero.CollectionTreeRow.prototype.isRecentlyRead = function () { + return this.type == 'recentlyRead'; +} + Zotero.CollectionTreeRow.prototype.isRetracted = function () { return this.type == 'retracted'; } @@ -187,7 +194,7 @@ Zotero.CollectionTreeRow.prototype.__defineGetter__('editable', function () { return true; } var libraryID = this.ref.libraryID; - if (this.isCollection() || this.isSearch() || this.isDuplicates() || this.isUnfiled() || this.isRetracted()) { + if (this.isCollection() || this.isSearch() || this.isDuplicates() || this.isUnfiled() || this.isRecentlyRead() || this.isRetracted()) { var type = Zotero.Libraries.get(libraryID).libraryType; if (type == 'group') { var groupID = Zotero.Groups.getGroupIDFromLibraryID(libraryID); @@ -210,7 +217,7 @@ Zotero.CollectionTreeRow.prototype.__defineGetter__('filesEditable', function () if (this.isGroup()) { return this.ref.editable && this.ref.filesEditable; } - if (this.isCollection() || this.isSearch() || this.isDuplicates() || this.isUnfiled() || this.isRetracted()) { + if (this.isCollection() || this.isSearch() || this.isDuplicates() || this.isUnfiled() || this.isRecentlyRead() || this.isRetracted()) { var type = Zotero.Libraries.get(libraryID).libraryType; if (type == 'group') { var groupID = Zotero.Groups.getGroupIDFromLibraryID(libraryID); @@ -223,7 +230,7 @@ Zotero.CollectionTreeRow.prototype.__defineGetter__('filesEditable', function () }); -Zotero.CollectionTreeRow.visibilityGroups = {'feed': 'feed', 'feeds': 'feeds'}; +Zotero.CollectionTreeRow.visibilityGroups = {'feed': 'feed', 'feeds': 'feeds', 'recentlyRead': 'recentlyRead'}; Zotero.CollectionTreeRow.prototype.__defineGetter__('visibilityGroup', function () { @@ -503,6 +510,10 @@ Zotero.CollectionTreeRow.prototype.isSearchMode = function () { } } +Zotero.CollectionTreeRow.prototype.isSortable = function () { + return !this.isFeedsOrFeed() && !this.isRecentlyRead(); +} + Zotero.CollectionTreeCache = { "lastTreeRow":null, "lastTempTable":null, diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js index cd8a798c59..9ae0b4f302 100644 --- a/chrome/content/zotero/xpcom/data/item.js +++ b/chrome/content/zotero/xpcom/data/item.js @@ -50,6 +50,7 @@ Zotero.Item = function (itemTypeOrID) { this._attachmentSyncedModificationTime = null; this._attachmentSyncedHash = null; this._attachmentLastProcessedModificationTime = null; + this._attachmentLastRead = null; // loadCreators this._creators = []; @@ -363,6 +364,7 @@ Zotero.Item.prototype._parseRowData = function (row) { case 'attachmentSyncedModificationTime': case 'attachmentSyncedHash': case 'attachmentLastProcessedModificationTime': + case 'attachmentLastRead': case 'createdByUserID': case 'lastModifiedByUserID': break; @@ -1938,13 +1940,13 @@ Zotero.Item.prototype._saveData = async function (env) { let sql = ""; let cols = [ 'parentItemID', 'linkMode', 'contentType', 'charsetID', 'path', 'syncState', - 'storageModTime', 'storageHash', 'lastProcessedModificationTime' + 'storageModTime', 'storageHash', 'lastProcessedModificationTime', 'lastRead' ]; // TODO: Replace with UPSERT after SQLite 3.24.0 if (isNew) { sql = "INSERT INTO itemAttachments " + "(itemID, " + cols.join(", ") + ") " - + "VALUES (?,?,?,?,?,?,?,?,?,?)"; + + "VALUES (?,?,?,?,?,?,?,?,?,?,?)"; } else { sql = "UPDATE itemAttachments SET " + cols.join("=?, ") + "=? WHERE itemID=?"; @@ -1959,6 +1961,7 @@ Zotero.Item.prototype._saveData = async function (env) { let storageModTime = this.attachmentSyncedModificationTime; let storageHash = this.attachmentSyncedHash; let lastProcessedModificationTime = this.attachmentLastProcessedModificationTime; + let lastRead = this.attachmentLastRead; if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE && libraryType != 'user') { throw new Error("Linked files can only be added to user library"); @@ -1974,6 +1977,7 @@ Zotero.Item.prototype._saveData = async function (env) { storageModTime !== undefined ? storageModTime : null, storageHash || null, lastProcessedModificationTime || null, + lastRead || null, ]; if (isNew) { params.unshift(itemID); @@ -1987,6 +1991,17 @@ Zotero.Item.prototype._saveData = async function (env) { if (!isNew && parentItemID) { reloadParentChildItems[parentItemID] = true; } + + // Save attachmentLastRead to a synced setting if this is a group item + if (libraryType == 'group' && lastRead !== undefined) { + let id = this._getLastReadSettingKey(); + if (lastRead === null) { + await Zotero.SyncedSettings.clear(Zotero.Libraries.userLibraryID, id); + } + else { + await Zotero.SyncedSettings.set(Zotero.Libraries.userLibraryID, id, lastRead); + } + } } // @@ -3547,6 +3562,49 @@ Zotero.defineProperty(Zotero.Item.prototype, 'attachmentSyncedHash', { }); +Zotero.defineProperty(Zotero.Item.prototype, 'attachmentLastRead', { + get() { + if (!this.isAttachment()) { + return undefined; + } + return this._attachmentLastRead; + }, + + set(val) { + if (!this.isAttachment()) { + throw new Error('attachmentLastRead can only be set for attachment items'); + } + if (!this.libraryID) { + throw new Error('Item not in library'); + } + if (this.libraryID != Zotero.Libraries.userLibraryID && !this.library.isGroup) { + throw new Error('attachmentLastRead can only be set on items in My Library and groups'); + } + + if (val !== null && typeof val != 'number') { + throw new Error('attachmentLastRead must be a number'); + } + if (val != parseInt(val)) { + throw new Error('attachmentLastRead must be an integer timestamp in seconds'); + } + + let lastReadItem = Zotero.Items.get(this.library.lastReadItemInSession); + if (!lastReadItem || lastReadItem.attachmentLastRead < val) { + this.library.lastReadItemInSession = this.id; + } + + if (val == this._attachmentLastRead) { + return; + } + if (!this._changed.attachmentData) { + this._changed.attachmentData = {}; + } + this._changed.attachmentData.lastRead = true; + this._attachmentLastRead = val; + } +}); + + // // PDF attachment properties // @@ -3627,20 +3685,22 @@ Zotero.Item.prototype.setAttachmentLastPageIndex = async function (val) { var id = this._getLastPageIndexSettingKey(); if (val === null) { - return Zotero.SyncedSettings.clear(id); + return Zotero.SyncedSettings.clear(Zotero.Libraries.userLibraryID, id); } return Zotero.SyncedSettings.set(Zotero.Libraries.userLibraryID, id, val); }; /** - * Get the key for the item's pageIndex synced setting + * Get the key for a synced setting related to this item * - * E.g., 'lastPageIndex_u_ABCD2345' or 'lastPageIndex_g123_ABCD2345' + * @param {String} prefix + * @param {Boolean} [ignoreInvalid=false] + * @return {String | false} */ -Zotero.Item.prototype._getLastPageIndexSettingKey = function (ignoreInvalid) { - var library = Zotero.Libraries.get(this.libraryID); - var id = 'lastPageIndex_'; +Zotero.Item.prototype._getSettingKey = function (prefix, ignoreInvalid = false) { + var library = this.library; + var id = prefix + '_'; switch (library.libraryType) { case 'user': id += 'u'; @@ -3651,7 +3711,7 @@ Zotero.Item.prototype._getLastPageIndexSettingKey = function (ignoreInvalid) { break; default: - var msg = `Can't get last page index key for ${library.libraryType} item`; + var msg = `Can't get ${prefix} key for ${library.libraryType} item`; if (ignoreInvalid) { Zotero.logError(msg); return false; @@ -3663,6 +3723,42 @@ Zotero.Item.prototype._getLastPageIndexSettingKey = function (ignoreInvalid) { }; +/** + * Get the key for the item's lastPageIndex synced setting + * + * E.g., 'lastPageIndex_u_ABCD2345' or 'lastPageIndex_g123_ABCD2345' + * + * @param {Boolean} [ignoreInvalid=false] + * @return {String | false} + */ +Zotero.Item.prototype._getLastPageIndexSettingKey = function (ignoreInvalid = false) { + return this._getSettingKey('lastPageIndex', ignoreInvalid); +}; + + +/** + * Get the key for the item's lastRead synced setting + * + * E.g., 'lastRead_g123_ABCD2345' in a group library. + * If this item is in a non-group library and ignoreInvalid isn't true, throws. + * + * @param {Boolean} [ignoreInvalid=false] + * @return {String | false} + */ +Zotero.Item.prototype._getLastReadSettingKey = function (ignoreInvalid = false) { + let library = this.library; + if (!library.isGroup) { + let msg = `Can't get lastRead key for ${library.libraryType} item`; + if (ignoreInvalid) { + Zotero.logError(msg); + return false; + } + throw new Error(msg); + } + return this._getSettingKey('lastRead', ignoreInvalid); +}; + + /** * Modification time of an attachment file * @@ -4050,6 +4146,22 @@ Zotero.Item.prototype.setAutoAttachmentTitle = function ({ forceFirstOfType } = }; +Zotero.Item.prototype.getItemLastRead = function () { + if (this.isAttachment()) { + return this.attachmentLastRead; + } + else { + let max = null; + for (let attachment of Zotero.Items.get(this.getAttachments(false))) { + if (!max || attachment.attachmentLastRead > max) { + max = attachment.attachmentLastRead; + } + } + return max; + } +}; + + //////////////////////////////////////////////////////// // // @@ -5142,6 +5254,13 @@ Zotero.Item.prototype._eraseData = async function (env) { if (id) { await Zotero.SyncedSettings.clear(Zotero.Libraries.userLibraryID, id); } + + // Delete last read synced setting for group items + if (this.library.isGroup) { + await Zotero.SyncedSettings.clear( + Zotero.Libraries.userLibraryID, this._getLastReadSettingKey() + ); + } } // Zotero.Sync.EventListeners.ChangeListener needs to know if this was a storage file @@ -5306,6 +5425,22 @@ Zotero.Item.prototype.fromJSON = function (json, options = {}) { this[field] = val; break; + case 'lastRead': + if (this.libraryID != Zotero.Libraries.userLibraryID) { + Zotero.logError(`Discarding invalid ${field} '${val}' for item ${this.libraryKey} (not in user library)`); + continue; + } + if (val) { + let i = parseInt(val); + if (!Number.isInteger(i)) { + Zotero.logError(`Discarding invalid ${field} '${val}' for item ${this.libraryKey}`); + continue; + } + val = i; + } + this.attachmentLastRead = val; + break; + case 'creators': //this.setCreators(json.creators.concat(extraCreators), options); this.setCreators(json.creators, options); @@ -5585,6 +5720,10 @@ Zotero.Item.prototype.toJSON = function (options = {}) { obj.filename = this.attachmentFilename; } + if (this.libraryID == Zotero.Libraries.userLibraryID && this.attachmentLastRead) { + obj.lastRead = this.attachmentLastRead; + } + if (this.isStoredFileAttachment() && !options.skipStorageProperties) { if (options.syncedStorageProperties) { let mtime = this.attachmentSyncedModificationTime; diff --git a/chrome/content/zotero/xpcom/data/items.js b/chrome/content/zotero/xpcom/data/items.js index c65e657103..afa6520871 100644 --- a/chrome/content/zotero/xpcom/data/items.js +++ b/chrome/content/zotero/xpcom/data/items.js @@ -78,6 +78,7 @@ Zotero.Items = function () { attachmentSyncedModificationTime: "IA.storageModTime AS attachmentSyncedModificationTime", attachmentSyncedHash: "IA.storageHash AS attachmentSyncedHash", attachmentLastProcessedModificationTime: "IA.lastProcessedModificationTime AS attachmentLastProcessedModificationTime", + attachmentLastRead: "IA.lastRead AS attachmentLastRead", }; } }, {lazy: true}); diff --git a/chrome/content/zotero/xpcom/data/library.js b/chrome/content/zotero/xpcom/data/library.js index 66cab2750a..22fefa57c5 100644 --- a/chrome/content/zotero/xpcom/data/library.js +++ b/chrome/content/zotero/xpcom/data/library.js @@ -38,6 +38,8 @@ Zotero.Library = function (params = {}) { this._hasSearches = null; this._storageDownloadNeeded = false; + this._lastReadItemInSession = null; + Zotero.Utilities.Internal.assignProps( this, params, @@ -126,6 +128,11 @@ Zotero.defineProperty(Zotero.Library.prototype, 'libraryType', { set: function (v) { return this._set('_libraryType', v); } }); +Zotero.defineProperty(Zotero.Library.prototype, 'lastReadItemInSession', { + get() { return this._lastReadItemInSession; }, + set(val) { this._lastReadItemInSession = val; } +}); + /** * Get the library-type-specific id for the library (e.g., userID for user library, * groupID for group library) diff --git a/chrome/content/zotero/xpcom/data/search.js b/chrome/content/zotero/xpcom/data/search.js index 36075d500b..594d1cbede 100644 --- a/chrome/content/zotero/xpcom/data/search.js +++ b/chrome/content/zotero/xpcom/data/search.js @@ -1469,22 +1469,23 @@ Zotero.Search.prototype._buildQuery = async function () { break; } - if (!skipOperators){ + if (!skipOperators) { // Special handling for date fields // // Note: We assume full datetimes are already UTC and don't // need to be handled specially - if ((condition['name']=='dateAdded' || - condition['name']=='dateModified' || - condition['name']=='datefield') && - !Zotero.Date.isSQLDateTime(condition['value'])){ + if ((condition.name == 'dateAdded' + || condition.name == 'dateModified' + || condition.name == 'lastRead' + || condition.name == 'datefield') + && !Zotero.Date.isSQLDateTime(condition.value)) { // TODO: document these flags var parseDate = null; var alt = null; var useFreeform = null; - switch (condition['operator']){ + switch (condition.operator) { case 'is': case 'isNot': var parseDate = true; @@ -1511,7 +1512,7 @@ Zotero.Search.prototype._buildQuery = async function () { break; default: - throw ('Invalid date field operator in search'); + throw new Error('Invalid date field operator in search'); } // Convert stored UTC dates to localtime @@ -1519,14 +1520,20 @@ Zotero.Search.prototype._buildQuery = async function () { // It'd be nice not to deal with time zones here at all, // but otherwise searching for the date part of a field // stored as UTC that wraps midnight would be unsuccessful - if (condition['name']=='dateAdded' || - condition['name']=='dateModified' || - condition['alias']=='accessDate'){ - condSQL += "DATE(" + condition['field'] + ", 'localtime')"; + // + // lastRead is a UNIX timestamp in seconds, so we need to + // explicitly pass 'unixepoch' + if (condition.name == 'lastRead') { + condSQL += "DATE(" + condition.field + ", 'unixepoch', 'localtime')"; + } + else if (condition.name == 'dateAdded' + || condition.name == 'dateModified' + || condition.alias == 'accessDate') { + condSQL += "DATE(" + condition.field + ", 'localtime')"; } // Only use first (SQL) part of multipart dates else { - condSQL += "SUBSTR(" + condition['field'] + ", 1, 10)"; + condSQL += "SUBSTR(" + condition.field + ", 1, 10)"; } if (parseDate){ diff --git a/chrome/content/zotero/xpcom/data/searchConditions.js b/chrome/content/zotero/xpcom/data/searchConditions.js index 5384e29bea..62f5769326 100644 --- a/chrome/content/zotero/xpcom/data/searchConditions.js +++ b/chrome/content/zotero/xpcom/data/searchConditions.js @@ -311,6 +311,19 @@ Zotero.SearchConditions = new function () { field: 'dateModified' }, + { + name: 'lastRead', + operators: { + is: true, + isNot: true, + isBefore: true, + isAfter: true, + isInTheLast: true + }, + table: 'itemAttachments', + field: 'lastRead' + }, + // Deprecated { name: 'itemTypeID', diff --git a/chrome/content/zotero/xpcom/notifier.js b/chrome/content/zotero/xpcom/notifier.js index 3cf3826ae0..2d7af1969d 100644 --- a/chrome/content/zotero/xpcom/notifier.js +++ b/chrome/content/zotero/xpcom/notifier.js @@ -116,9 +116,10 @@ Zotero.Notifier = new function () { * Possible values: * * event: 'add', 'modify', 'delete', 'move' ('c', for changing parent), - * 'remove' (ci, it), 'refresh', 'redraw', 'trash', 'unreadCountUpdated', 'index' + * 'remove' (ci, it), 'refresh', 'redraw', 'trash', 'unreadCountUpdated', 'index', + * 'pageChange' (file) * type - 'collection', 'search', 'item', 'collection-item', 'item-tag', 'tag', - * 'group', 'relation', 'feed', 'feedItem' + * 'group', 'relation', 'feed', 'feedItem', 'file' * ids - single id or array of ids * * Notes: diff --git a/chrome/content/zotero/xpcom/prefs.js b/chrome/content/zotero/xpcom/prefs.js index ba00fbb8da..66d6d69eb3 100644 --- a/chrome/content/zotero/xpcom/prefs.js +++ b/chrome/content/zotero/xpcom/prefs.js @@ -504,8 +504,9 @@ Zotero.Prefs = new function () { const prefKeys = { duplicates: 'duplicateLibraries', unfiled: 'unfiledLibraries', + recentlyRead: 'recentlyReadLibraries', retracted: 'retractedLibraries', - publications: 'publications', + publications: 'publications' }; let prefKey = prefKeys[type]; if (!prefKey) { @@ -538,6 +539,7 @@ Zotero.Prefs = new function () { const prefKeys = { duplicates: 'duplicateLibraries', unfiled: 'unfiledLibraries', + recentlyRead: 'recentlyReadLibraries', retracted: 'retractedLibraries', publications: 'publications' }; diff --git a/chrome/content/zotero/xpcom/reader.js b/chrome/content/zotero/xpcom/reader.js index 70e0147e77..290632ea76 100644 --- a/chrome/content/zotero/xpcom/reader.js +++ b/chrome/content/zotero/xpcom/reader.js @@ -970,15 +970,21 @@ class ReaderInstance { async _setState(state) { let item = Zotero.Items.get(this._item.id); if (item) { + let lastPageIndex; if (this._type === 'pdf') { - item.setAttachmentLastPageIndex(state.pageIndex); + lastPageIndex = state.pageIndex; } else if (this._type === 'epub') { - item.setAttachmentLastPageIndex(state.cfi); + lastPageIndex = state.cfi; } else if (this._type === 'snapshot') { - item.setAttachmentLastPageIndex(state.scrollYPercent); + lastPageIndex = state.scrollYPercent; } + else { + throw new Error('Unknown reader type: ' + this._type); + } + let pageChanged = item.getAttachmentLastPageIndex() != lastPageIndex; + item.setAttachmentLastPageIndex(lastPageIndex); let file = Zotero.Attachments.getStorageDirectory(item); if (!(await OS.File.exists(file.path))) { await Zotero.Attachments.createDirectoryForItem(item); @@ -1006,6 +1012,10 @@ class ReaderInstance { await IOUtils.writeJSON(path, state); }; this._pendingWriteStateTimeout = setTimeout(this._pendingWriteStateFunction, 5000); + + if (pageChanged) { + Zotero.Notifier.trigger('pageChange', 'file', item.id); + } } } diff --git a/chrome/content/zotero/xpcom/schema.js b/chrome/content/zotero/xpcom/schema.js index ecc87d9ce3..dc3143da34 100644 --- a/chrome/content/zotero/xpcom/schema.js +++ b/chrome/content/zotero/xpcom/schema.js @@ -3516,6 +3516,11 @@ Zotero.Schema = new function () { else if (i == 123) { await Zotero.DB.queryAsync("CREATE INDEX itemData_valueID ON itemData(valueID)"); } + + else if (i == 124) { + await Zotero.DB.queryAsync("ALTER TABLE itemAttachments ADD COLUMN lastRead INT"); + await Zotero.DB.queryAsync("CREATE INDEX itemAttachments_lastRead ON itemAttachments(lastRead)"); + } // If breaking compatibility or doing anything dangerous, clear minorUpdateFrom } diff --git a/chrome/content/zotero/xpcom/syncedSettings.js b/chrome/content/zotero/xpcom/syncedSettings.js index e30f1bb739..481c7d70e8 100644 --- a/chrome/content/zotero/xpcom/syncedSettings.js +++ b/chrome/content/zotero/xpcom/syncedSettings.js @@ -298,6 +298,10 @@ Zotero.SyncedSettings = (function () { var currentValue = this.get(libraryID, setting); var hasCurrentValue = currentValue !== null; + if (!hasCurrentValue) { + return false; + } + var id = libraryID + '/' + setting; var extraData = {}; diff --git a/chrome/content/zotero/xpcom/zotero.js b/chrome/content/zotero/xpcom/zotero.js index d91e72e82e..63e3f0e8e5 100644 --- a/chrome/content/zotero/xpcom/zotero.js +++ b/chrome/content/zotero/xpcom/zotero.js @@ -735,6 +735,7 @@ const { CommandLineOptions } = ChromeUtils.importESModule("chrome://zotero/conte await Zotero.Retractions.init(); await Zotero.Dictionaries.init(); Zotero.Reader.init(); + Zotero.AttachmentReadObserver.init(); // Load all library data except for items, which are loaded when libraries are first // clicked on or if otherwise necessary diff --git a/chrome/content/zotero/zotero.mjs b/chrome/content/zotero/zotero.mjs index 47a2a0f520..087c13489e 100644 --- a/chrome/content/zotero/zotero.mjs +++ b/chrome/content/zotero/zotero.mjs @@ -70,6 +70,7 @@ const xpcomFilesLocal = [ 'annotations', 'api', 'attachments', + 'attachmentReadObserver', 'browserDownload', 'cite', 'citeprocRsBridge', diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js index 9d406d5903..2a3ae4294f 100644 --- a/chrome/content/zotero/zoteroPane.js +++ b/chrome/content/zotero/zoteroPane.js @@ -2302,6 +2302,7 @@ var ZoteroPane = new function () { else if (collectionTreeRow.isLibrary(true) || collectionTreeRow.isSearch() || collectionTreeRow.isUnfiled() + || collectionTreeRow.isRecentlyRead() || collectionTreeRow.isRetracted() || collectionTreeRow.isDuplicates()) { // In library, don't prompt if meta key was pressed @@ -2408,6 +2409,11 @@ var ZoteroPane = new function () { this.setVirtual(collectionTreeRow.ref.libraryID, 'unfiled', false); return; } + // Remove virtual recently read collection + else if (collectionTreeRow.isRecentlyRead()) { + this.setVirtual(collectionTreeRow.ref.libraryID, 'recentlyRead', false); + return; + } // Remove virtual retracted collection else if (collectionTreeRow.isRetracted()) { this.setVirtual(collectionTreeRow.ref.libraryID, 'retracted', false); @@ -3336,6 +3342,12 @@ var ZoteroPane = new function () { this.setVirtual(this.getSelectedLibraryID(), 'unfiled', true, true); } }, + { + id: "showRecentlyRead", + oncommand: () => { + this.setVirtual(this.getSelectedLibraryID(), 'recentlyRead', true, true); + } + }, { id: "showRetracted", oncommand: () => { @@ -3569,7 +3581,8 @@ var ZoteroPane = new function () { else if (collectionTreeRow.isTrash()) { show = ['emptyTrash']; } - else if (collectionTreeRow.isDuplicates() || collectionTreeRow.isUnfiled() || collectionTreeRow.isRetracted()) { + else if (collectionTreeRow.isDuplicates() || collectionTreeRow.isUnfiled() || collectionTreeRow.isRecentlyRead() + || collectionTreeRow.isRetracted()) { show = ['deleteCollection']; m.deleteCollection.setAttribute('label', Zotero.getString('general.hide')); @@ -3601,13 +3614,16 @@ var ZoteroPane = new function () { let unfiled = Zotero.Prefs.getVirtualCollectionStateForLibrary( libraryID, 'unfiled' ); + let recentlyRead = Zotero.Prefs.getVirtualCollectionStateForLibrary( + libraryID, 'recentlyRead' + ); let retracted = Zotero.Prefs.getVirtualCollectionStateForLibrary( libraryID, 'retracted' ); let publications = Zotero.Prefs.getVirtualCollectionStateForLibrary( libraryID, 'publications' ); - if (!duplicates || !unfiled || !retracted || !publications) { + if (!duplicates || !unfiled || !recentlyRead || !retracted || !publications) { if (!library.archived) { show.push('sep2'); } @@ -3617,6 +3633,9 @@ var ZoteroPane = new function () { if (!unfiled) { show.push('showUnfiled'); } + if (!recentlyRead) { + show.push('showRecentlyRead'); + } if (!retracted) { show.push('showRetracted'); } diff --git a/chrome/content/zotero/zoteroPane.xhtml b/chrome/content/zotero/zoteroPane.xhtml index a0291c2ec0..e345f06214 100644 --- a/chrome/content/zotero/zoteroPane.xhtml +++ b/chrome/content/zotero/zoteroPane.xhtml @@ -939,6 +939,7 @@ + diff --git a/chrome/locale/en-US/zotero/zotero.ftl b/chrome/locale/en-US/zotero/zotero.ftl index b96e55bcba..3f88c42221 100644 --- a/chrome/locale/en-US/zotero/zotero.ftl +++ b/chrome/locale/en-US/zotero/zotero.ftl @@ -179,6 +179,8 @@ zotero-toolbar-tabs-scroll-backwards = toolbar-add-attachment = .tooltiptext = { add-attachment } +collections-menu-show-recently-read = + .label = Show Recently Read collections-menu-rename-collection = .label = Rename Collection collections-menu-edit-saved-search = diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties index b44ebb9dc2..12ddcc3b71 100644 --- a/chrome/locale/en-US/zotero/zotero.properties +++ b/chrome/locale/en-US/zotero/zotero.properties @@ -273,6 +273,7 @@ pane.collections.feedLibraries = Feeds pane.collections.trash = Trash pane.collections.untitled = Untitled pane.collections.unfiled = Unfiled Items +pane.collections.recentlyRead = Recently Read pane.collections.retracted = Retracted Items pane.collections.duplicate = Duplicate Items pane.collections.removeLibrary = Remove Library @@ -374,6 +375,7 @@ pane.items.menu.duplicateAndConvert.toBookSection = Create Book Section pane.items.menu.duplicateAndConvert.toBook = Create Book from Book Section pane.items.menu.showInFeed = Show in Feed pane.items.showItemInLibrary = Show Item in Library +pane.items.columns.lastRead = Last Read pane.items.letter.oneParticipant = Letter to %S pane.items.letter.twoParticipants = Letter to %S and %S @@ -838,6 +840,7 @@ searchConditions.dateModified = Date Modified searchConditions.fulltextContent = Attachment Content searchConditions.programmingLanguage = Programming Language searchConditions.fileTypeID = Attachment File Type +searchConditions.lastRead = Attachment Last Read searchConditions.annotationText = Annotation Text searchConditions.annotationComment = Annotation Comment searchConditions.anyField = Any Field diff --git a/chrome/skin/default/zotero/collection-tree/16/universal/recent.svg b/chrome/skin/default/zotero/collection-tree/16/universal/recent.svg new file mode 100644 index 0000000000..80306f25e6 --- /dev/null +++ b/chrome/skin/default/zotero/collection-tree/16/universal/recent.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/resource/schema/userdata.sql b/resource/schema/userdata.sql index 214f535b48..715cca5786 100644 --- a/resource/schema/userdata.sql +++ b/resource/schema/userdata.sql @@ -1,4 +1,4 @@ --- 123 +-- 124 -- Copyright (c) 2009 Center for History and New Media -- George Mason University, Fairfax, Virginia, USA @@ -211,6 +211,7 @@ CREATE TABLE itemAttachments ( storageModTime INT, storageHash TEXT, lastProcessedModificationTime INT, + lastRead INT, FOREIGN KEY (itemID) REFERENCES items(itemID) ON DELETE CASCADE, FOREIGN KEY (parentItemID) REFERENCES items(itemID) ON DELETE CASCADE, FOREIGN KEY (charsetID) REFERENCES charsets(charsetID) ON DELETE SET NULL @@ -220,6 +221,7 @@ CREATE INDEX itemAttachments_charsetID ON itemAttachments(charsetID); CREATE INDEX itemAttachments_contentType ON itemAttachments(contentType); CREATE INDEX itemAttachments_syncState ON itemAttachments(syncState); CREATE INDEX itemAttachments_lastProcessedModificationTime ON itemAttachments(lastProcessedModificationTime); +CREATE INDEX itemAttachments_lastRead ON itemAttachments(lastRead); CREATE TABLE itemAnnotations ( itemID INTEGER PRIMARY KEY, diff --git a/scss/components/_collection-tree.scss b/scss/components/_collection-tree.scss index 0cae11a831..d03b3b9a1c 100644 --- a/scss/components/_collection-tree.scss +++ b/scss/components/_collection-tree.scss @@ -1,10 +1,17 @@ -$icons: ( +// Themed icons -- separate SVGs per color in dark/, light/, white/ +$themed-icons: ( collection, duplicates, feed, feed-error, feed-library, feed-updating, groups, library, library-group, publications, trash-full, trash, unfiled, retracted, search, ); -@mixin -icon-collection-type-rules { - @each $icon in $icons { +// Universal icons -- single SVG with context-fill, colored via CSS +// Map of icon name to fill color variable +$universal-icons: ( + recent: --tag-purple, +); + +@mixin -themed-icon-rules { + @each $icon in $themed-icons { .icon-css.icon-#{$icon} { @include focus-states using ($color) { @include svgicon($icon, $color, "16", "collection-tree"); @@ -13,15 +20,37 @@ $icons: ( } } -// Universal icon rules -@include focus-states using ($color) { - @include -icon-collection-type-rules; +@mixin -universal-icon-rules { + @each $icon, $fill in $universal-icons { + .icon-css.icon-#{$icon} { + @include svgicon($icon, "universal", "16", "collection-tree"); + @include focus-states using ($color) { + @if $color == "white" { + fill: white; + } + @else { + fill: var(#{$fill}); + } + } + } + } +} + +@include focus-states using ($color) { + @include -themed-icon-rules; } -// Selection and focus aware icon rules .focus-states-target { .row { - @include -icon-collection-type-rules; + @include -themed-icon-rules; + } +} + +@include -universal-icon-rules; + +.focus-states-target { + .row { + @include -universal-icon-rules; } } @@ -60,7 +89,7 @@ $icons: ( height: 16px; } - @each $icon in $icons { + @each $icon in $themed-icons { .icon-css.icon-#{$icon} { @include focus-states( ".row.selected", diff --git a/test/tests/attachmentReadObserverTest.js b/test/tests/attachmentReadObserverTest.js new file mode 100644 index 0000000000..1262dfddc3 --- /dev/null +++ b/test/tests/attachmentReadObserverTest.js @@ -0,0 +1,73 @@ +"use strict"; + +describe("Zotero.AttachmentReadObserver", function () { + describe("file events", function () { + beforeEach(function () { + Zotero.Libraries.userLibrary.lastReadItemInSession = null; + }); + + it("should update an attachment's attachmentLastRead every time it is opened", async function () { + let attachment = await importPDFAttachment(null); + + // We open the attachment at midnight on January 1 + let stub = sinon.stub(Zotero.AttachmentReadObserver, '_getCurrentDate') + .callsFake(() => new Date(2023, 1, 1, 0, 0, 0)); + await Zotero.Notifier.trigger('open', 'file', [attachment.id]); + let initialLastRead = attachment.attachmentLastRead; + assert.isNumber(initialLastRead); + + // We open it again, only five seconds later + stub.callsFake(() => new Date(2023, 1, 1, 0, 0, 5)); + await Zotero.Notifier.trigger('open', 'file', [attachment.id]); + assert.isAbove(attachment.attachmentLastRead, initialLastRead); + + stub.restore(); + }); + + it("should update an attachment's attachmentLastRead every five minutes when the page changes", async function () { + let attachment = await importPDFAttachment(null); + + // We open the attachment at midnight on January 1 + let stub = sinon.stub(Zotero.AttachmentReadObserver, '_getCurrentDate') + .callsFake(() => new Date(2023, 1, 1, 0, 0, 0)); + await Zotero.Notifier.trigger('open', 'file', [attachment.id]); + let initialLastRead = attachment.attachmentLastRead; + assert.isNumber(initialLastRead); + + // We change pages, only five seconds later + stub.callsFake(() => new Date(2023, 1, 1, 0, 0, 5)); + await Zotero.Notifier.trigger('pageChange', 'file', [attachment.id]); + assert.equal(attachment.attachmentLastRead, initialLastRead); + + // We change pages again, five minutes later + stub.callsFake(() => new Date(2023, 1, 1, 0, 5, 5)); + await Zotero.Notifier.trigger('pageChange', 'file', [attachment.id]); + let updatedLastRead = attachment.attachmentLastRead; + assert.isAbove(updatedLastRead, initialLastRead); + + // We change pages again, a minute after that + stub.callsFake(() => new Date(2023, 1, 1, 0, 6, 5)); + await Zotero.Notifier.trigger('pageChange', 'file', [attachment.id]); + assert.equal(attachment.attachmentLastRead, updatedLastRead); + + stub.restore(); + }); + }); + + describe("setting events", function () { + it("should update a group attachment's attachmentLastRead when the associated synced setting changes", async function () { + let group = await createGroup(); + let item = await createDataObject('item', { libraryID: group.libraryID }); + let attachment = await importPDFAttachment(item); + let key = attachment._getLastReadSettingKey(); + + let firstValue = 1674668000; + await Zotero.SyncedSettings.set(Zotero.Libraries.userLibraryID, key, firstValue); + assert.equal(attachment.attachmentLastRead, firstValue); + + let secondValue = 1674668123; + await Zotero.SyncedSettings.set(Zotero.Libraries.userLibraryID, key, secondValue); + assert.equal(attachment.attachmentLastRead, secondValue); + }); + }); +}); diff --git a/test/tests/collectionTreeTest.js b/test/tests/collectionTreeTest.js index d76c090a98..7f630fef54 100644 --- a/test/tests/collectionTreeTest.js +++ b/test/tests/collectionTreeTest.js @@ -604,8 +604,8 @@ describe("Zotero.CollectionTree", function () { await createDataObject('collection', { libraryID: group.libraryID }); await select(win, c2); - // Group, collections, Duplicates, Unfiled, and trash - assert.equal(cv._rows.length, originalRowCount + 9); + // Group, collections, Recently Read, Duplicates, Unfiled, and trash + assert.equal(cv._rows.length, originalRowCount + 10); // Select group await cv.selectLibrary(group.libraryID); diff --git a/test/tests/itemTest.js b/test/tests/itemTest.js index b33b6e3ab3..99d0aec0b9 100644 --- a/test/tests/itemTest.js +++ b/test/tests/itemTest.js @@ -2159,6 +2159,20 @@ describe("Zotero.Item", function () { assert.equal(Zotero.Users.getCurrentName(), username); }); + + it("should save a group attachment's attachmentLastRead to the database", async function () { + let group = await createGroup(); + let libraryID = group.libraryID; + let item = await createDataObject('item', { libraryID }); + let attachment = await importPDFAttachment(item); + attachment.attachmentLastRead = 1674668111; + await attachment.saveTx(); + + let dbVal = await Zotero.DB.valueQueryAsync( + "SELECT lastRead FROM itemAttachments WHERE itemID=?", attachment.id + ); + assert.equal(dbVal, attachment.attachmentLastRead); + }); }) @@ -2457,6 +2471,23 @@ describe("Zotero.Item", function () { assert.notProperty(json, 'charset'); assert.notProperty(json, 'path'); }); + + it("should output lastRead for a user library item", async function () { + let attachment = await createDataObject('item', { itemType: 'attachment' }); + attachment.attachmentLastRead = 123450000; + let json = attachment.toJSON(); + assert.equal(json.lastRead, 123450000); + }); + + it("shouldn't output lastRead for a group item", async function () { + let group = await createGroup(); + let attachment = await createDataObject('item', { itemType: 'attachment', libraryID: group.libraryID }); + attachment.attachmentLastRead = 123450000; + assert.equal(attachment.attachmentLastRead, 123450000); + let json = attachment.toJSON(); + assert.notProperty(json, 'lastRead'); + await group.eraseTx(); + }); }); describe("Annotations", function () { diff --git a/test/tests/itemTreeTest.js b/test/tests/itemTreeTest.js index 857f3be6d2..b61439cc19 100644 --- a/test/tests/itemTreeTest.js +++ b/test/tests/itemTreeTest.js @@ -1005,6 +1005,60 @@ describe("Zotero.ItemTree", function () { }); }); + it("should re-sort by Last Read when child attachmentLastRead is updated in the user library", async function () { + let userLibraryID = Zotero.Libraries.userLibraryID; + let item1 = await createDataObject('item', { libraryID: userLibraryID }); + let attachment1 = await importPDFAttachment(item1); + let item2 = await createDataObject('item', { libraryID: userLibraryID }); + let attachment2 = await importPDFAttachment(item2); + assert.notOk(item1.getItemLastRead()); + assert.notOk(item2.getItemLastRead()); + + // attachment2 is more recently opened + attachment1.attachmentLastRead = Math.round(Date.now() / 1000) - 5; + attachment2.attachmentLastRead = Math.round(Date.now() / 1000); + await attachment1.saveTx(); + await attachment2.saveTx(); + + await zp.setVirtual(userLibraryID, 'recentlyRead', true, true); + assert.equal(zp.getCollectionTreeRow().id, 'Y' + userLibraryID); + await waitForItemsLoad(win); + assert.isAbove(zp.itemsView.getRowIndexByID(item1.id), zp.itemsView.getRowIndexByID(item2.id)); + + // Now make attachment2 much less recently opened + attachment2.attachmentLastRead = Math.round(Date.now() / 1000) - 60; + await attachment2.saveTx(); + + assert.isBelow(zp.itemsView.getRowIndexByID(item1.id), zp.itemsView.getRowIndexByID(item2.id)); + }); + + it("should re-sort by Last Read when child attachmentLastRead is updated in a group library", async function () { + let groupLibraryID = (await createGroup()).libraryID; + let item1 = await createDataObject('item', { libraryID: groupLibraryID }); + let attachment1 = await importPDFAttachment(item1); + let item2 = await createDataObject('item', { libraryID: groupLibraryID }); + let attachment2 = await importPDFAttachment(item2); + assert.notOk(item1.getItemLastRead()); + assert.notOk(item2.getItemLastRead()); + + // attachment2 is more recently opened + attachment1.attachmentLastRead = Math.round(Date.now() / 1000) - 5; + attachment2.attachmentLastRead = Math.round(Date.now() / 1000); + await attachment1.saveTx(); + await attachment2.saveTx(); + + await zp.setVirtual(groupLibraryID, 'recentlyRead', true, true); + assert.equal(zp.getCollectionTreeRow().id, 'Y' + groupLibraryID); + await waitForItemsLoad(win); + assert.isAbove(zp.itemsView.getRowIndexByID(item1.id), zp.itemsView.getRowIndexByID(item2.id)); + + // Now make attachment2 much less recently opened + attachment2.attachmentLastRead = Math.round(Date.now() / 1000) - 60; + await attachment2.saveTx(); + + assert.isBelow(zp.itemsView.getRowIndexByID(item1.id), zp.itemsView.getRowIndexByID(item2.id)); + }); + describe("Trash", function () { it("should remove untrashed parent item when last trashed child is deleted", async function () { var item = await createDataObject('item');