Add Retracted Items virtual collection

Shown automatically when retracted items are detected
This commit is contained in:
Dan Stillman 2019-06-10 02:37:54 -04:00
parent 502f5fe491
commit 5c03813d81
16 changed files with 417 additions and 78 deletions

View file

@ -54,6 +54,9 @@ Zotero.CollectionTreeRow.prototype.__defineGetter__('id', function () {
case 'unfiled': case 'unfiled':
return 'U' + this.ref.libraryID; return 'U' + this.ref.libraryID;
case 'retracted':
return 'R' + this.ref.libraryID;
case 'publications': case 'publications':
return 'P' + this.ref.libraryID; return 'P' + this.ref.libraryID;
@ -100,6 +103,10 @@ Zotero.CollectionTreeRow.prototype.isUnfiled = function () {
return this.type == 'unfiled'; return this.type == 'unfiled';
} }
Zotero.CollectionTreeRow.prototype.isRetracted = function () {
return this.type == 'retracted';
}
Zotero.CollectionTreeRow.prototype.isTrash = function() Zotero.CollectionTreeRow.prototype.isTrash = function()
{ {
return this.type == 'trash'; return this.type == 'trash';
@ -162,7 +169,7 @@ Zotero.CollectionTreeRow.prototype.__defineGetter__('editable', function () {
return true; return true;
} }
var libraryID = this.ref.libraryID; var libraryID = this.ref.libraryID;
if (this.isCollection() || this.isSearch() || this.isDuplicates() || this.isUnfiled()) { if (this.isCollection() || this.isSearch() || this.isDuplicates() || this.isUnfiled() || this.isRetracted()) {
var type = Zotero.Libraries.get(libraryID).libraryType; var type = Zotero.Libraries.get(libraryID).libraryType;
if (type == 'group') { if (type == 'group') {
var groupID = Zotero.Groups.getGroupIDFromLibraryID(libraryID); var groupID = Zotero.Groups.getGroupIDFromLibraryID(libraryID);
@ -185,7 +192,7 @@ Zotero.CollectionTreeRow.prototype.__defineGetter__('filesEditable', function ()
if (this.isGroup()) { if (this.isGroup()) {
return this.ref.filesEditable; return this.ref.filesEditable;
} }
if (this.isCollection() || this.isSearch() || this.isDuplicates() || this.isUnfiled()) { if (this.isCollection() || this.isSearch() || this.isDuplicates() || this.isUnfiled() || this.isRetracted()) {
var type = Zotero.Libraries.get(libraryID).libraryType; var type = Zotero.Libraries.get(libraryID).libraryType;
if (type == 'group') { if (type == 'group') {
var groupID = Zotero.Groups.getGroupIDFromLibraryID(libraryID); var groupID = Zotero.Groups.getGroupIDFromLibraryID(libraryID);

View file

@ -183,6 +183,8 @@ Zotero.CollectionTreeView.prototype.refresh = Zotero.Promise.coroutine(function*
} }
this._virtualCollectionLibraries.unfiled = this._virtualCollectionLibraries.unfiled =
Zotero.Utilities.Internal.getVirtualCollectionState('unfiled') Zotero.Utilities.Internal.getVirtualCollectionState('unfiled')
this._virtualCollectionLibraries.retracted =
Zotero.Utilities.Internal.getVirtualCollectionState('retracted');
var oldCount = this.rowCount || 0; var oldCount = this.rowCount || 0;
var newRows = []; var newRows = [];
@ -790,6 +792,9 @@ Zotero.CollectionTreeView.prototype.getImageSrc = function(row, col)
case 'publications': case 'publications':
return "chrome://zotero/skin/treeitem-journalArticle" + suffix + ".png"; return "chrome://zotero/skin/treeitem-journalArticle" + suffix + ".png";
case 'retracted':
return "chrome://zotero/skin/cross" + suffix + ".png";
} }
return "chrome://zotero/skin/treesource-" + collectionType + suffix + ".png"; return "chrome://zotero/skin/treesource-" + collectionType + suffix + ".png";
@ -823,6 +828,8 @@ Zotero.CollectionTreeView.prototype.isContainerEmpty = function(row)
|| this._virtualCollectionLibraries.duplicates[libraryID] === false) || this._virtualCollectionLibraries.duplicates[libraryID] === false)
// Unfiled Items not shown // Unfiled Items not shown
&& this._virtualCollectionLibraries.unfiled[libraryID] === false && this._virtualCollectionLibraries.unfiled[libraryID] === false
// Retracted Items not shown
&& this._virtualCollectionLibraries.retracted[libraryID] === false
&& this.hideSources.indexOf('trash') != -1; && this.hideSources.indexOf('trash') != -1;
} }
if (treeRow.isCollection()) { if (treeRow.isCollection()) {
@ -1071,6 +1078,7 @@ Zotero.CollectionTreeView.prototype.selectByID = Zotero.Promise.coroutine(functi
case 'D': case 'D':
case 'U': case 'U':
case 'R':
yield this.expandLibrary(id); yield this.expandLibrary(id);
break; break;
@ -1326,6 +1334,8 @@ Zotero.CollectionTreeView.prototype._expandRow = Zotero.Promise.coroutine(functi
var showDuplicates = this.hideSources.indexOf('duplicates') == -1 var showDuplicates = this.hideSources.indexOf('duplicates') == -1
&& this._virtualCollectionLibraries.duplicates[libraryID] !== false; && this._virtualCollectionLibraries.duplicates[libraryID] !== false;
var showUnfiled = this._virtualCollectionLibraries.unfiled[libraryID] !== false; var showUnfiled = this._virtualCollectionLibraries.unfiled[libraryID] !== false;
var showRetracted = this._virtualCollectionLibraries.retracted[libraryID] !== false
&& Zotero.Retractions.libraryHasRetractedItems(libraryID);
var showPublications = libraryID == Zotero.Libraries.userLibraryID; var showPublications = libraryID == Zotero.Libraries.userLibraryID;
var showTrash = this.hideSources.indexOf('trash') == -1; var showTrash = this.hideSources.indexOf('trash') == -1;
} }
@ -1333,6 +1343,7 @@ Zotero.CollectionTreeView.prototype._expandRow = Zotero.Promise.coroutine(functi
var savedSearches = []; var savedSearches = [];
var showDuplicates = false; var showDuplicates = false;
var showUnfiled = false; var showUnfiled = false;
var showRetracted = false;
var showPublications = false; var showPublications = false;
var showTrash = false; var showTrash = false;
} }
@ -1346,7 +1357,7 @@ Zotero.CollectionTreeView.prototype._expandRow = Zotero.Promise.coroutine(functi
return 0; return 0;
} }
var startOpen = !!(collections.length || savedSearches.length || showDuplicates || showUnfiled || showTrash); var startOpen = !!(collections.length || savedSearches.length || showDuplicates || showUnfiled || showRetracted || showTrash);
// If this isn't a manual open, set the initial state depending on whether // If this isn't a manual open, set the initial state depending on whether
// there are child nodes // there are child nodes
@ -1430,6 +1441,21 @@ Zotero.CollectionTreeView.prototype._expandRow = Zotero.Promise.coroutine(functi
newRows++; newRows++;
} }
// Retracted items
if (showRetracted) {
let s = new Zotero.Search;
s.libraryID = libraryID;
s.name = Zotero.getString('pane.collections.retracted');
s.addCondition('libraryID', 'is', libraryID);
s.addCondition('retracted', 'true');
this._addRowToArray(
rows,
new Zotero.CollectionTreeRow(this, 'retracted', s, level + 1),
row + 1 + newRows
);
newRows++;
}
if (showTrash) { if (showTrash) {
let deletedItems = yield Zotero.Items.getDeleted(libraryID); let deletedItems = yield Zotero.Items.getDeleted(libraryID);
if (deletedItems.length || Zotero.Prefs.get("showTrashWhenEmpty")) { if (deletedItems.length || Zotero.Prefs.get("showTrashWhenEmpty")) {

View file

@ -972,6 +972,10 @@ Zotero.Search.prototype._buildQuery = Zotero.Promise.coroutine(function* () {
var unfiled = condition.operator == 'true'; var unfiled = condition.operator == 'true';
continue; continue;
case 'retracted':
var retracted = condition.operator == 'true';
continue;
case 'publications': case 'publications':
var publications = condition.operator == 'true'; var publications = condition.operator == 'true';
continue; continue;
@ -1034,6 +1038,10 @@ Zotero.Search.prototype._buildQuery = Zotero.Promise.coroutine(function* () {
+ "AND itemID NOT IN (SELECT itemID FROM publicationsItems)"; + "AND itemID NOT IN (SELECT itemID FROM publicationsItems)";
} }
if (retracted) {
sql += " AND (itemID IN (SELECT itemID FROM retractedItems))";
}
if (publications) { if (publications) {
sql += " AND (itemID IN (SELECT itemID FROM publicationsItems))"; sql += " AND (itemID IN (SELECT itemID FROM publicationsItems))";
} }

View file

@ -99,6 +99,14 @@ Zotero.SearchConditions = new function(){
} }
}, },
{
name: 'retracted',
operators: {
true: true,
false: true
}
},
{ {
name: 'publications', name: 'publications',
operators: { operators: {

View file

@ -44,13 +44,17 @@ Zotero.Retractions = {
_keyItems: {}, _keyItems: {},
_itemKeys: {}, _itemKeys: {},
_retractedItems: new Set(),
_retractedItemsByLibrary: {},
_librariesWithRetractions: new Set(),
init: async function () { init: async function () {
this._cacheFile = OS.Path.join(Zotero.Profile.dir, 'retractions.json'); this._cacheFile = OS.Path.join(Zotero.Profile.dir, 'retractions.json');
// Load mappings of keys (DOI hashes and PMIDs) to items and vice versa and register for // Load mappings of keys (DOI hashes and PMIDs) to items and vice versa and register for
// item changes so they can be kept up to date in notify(). // item changes so they can be kept up to date in notify().
await this._cacheKeyMappings(); await this._cacheKeyMappings();
Zotero.Notifier.registerObserver(this, ['item'], 'retractions'); Zotero.Notifier.registerObserver(this, ['item', 'group'], 'retractions', 20);
// Load in the cached prefix list that we check new items against // Load in the cached prefix list that we check new items against
try { try {
@ -62,8 +66,20 @@ Zotero.Retractions = {
} }
// Load existing retracted items // Load existing retracted items
var itemIDs = await Zotero.DB.columnQueryAsync("SELECT itemID FROM retractedItems"); var rows = await Zotero.DB.queryAsync(
this._retractedItems = new Set(itemIDs); "SELECT libraryID, itemID, DI.itemID IS NOT NULL AS deleted FROM items "
+ "JOIN retractedItems USING (itemID) "
+ "LEFT JOIN deletedItems DI USING (itemID)"
);
for (let row of rows) {
this._retractedItems.add(row.itemID);
if (!row.deleted) {
if (!this._retractedItemsByLibrary[row.libraryID]) {
this._retractedItemsByLibrary[row.libraryID] = new Set();
}
this._retractedItemsByLibrary[row.libraryID].add(row.itemID);
}
}
this._initialized = true; this._initialized = true;
@ -81,6 +97,55 @@ Zotero.Retractions = {
return this._retractedItems.has(item.id); return this._retractedItems.has(item.id);
}, },
libraryHasRetractedItems: function (libraryID) {
return !!(this._retractedItemsByLibrary[libraryID]
&& this._retractedItemsByLibrary[libraryID].size);
},
_addLibraryRetractedItem: async function (libraryID, itemID) {
if (!this._retractedItemsByLibrary[libraryID]) {
this._retractedItemsByLibrary[libraryID] = new Set();
}
this._retractedItemsByLibrary[libraryID].add(itemID);
await this._updateLibraryRetractions(libraryID);
},
_removeLibraryRetractedItem: async function (libraryID, itemID) {
this._retractedItemsByLibrary[libraryID].delete(itemID);
await this._updateLibraryRetractions(libraryID);
},
_updateLibraryRetractions: async function (libraryID) {
var previous = this._librariesWithRetractions.has(libraryID);
var current = this.libraryHasRetractedItems(libraryID);
// Update Retracted Items virtual collection
if (Zotero.Libraries.exists(libraryID)
// Changed
&& (previous != current ||
// Explicitly hidden
(current && !Zotero.Utilities.Internal.getVirtualCollectionStateForLibrary(libraryID, 'retracted')))) {
let promises = [];
for (let zp of Zotero.getZoteroPanes()) {
promises.push(zp.setVirtual(libraryID, 'retracted', current));
zp.hideRetractionBanner();
}
await Zotero.Promise.all(promises);
}
if (current) {
this._librariesWithRetractions.add(libraryID);
}
else {
this._librariesWithRetractions.delete(libraryID);
}
},
_resetLibraryRetractions: function (libraryID) {
delete this._retractedItemsByLibrary[libraryID];
this._updateLibraryRetractions(libraryID);
},
/** /**
* Return retraction data for an item * Return retraction data for an item
* *
@ -127,7 +192,16 @@ Zotero.Retractions = {
return description; return description;
}, },
notify: async function (action, type, ids, _extraData) { notify: async function (action, type, ids, extraData) {
// Clean up cache on group deletion
if (action == 'delete' && type == 'group') {
for (let libraryID of ids) {
this._resetLibraryRetractions(libraryID);
}
return;
}
// Items
if (action == 'add') { if (action == 'add') {
for (let id of ids) { for (let id of ids) {
this._updateItem(Zotero.Items.get(id)); this._updateItem(Zotero.Items.get(id));
@ -140,7 +214,7 @@ Zotero.Retractions = {
let typeID = this['TYPE_' + type]; let typeID = this['TYPE_' + type];
let fieldVal = this['_getItem' + type](item); let fieldVal = this['_getItem' + type](item);
if (fieldVal) { if (fieldVal) {
// If the item isn't already mapped to the key, re-map // If the item isn't already mapped to the key, re-map and re-check
let key = this._itemKeys[typeID].get(item.id); let key = this._itemKeys[typeID].get(item.id);
let newKey = this._valueToKey(typeID, fieldVal); let newKey = this._valueToKey(typeID, fieldVal);
if (key != newKey) { if (key != newKey) {
@ -149,18 +223,31 @@ Zotero.Retractions = {
continue; continue;
} }
} }
// If a previous key value was cleared, re-map // If a previous key value was cleared, re-map and re-check
else if (this._itemKeys[typeID].get(item.id)) { else if (this._itemKeys[typeID].get(item.id)) {
this._deleteItemKeyMappings(id); this._deleteItemKeyMappings(id);
this._updateItem(item); this._updateItem(item);
continue; continue;
} }
} }
// We don't want to show the virtual collection for items in the trash, so add or
// remove from the library set depending on whether it's in the trash. This is
// handled for newly detected items in _addEntry(), which gets called by
// _updateItem() above after a delay (such that the item won't yet be retracted
// here).
if (this._retractedItems.has(item.id)) {
if (item.deleted) {
await this._removeLibraryRetractedItem(item.libraryID, item.id);
}
else {
await this._addLibraryRetractedItem(item.libraryID, item.id);
}
}
} }
} }
else if (action == 'delete') { else if (action == 'delete') {
for (let id of ids) { for (let id of ids) {
await this._removeEntry(id); await this._removeEntry(id, extraData[id].libraryID);
} }
} }
}, },
@ -169,12 +256,16 @@ Zotero.Retractions = {
* Check for possible matches for items in the queue (debounced) * Check for possible matches for items in the queue (debounced)
*/ */
checkQueuedItems: Zotero.Utilities.debounce(async function () { checkQueuedItems: Zotero.Utilities.debounce(async function () {
return this._checkQueuedItemsInternal();
}, 1000),
_checkQueuedItemsInternal: async function () {
Zotero.debug("Checking updated items for retractions"); Zotero.debug("Checking updated items for retractions");
// If no possible matches, clear retraction flag on any items that changed // If no possible matches, clear retraction flag on any items that changed
if (!this._queuedPrefixStrings.size) { if (!this._queuedPrefixStrings.size) {
for (let item of this._queuedItems) { for (let item of this._queuedItems) {
await this._removeEntry(item.id); await this._removeEntry(item.id, item.libraryID);
} }
this._queuedItems.clear(); this._queuedItems.clear();
return; return;
@ -202,10 +293,10 @@ Zotero.Retractions = {
// Remove retraction status for items that were checked but didn't match // Remove retraction status for items that were checked but didn't match
for (let item of items) { for (let item of items) {
if (!addedItems.includes(item.id)) { if (!addedItems.includes(item.id)) {
await this._removeEntry(item.id); await this._removeEntry(item.id, item.libraryID);
} }
} }
}, 1000), },
updateFromServer: Zotero.serial(async function () { updateFromServer: Zotero.serial(async function () {
if (!this._initialized) { if (!this._initialized) {
@ -231,7 +322,12 @@ Zotero.Retractions = {
return; return;
} }
var etag = req.getResponseHeader('ETag'); var etag = req.getResponseHeader('ETag');
var list = req.response.trim().split('\n'); var list = req.response.split('\n').filter(x => x);
if (!list.length) {
Zotero.logError("Empty retraction list from server");
return;
}
// Calculate prefix length automatically // Calculate prefix length automatically
var doiPrefixLength; var doiPrefixLength;
@ -269,17 +365,15 @@ Zotero.Retractions = {
} }
} }
if (!prefixesToSend.size) { if (prefixesToSend.size) {
// TODO: Diff list and remove existing retractions that are missing
await this._downloadPossibleMatches([...prefixesToSend]);
}
else {
Zotero.debug("No possible retractions"); Zotero.debug("No possible retractions");
await Zotero.DB.queryAsync("DELETE FROM retractedItems");
this._retractedItems.clear();
await this._saveCacheFile(list, etag, doiPrefixLength, pmidPrefixLength);
return;
} }
// TODO: Diff list
await this._downloadPossibleMatches([...prefixesToSend]);
await this._saveCacheFile(list, etag, doiPrefixLength, pmidPrefixLength); await this._saveCacheFile(list, etag, doiPrefixLength, pmidPrefixLength);
}), }),
@ -492,7 +586,7 @@ Zotero.Retractions = {
return; return;
} }
this._queuedItems.add(item); this._queuedItems.add(item);
var doi = this._getItemDOI(item); let doi = this._getItemDOI(item);
if (doi) { if (doi) {
this._addItemKeyMapping(this.TYPE_DOI, doi, item.id); this._addItemKeyMapping(this.TYPE_DOI, doi, item.id);
let prefixStr = this.TYPE_DOI + this._getDOIPrefix(doi, this._cacheDOIPrefixLength); let prefixStr = this.TYPE_DOI + this._getDOIPrefix(doi, this._cacheDOIPrefixLength);
@ -500,7 +594,7 @@ Zotero.Retractions = {
this._queuedPrefixStrings.add(prefixStr); this._queuedPrefixStrings.add(prefixStr);
} }
} }
var pmid = this._getItemPMID(item); let pmid = this._getItemPMID(item);
if (pmid) { if (pmid) {
this._addItemKeyMapping(this.TYPE_PMID, pmid, item.id); this._addItemKeyMapping(this.TYPE_PMID, pmid, item.id);
let prefixStr = this.TYPE_PMID + this._getPMIDPrefix(pmid, this._cachePMIDPrefixLength); let prefixStr = this.TYPE_PMID + this._getPMIDPrefix(pmid, this._cachePMIDPrefixLength);
@ -523,19 +617,31 @@ Zotero.Retractions = {
var sql = "REPLACE INTO retractedItems VALUES (?, ?)"; var sql = "REPLACE INTO retractedItems VALUES (?, ?)";
await Zotero.DB.queryAsync(sql, [itemID, JSON.stringify(o)]); await Zotero.DB.queryAsync(sql, [itemID, JSON.stringify(o)]);
var item = Zotero.Items.get(itemID);
var libraryID = item.libraryID;
this._retractedItems.add(itemID); this._retractedItems.add(itemID);
if (!item.deleted) {
if (!this._retractedItemsByLibrary[libraryID]) {
this._retractedItemsByLibrary[libraryID] = new Set();
}
this._retractedItemsByLibrary[libraryID].add(itemID);
await this._updateLibraryRetractions(libraryID);
}
await Zotero.Notifier.trigger('refresh', 'item', [itemID]); await Zotero.Notifier.trigger('refresh', 'item', [itemID]);
}, },
_removeEntry: async function (itemID) { _removeEntry: async function (itemID, libraryID) {
this._deleteItemKeyMappings(itemID); this._deleteItemKeyMappings(itemID);
if (this._retractedItems.has(itemID)) { if (!this._retractedItems.has(itemID)) {
await Zotero.DB.queryAsync("DELETE FROM retractedItems WHERE itemID=?", itemID); return;
} }
await Zotero.DB.queryAsync("DELETE FROM retractedItems WHERE itemID=?", itemID);
this._retractedItems.delete(itemID); this._retractedItems.delete(itemID);
this._retractedItemsByLibrary[libraryID].delete(itemID);
await this._updateLibraryRetractions(libraryID);
await Zotero.Notifier.trigger('refresh', 'item', [itemID]); await Zotero.Notifier.trigger('refresh', 'item', [itemID]);
}, },

View file

@ -1410,6 +1410,10 @@ Zotero.Utilities.Internal = {
var prefKey = 'unfiledLibraries'; var prefKey = 'unfiledLibraries';
break; break;
case 'retracted':
var prefKey = 'retractedLibraries';
break;
default: default:
throw new Error("Invalid virtual collection type '" + type + "'"); throw new Error("Invalid virtual collection type '" + type + "'");
} }
@ -1445,6 +1449,10 @@ Zotero.Utilities.Internal = {
var prefKey = 'unfiledLibraries'; var prefKey = 'unfiledLibraries';
break; break;
case 'retracted':
var prefKey = 'retractedLibraries';
break;
default: default:
throw new Error("Invalid virtual collection type '" + type + "'"); throw new Error("Invalid virtual collection type '" + type + "'");
} }

View file

@ -71,6 +71,17 @@ Services.scriptloader.loadSubScript("resource://zotero/polyfill.js");
return win ? win.ZoteroPane : null; return win ? win.ZoteroPane : null;
}; };
this.getZoteroPanes = function () {
var enumerator = Services.wm.getEnumerator("navigator:browser");
var zps = [];
while (enumerator.hasMoreElements()) {
let win = enumerator.getNext();
if (!win.ZoteroPane) continue;
zps.push(win.ZoteroPane);
}
return zps;
};
/** /**
* @property {Boolean} locked Whether all Zotero panes are locked * @property {Boolean} locked Whether all Zotero panes are locked
* with an overlay * with an overlay

View file

@ -1027,7 +1027,7 @@ var ZoteroPane = new function()
}); });
this.setVirtual = Zotero.Promise.coroutine(function* (libraryID, type, show) { this.setVirtual = Zotero.Promise.coroutine(function* (libraryID, type, show, select) {
switch (type) { switch (type) {
case 'duplicates': case 'duplicates':
var treeViewID = 'D' + libraryID; var treeViewID = 'D' + libraryID;
@ -1037,6 +1037,10 @@ var ZoteroPane = new function()
var treeViewID = 'U' + libraryID; var treeViewID = 'U' + libraryID;
break; break;
case 'retracted':
var treeViewID = 'R' + libraryID;
break;
default: default:
throw new Error("Invalid virtual collection type '" + type + "'"); throw new Error("Invalid virtual collection type '" + type + "'");
} }
@ -1046,13 +1050,17 @@ var ZoteroPane = new function()
var cv = this.collectionsView; var cv = this.collectionsView;
var promise = cv.waitForSelect(); var promise = cv.waitForSelect();
var selectedRowID = cv.selectedTreeRow.id;
var selectedRow = cv.selection.currentIndex; var selectedRow = cv.selection.currentIndex;
yield cv.refresh(); yield cv.refresh();
// Select new row // Select new or original row
if (show) { if (show) {
yield this.collectionsView.selectByID(treeViewID); yield this.collectionsView.selectByID(select ? treeViewID : selectedRowID);
}
else if (type == 'retracted') {
yield this.collectionsView.selectByID("L" + libraryID);
} }
// Select next appropriate row after removal // Select next appropriate row after removal
else { else {
@ -1791,7 +1799,10 @@ var ZoteroPane = new function()
var prompt = force ? toTrash : toRemove; var prompt = force ? toTrash : toRemove;
} }
else if (collectionTreeRow.isSearch() || collectionTreeRow.isUnfiled() || collectionTreeRow.isDuplicates()) { else if (collectionTreeRow.isSearch()
|| collectionTreeRow.isUnfiled()
|| collectionTreeRow.isRetracted()
|| collectionTreeRow.isDuplicates()) {
if (!force) { if (!force) {
return; return;
} }
@ -1865,6 +1876,11 @@ var ZoteroPane = new function()
this.setVirtual(collectionTreeRow.ref.libraryID, 'unfiled', false); this.setVirtual(collectionTreeRow.ref.libraryID, 'unfiled', false);
return; return;
} }
// Remove virtual retracted collection
else if (collectionTreeRow.isRetracted()) {
this.setVirtual(collectionTreeRow.ref.libraryID, 'retracted', false);
return;
}
if (!this.canEdit() && !collectionTreeRow.isFeed()) { if (!this.canEdit() && !collectionTreeRow.isFeed()) {
this.displayCannotEditLibraryMessage(); this.displayCannotEditLibraryMessage();
@ -2401,13 +2417,19 @@ var ZoteroPane = new function()
{ {
id: "showDuplicates", id: "showDuplicates",
oncommand: () => { oncommand: () => {
this.setVirtual(this.getSelectedLibraryID(), 'duplicates', true); this.setVirtual(this.getSelectedLibraryID(), 'duplicates', true, true);
} }
}, },
{ {
id: "showUnfiled", id: "showUnfiled",
oncommand: () => { oncommand: () => {
this.setVirtual(this.getSelectedLibraryID(), 'unfiled', true); this.setVirtual(this.getSelectedLibraryID(), 'unfiled', true, true);
}
},
{
id: "showRetracted",
oncommand: () => {
this.setVirtual(this.getSelectedLibraryID(), 'retracted', true, true);
} }
}, },
{ {
@ -2600,7 +2622,7 @@ var ZoteroPane = new function()
else if (collectionTreeRow.isTrash()) { else if (collectionTreeRow.isTrash()) {
show = ['emptyTrash']; show = ['emptyTrash'];
} }
else if (collectionTreeRow.isDuplicates() || collectionTreeRow.isUnfiled()) { else if (collectionTreeRow.isDuplicates() || collectionTreeRow.isUnfiled() || collectionTreeRow.isRetracted()) {
show = ['deleteCollection']; show = ['deleteCollection'];
m.deleteCollection.setAttribute('label', Zotero.getString('general.hide')); m.deleteCollection.setAttribute('label', Zotero.getString('general.hide'));
@ -2624,14 +2646,17 @@ var ZoteroPane = new function()
'newSavedSearch' 'newSavedSearch'
); );
} }
// Only show "Show Duplicates" and "Show Unfiled Items" if rows are hidden // Only show "Show Duplicates", "Show Unfiled Items", and "Show Retracted" if rows are hidden
let duplicates = Zotero.Utilities.Internal.getVirtualCollectionStateForLibrary( let duplicates = Zotero.Utilities.Internal.getVirtualCollectionStateForLibrary(
libraryID, 'duplicates' libraryID, 'duplicates'
); );
let unfiled = Zotero.Utilities.Internal.getVirtualCollectionStateForLibrary( let unfiled = Zotero.Utilities.Internal.getVirtualCollectionStateForLibrary(
libraryID, 'unfiled' libraryID, 'unfiled'
); );
if (!duplicates || !unfiled) { let retracted = Zotero.Utilities.Internal.getVirtualCollectionStateForLibrary(
libraryID, 'retracted'
);
if (!duplicates || !unfiled || !retracted) {
if (!library.archived) { if (!library.archived) {
show.push('sep2'); show.push('sep2');
} }
@ -2641,6 +2666,9 @@ var ZoteroPane = new function()
if (!unfiled) { if (!unfiled) {
show.push('showUnfiled'); show.push('showUnfiled');
} }
if (!retracted) {
show.push('showRetracted');
}
} }
if (!library.archived) { if (!library.archived) {
show.push('sep3'); show.push('sep3');
@ -2656,7 +2684,11 @@ var ZoteroPane = new function()
// Disable some actions if user doesn't have write access // Disable some actions if user doesn't have write access
// //
// Some actions are disabled via their commands in onCollectionSelected() // Some actions are disabled via their commands in onCollectionSelected()
if (collectionTreeRow.isWithinGroup() && !collectionTreeRow.editable && !collectionTreeRow.isDuplicates() && !collectionTreeRow.isUnfiled()) { if (collectionTreeRow.isWithinGroup()
&& !collectionTreeRow.editable
&& !collectionTreeRow.isDuplicates()
&& !collectionTreeRow.isUnfiled()
&& !collectionTreeRow.isRetracted()) {
disable.push( disable.push(
'newSubcollection', 'newSubcollection',
'editSelectedCollection', 'editSelectedCollection',
@ -3219,8 +3251,8 @@ var ZoteroPane = new function()
return; return;
} }
// Ignore double-clicks on Unfiled Items source row // Ignore double-clicks on Unfiled/Retracted Items source rows
if (collectionTreeRow.isUnfiled()) { if (collectionTreeRow.isUnfiled() || collectionTreeRow.isRetracted()) {
return; return;
} }
@ -4762,12 +4794,15 @@ var ZoteroPane = new function()
var suffix = items.length > 1 ? 'multiple' : 'single'; var suffix = items.length > 1 ? 'multiple' : 'single';
message.textContent = Zotero.getString('retraction.alert.' + suffix); message.textContent = Zotero.getString('retraction.alert.' + suffix);
link.textContent = Zotero.getString('retraction.alert.view.' + suffix); link.textContent = Zotero.getString('retraction.alert.view.' + suffix);
link.onclick = function () { link.onclick = async function () {
var libraryID = this.getSelectedLibraryID();
// Pick the first item we find in the current library, or just pick one at random
var item = items.find(item => item.libraryID == libraryID) || items[0];
this.selectItem(item.id);
this.hideRetractionBanner(); this.hideRetractionBanner();
var libraryID = this.getSelectedLibraryID();
// Select the Retracted Items collection
await this.collectionsView.selectByID("R" + libraryID);
// Select newly detected item if only one
if (items.length == 1) {
await this.selectItem(items[0].id);
}
}.bind(this); }.bind(this);
close.onclick = function () { close.onclick = function () {

View file

@ -216,7 +216,7 @@
</toolbar> </toolbar>
<vbox id="retracted-items-container" collapsed="true"> <vbox id="retracted-items-container" collapsed="true">
<div xmlns="http://www.w3.org/1999/xhtml" id="retracted-items-banner" collapsed="true"> <div xmlns="http://www.w3.org/1999/xhtml" id="retracted-items-banner">
<div id="retracted-items-message"/> <div id="retracted-items-message"/>
<div id="retracted-items-link"/> <div id="retracted-items-link"/>
<div id="retracted-items-close">×</div> <div id="retracted-items-close">×</div>
@ -236,6 +236,7 @@
<menuseparator/> <menuseparator/>
<menuitem class="zotero-menuitem-show-duplicates" label="&zotero.toolbar.duplicate.label;"/> <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-unfiled" label="&zotero.collections.showUnfiledItems;"/>
<menuitem class="zotero-menuitem-show-retracted" label="&zotero.collections.showRetractedItems;"/>
<menuitem class="zotero-menuitem-edit-collection"/> <menuitem class="zotero-menuitem-edit-collection"/>
<menuitem class="zotero-menuitem-duplicate-collection"/> <menuitem class="zotero-menuitem-duplicate-collection"/>
<menuitem class="zotero-menuitem-mark-read-feed" label="&zotero.toolbar.markFeedRead.label;"/> <menuitem class="zotero-menuitem-mark-read-feed" label="&zotero.toolbar.markFeedRead.label;"/>

View file

@ -50,6 +50,7 @@
<!ENTITY zotero.toolbar.duplicate.label "Show Duplicates"> <!ENTITY zotero.toolbar.duplicate.label "Show Duplicates">
<!ENTITY zotero.collections.showUnfiledItems "Show Unfiled Items"> <!ENTITY zotero.collections.showUnfiledItems "Show Unfiled Items">
<!ENTITY zotero.collections.showRetractedItems "Show Retracted Items">
<!ENTITY zotero.items.itemType "Item Type"> <!ENTITY zotero.items.itemType "Item Type">
<!ENTITY zotero.items.type_column "Item Type"> <!ENTITY zotero.items.type_column "Item Type">

View file

@ -234,6 +234,7 @@ pane.collections.feedLibraries = Feeds
pane.collections.trash = Trash pane.collections.trash = Trash
pane.collections.untitled = Untitled pane.collections.untitled = Untitled
pane.collections.unfiled = Unfiled Items pane.collections.unfiled = Unfiled Items
pane.collections.retracted = Retracted Items
pane.collections.duplicate = Duplicate Items pane.collections.duplicate = Duplicate Items
pane.collections.removeLibrary = Remove Library pane.collections.removeLibrary = Remove Library
pane.collections.removeLibrary.text = Are you sure you want to permanently remove “%S” from this computer? pane.collections.removeLibrary.text = Are you sure you want to permanently remove “%S” from this computer?

View file

@ -445,6 +445,10 @@
list-style-image: url('chrome://zotero/skin/treesource-unfiled.png'); list-style-image: url('chrome://zotero/skin/treesource-unfiled.png');
} }
.zotero-menuitem-show-retracted {
list-style-image: url('chrome://zotero/skin/cross.png');
}
.zotero-menuitem-sync { .zotero-menuitem-sync {
list-style-image: url(chrome://zotero/skin/arrow_rotate_static.png); list-style-image: url(chrome://zotero/skin/arrow_rotate_static.png);
} }
@ -764,6 +768,7 @@
font-size: 22px; font-size: 22px;
} }
/* BEGIN 2X BLOCK -- DO NOT EDIT MANUALLY -- USE 2XIZE */ /* BEGIN 2X BLOCK -- DO NOT EDIT MANUALLY -- USE 2XIZE */
@media (min-resolution: 1.25dppx) { @media (min-resolution: 1.25dppx) {
#zotero-items-column-hasAttachment { list-style-image: url(chrome://zotero/skin/attach-small@2x.png); } #zotero-items-column-hasAttachment { list-style-image: url(chrome://zotero/skin/attach-small@2x.png); }
@ -780,6 +785,7 @@
.zotero-menuitem-new-saved-search { list-style-image: url('chrome://zotero/skin/treesource-search@2x.png'); } .zotero-menuitem-new-saved-search { list-style-image: url('chrome://zotero/skin/treesource-search@2x.png'); }
.zotero-menuitem-show-duplicates { list-style-image: url('chrome://zotero/skin/treesource-duplicates@2x.png'); } .zotero-menuitem-show-duplicates { list-style-image: url('chrome://zotero/skin/treesource-duplicates@2x.png'); }
.zotero-menuitem-show-unfiled { list-style-image: url('chrome://zotero/skin/treesource-unfiled@2x.png'); } .zotero-menuitem-show-unfiled { list-style-image: url('chrome://zotero/skin/treesource-unfiled@2x.png'); }
.zotero-menuitem-show-retracted { list-style-image: url('chrome://zotero/skin/cross@2x.png'); }
.zotero-menuitem-sync { list-style-image: url(chrome://zotero/skin/arrow_rotate_static@2x.png); } .zotero-menuitem-sync { list-style-image: url(chrome://zotero/skin/arrow_rotate_static@2x.png); }
.zotero-menuitem-new-collection { list-style-image: url('chrome://zotero/skin/toolbar-collection-add@2x.png'); } .zotero-menuitem-new-collection { list-style-image: url('chrome://zotero/skin/toolbar-collection-add@2x.png'); }
.zotero-menuitem-edit-collection { list-style-image: url('chrome://zotero/skin/toolbar-collection-edit@2x.png'); } .zotero-menuitem-edit-collection { list-style-image: url('chrome://zotero/skin/toolbar-collection-edit@2x.png'); }

View file

@ -18,20 +18,24 @@ describe("Zotero.CollectionTreeView", function() {
}); });
describe("#refresh()", function () { describe("#refresh()", function () {
it("should show Duplicate Items and Unfiled Items by default", function* () { it("should show Duplicate Items and Unfiled Items by default and shouldn't show Retracted Items", function* () {
Zotero.Prefs.clear('duplicateLibraries'); Zotero.Prefs.clear('duplicateLibraries');
Zotero.Prefs.clear('unfiledLibraries'); Zotero.Prefs.clear('unfiledLibraries');
Zotero.Prefs.clear('retractedLibraries');
yield cv.refresh(); yield cv.refresh();
assert.ok(cv.getRowIndexByID("D" + userLibraryID)); assert.ok(cv.getRowIndexByID("D" + userLibraryID));
assert.ok(cv.getRowIndexByID("U" + userLibraryID)); assert.ok(cv.getRowIndexByID("U" + userLibraryID));
assert.isFalse(cv.getRowIndexByID("R" + userLibraryID));
}); });
it("shouldn't show Duplicate Items and Unfiled Items if hidden", function* () { it("shouldn't show virtual collections if hidden", function* () {
Zotero.Prefs.set('duplicateLibraries', `{"${userLibraryID}": false}`); Zotero.Prefs.set('duplicateLibraries', `{"${userLibraryID}": false}`);
Zotero.Prefs.set('unfiledLibraries', `{"${userLibraryID}": false}`); Zotero.Prefs.set('unfiledLibraries', `{"${userLibraryID}": false}`);
Zotero.Prefs.set('retractedLibraries', `{"${userLibraryID}": false}`);
yield cv.refresh(); yield cv.refresh();
assert.isFalse(cv.getRowIndexByID("D" + userLibraryID)); assert.isFalse(cv.getRowIndexByID("D" + userLibraryID));
assert.isFalse(cv.getRowIndexByID("U" + userLibraryID)); assert.isFalse(cv.getRowIndexByID("U" + userLibraryID));
assert.isFalse(cv.getRowIndexByID("R" + userLibraryID));
}); });
it("should maintain open state of group", function* () { it("should maintain open state of group", function* () {

View file

@ -595,9 +595,8 @@ describe("Zotero.ItemTreeView", function() {
var userLibraryID = Zotero.Libraries.userLibraryID; var userLibraryID = Zotero.Libraries.userLibraryID;
var collection = yield createDataObject('collection'); var collection = yield createDataObject('collection');
var item = yield createDataObject('item', { title: "Unfiled Item" }); var item = yield createDataObject('item', { title: "Unfiled Item" });
yield zp.setVirtual(userLibraryID, 'unfiled', true); yield zp.setVirtual(userLibraryID, 'unfiled', true, true);
var selected = yield cv.selectByID("U" + userLibraryID); assert.equal(cv.selectedTreeRow.id, 'U' + userLibraryID);
assert.ok(selected);
yield waitForItemsLoad(win); yield waitForItemsLoad(win);
assert.isNumber(zp.itemsView.getRowIndexByID(item.id)); assert.isNumber(zp.itemsView.getRowIndexByID(item.id));
yield Zotero.DB.executeTransaction(function* () { yield Zotero.DB.executeTransaction(function* () {

View file

@ -1,34 +1,93 @@
describe("Retractions", function() { describe("Retractions", function() {
var userLibraryID;
var win;
var zp;
var server;
var checkQueueItemsStub;
var retractedDOI = '10.1016/S0140-6736(97)11096-0';
before(async function () {
userLibraryID = Zotero.Libraries.userLibraryID;
win = await loadZoteroPane();
zp = win.ZoteroPane;
// Remove debouncing on checkQueuedItems()
checkQueueItemsStub = sinon.stub(Zotero.Retractions, 'checkQueuedItems').callsFake(() => {
return Zotero.Retractions._checkQueuedItemsInternal();
});
});
beforeEach(async function () {
var ids = await Zotero.DB.columnQueryAsync("SELECT itemID FROM retractedItems");
if (ids.length) {
await Zotero.Items.erase(ids);
}
});
afterEach(async function () {
win.document.getElementById('retracted-items-close').click();
checkQueueItemsStub.resetHistory();
});
after(async function () {
win.close();
checkQueueItemsStub.restore();
var ids = await Zotero.DB.columnQueryAsync("SELECT itemID FROM retractedItems");
if (ids.length) {
await Zotero.Items.erase(ids);
}
});
async function createRetractedItem(options = {}) {
var o = {
itemType: 'journalArticle'
};
Object.assign(o, options);
var item = createUnsavedDataObject('item', o);
item.setField('DOI', retractedDOI);
if (Zotero.DB.inTransaction) {
await item.save();
}
else {
await item.saveTx();
}
while (!checkQueueItemsStub.called) {
await Zotero.Promise.delay(50);
}
await checkQueueItemsStub.returnValues[0];
checkQueueItemsStub.resetHistory();
return item;
}
describe("Notification Banner", function () { describe("Notification Banner", function () {
var win; function bannerShown() {
var zp; var container = win.document.getElementById('retracted-items-container');
if (container.getAttribute('collapsed') == 'true') {
return false;
}
if (!container.hasAttribute('collapsed')) {
return true;
}
throw new Error("'collapsed' attribute not found");
}
before(async function () { it("should show banner when retracted item is added", async function () {
win = await loadZoteroPane(); var banner = win.document.getElementById('retracted-items-container');
zp = win.ZoteroPane; assert.isFalse(bannerShown());
await createRetractedItem();
assert.isTrue(bannerShown());
}); });
afterEach(function () { it("shouldn't show banner when item in trash is added", async function () {
win.document.getElementById('retracted-items-close').click(); var item = await createRetractedItem({ deleted: true });
});
after(function () {
win.close();
});
it("shouldn't select item in trash", async function () {
var item1 = await createDataObject('item', { deleted: true });
var item2 = await createDataObject('item');
var item3 = await createDataObject('item', { deleted: true });
await Zotero.Retractions._addEntry(item1.id, {}); assert.isFalse(bannerShown());
await Zotero.Retractions._addEntry(item2.id, {});
await Zotero.Retractions._addEntry(item3.id, {});
await createDataObject('collection');
await waitForItemsLoad(win);
await Zotero.Retractions._showAlert([item1.id, item2.id, item3.id]);
win.document.getElementById('retracted-items-link').click(); win.document.getElementById('retracted-items-link').click();
while (zp.collectionsView.selectedTreeRow.id != 'L1') { while (zp.collectionsView.selectedTreeRow.id != 'L1') {
@ -37,7 +96,66 @@ describe("Retractions", function() {
await waitForItemsLoad(win); await waitForItemsLoad(win);
var item = await zp.getSelectedItems()[0]; var item = await zp.getSelectedItems()[0];
assert.equal(item, item2); assert.equal(item, item);
});
});
describe("virtual collection", function () {
it("should show/hide Retracted Items collection when a retracted item is found/erased", async function () {
// Create item
var item = await createRetractedItem();
assert.ok(zp.collectionsView.getRowIndexByID("R" + userLibraryID));
// Erase item
var promise = waitForItemEvent('refresh');
await item.eraseTx();
await promise;
assert.isFalse(zp.collectionsView.getRowIndexByID("R" + userLibraryID));
});
it("should unhide Retracted Items collection when retracted item is found", async function () {
await createRetractedItem();
// Hide collection
await zp.setVirtual(userLibraryID, 'retracted', false);
// Add another retracted item, which should unhide it
await createRetractedItem();
assert.ok(zp.collectionsView.getRowIndexByID("R" + userLibraryID));
});
it("should hide Retracted Items collection when last retracted item is moved to trash", async function () {
var rowID = "R" + userLibraryID;
// Create item
var item = await createRetractedItem();
assert.ok(zp.collectionsView.getRowIndexByID(rowID));
// Select Retracted Items collection
await zp.collectionsView.selectByID(rowID);
await waitForItemsLoad(win);
// Erase item
item.deleted = true;
await item.saveTx();
await Zotero.Promise.delay(50);
// Retracted Items should be gone
assert.isFalse(zp.collectionsView.getRowIndexByID(rowID));
// And My Library should be selected
assert.equal(zp.collectionsView.selectedTreeRow.id, "L" + userLibraryID);
});
it("should show Retracted Items collection when retracted item is restored from trash", async function () {
// Create trashed item
var item = await createRetractedItem({ deleted: true });
await Zotero.Promise.delay(50);
assert.isFalse(zp.collectionsView.getRowIndexByID("R" + userLibraryID));
// Restore item
item.deleted = false;
await item.saveTx();
await Zotero.Promise.delay(50);
assert.ok(zp.collectionsView.getRowIndexByID("R" + userLibraryID));
}); });
}); });
}); });

View file

@ -470,7 +470,7 @@ describe("ZoteroPane", function() {
// Show Duplicate Items // Show Duplicate Items
var id = "D" + userLibraryID; var id = "D" + userLibraryID;
assert.isFalse(cv.getRowIndexByID(id)); assert.isFalse(cv.getRowIndexByID(id));
yield zp.setVirtual(userLibraryID, 'duplicates', true); yield zp.setVirtual(userLibraryID, 'duplicates', true, true);
// Duplicate Items should be selected // Duplicate Items should be selected
assert.equal(cv.selectedTreeRow.id, id); assert.equal(cv.selectedTreeRow.id, id);
// Should be missing from pref // Should be missing from pref
@ -490,7 +490,7 @@ describe("ZoteroPane", function() {
// Show Unfiled Items // Show Unfiled Items
id = "U" + userLibraryID; id = "U" + userLibraryID;
assert.isFalse(cv.getRowIndexByID(id)); assert.isFalse(cv.getRowIndexByID(id));
yield zp.setVirtual(userLibraryID, 'unfiled', true); yield zp.setVirtual(userLibraryID, 'unfiled', true, true);
// Unfiled Items should be selected // Unfiled Items should be selected
assert.equal(cv.selectedTreeRow.id, id); assert.equal(cv.selectedTreeRow.id, id);
// Should be missing from pref // Should be missing from pref
@ -510,7 +510,7 @@ describe("ZoteroPane", function() {
// Show Duplicate Items // Show Duplicate Items
var id = "D" + userLibraryID; var id = "D" + userLibraryID;
yield zp.setVirtual(userLibraryID, 'duplicates', true); yield zp.setVirtual(userLibraryID, 'duplicates', true, true);
// Library should have been expanded and Duplicate Items selected // Library should have been expanded and Duplicate Items selected
assert.ok(cv.getRowIndexByID(id)); assert.ok(cv.getRowIndexByID(id));