Track attachment last-read time, add Recently Read virtual collection (#2854)

Track when attachments are last opened or read, storing a `lastRead` Unix timestamp on the attachment. For user library items, `lastRead` syncs as an attachment property in item JSON. For group library items, it syncs via a per-user synced setting (like `lastPageIndex`).

- Add `lastRead` column to `itemAttachments`
- Add `AttachmentReadObserver` to update `lastRead` on file open and page change (throttled to 5 min for page changes)
- Add "Recently Read" virtual collection (items read in last 14 days, sorted by `lastRead` descending)
- Add `lastRead` search condition with date operators
- Add `lastRead` item tree column with new `dependsOnChildren` property for parent item aggregation
- Add `getItemLastRead()` to return max `lastRead` across child attachments

Also:

- Generalize collection tree SCSS to support universal (context-fill) icons alongside themed icons

---------

Co-authored-by: Dan Stillman <dstillman@zotero.org>
This commit is contained in:
Abe Jellinek 2026-03-17 13:52:00 -04:00 committed by GitHub
parent 0ce5cd5bac
commit 34039991f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 713 additions and 64 deletions

View file

@ -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);
}

View file

@ -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

View file

@ -360,6 +360,7 @@
case 'date':
case 'dateAdded':
case 'dateModified':
case 'lastRead':
case 'itemType':
case 'fileTypeID':
case 'publicationTitle':

View file

@ -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<void>}
*/
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);
}

View file

@ -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: <Icons.IconAttachSmall />,
fixedWidth: true,
@ -352,6 +364,7 @@ const COLUMNS = [
dataKey: "numNotes",
disabledIn: ["feeds", "feed"],
showInColumnPicker: true,
dependsOnChildren: true,
label: "zotero.tabs.notes.label",
iconLabel: <Icons.IconTreeitemNoteSmall />,
width: "26",

View file

@ -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 <http://www.gnu.org/licenses/>.
***** 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 });
}
}
}
}
}
};

View file

@ -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,

View file

@ -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;

View file

@ -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});

View file

@ -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)

View file

@ -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){

View file

@ -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',

View file

@ -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:

View file

@ -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'
};

View file

@ -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);
}
}
}

View file

@ -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
}

View file

@ -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 = {};

View file

@ -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

View file

@ -70,6 +70,7 @@ const xpcomFilesLocal = [
'annotations',
'api',
'attachments',
'attachmentReadObserver',
'browserDownload',
'cite',
'citeprocRsBridge',

View file

@ -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');
}

View file

@ -939,6 +939,7 @@
<menuseparator/>
<menuitem class="zotero-menuitem-show-duplicates" label="&zotero.toolbar.duplicate.label;"/>
<menuitem class="zotero-menuitem-show-unfiled" label="&zotero.collections.showUnfiledItems;"/>
<menuitem class="zotero-menuitem-show-recently-read" data-l10n-id="collections-menu-show-recently-read"/>
<menuitem class="zotero-menuitem-show-retracted" label="&zotero.collections.showRetractedItems;"/>
<menuitem class="zotero-menuitem-show-publications" data-l10n-id="show-publications-menuitem" />
<menuitem class="zotero-menuitem-edit-collection"/>

View file

@ -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 =

View file

@ -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

View file

@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2289_29)">
<path d="M7.9996 0C12.4179 0 15.9996 3.58172 15.9996 8C15.9996 12.4183 12.4179 16 7.9996 16C3.58151 15.9998 -0.000396729 12.4181 -0.000396729 8C-0.000396729 3.58185 3.58151 0.000214404 7.9996 0ZM7.9996 1C4.13379 1.00021 0.999603 4.13414 0.999603 8C0.999603 11.8659 4.13379 14.9998 7.9996 15C11.8656 15 14.9996 11.866 14.9996 8C14.9996 4.13401 11.8656 1 7.9996 1ZM7.9996 8H11.9996V9H6.9996V3H7.9996V8Z" fill="context-fill"/>
</g>
<defs>
<clipPath id="clip0_2289_29">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 669 B

View file

@ -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,

View file

@ -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",

View file

@ -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);
});
});
});

View file

@ -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);

View file

@ -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 () {

View file

@ -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');