Add "Delete Automatic Tags in This Library…" option to tag selector menu

I think it might be worth having a tag management window that lets you
view tags as a grid, sort by column (e.g., type), select ranges, delete,
consolidate, etc., but until then, this fulfills a popular request.
This commit is contained in:
Dan Stillman 2017-07-07 18:14:30 -04:00
parent 941ae5499c
commit de3b47fd78
5 changed files with 216 additions and 57 deletions

View file

@ -833,6 +833,59 @@
</method>
<method name="_updateDeleteAutomaticMenuOption">
<body><![CDATA[
(async function () {
var hasAutomatic = !!(await Zotero.Tags.getAutomaticInLibrary(this.libraryID)).length;
var menuitem = this.id('delete-automatic-tags');
menuitem.disabled = !hasAutomatic;
}.bind(this))();
]]></body>
</method>
<method name="_deleteAutomatic">
<body><![CDATA[
(async function () {
var num = (await Zotero.Tags.getAutomaticInLibrary(this.libraryID)).length;
if (!num) {
return;
}
var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"]
.getService(Components.interfaces.nsIPromptService);
var confirmed = ps.confirm(
window,
Zotero.getString('pane.tagSelector.deleteAutomatic.title'),
Zotero.getString(
'pane.tagSelector.deleteAutomatic.message',
new Intl.NumberFormat().format(num),
num
)
+ "\n\n"
+ Zotero.getString('general.actionCannotBeUndone')
);
if (confirmed) {
Zotero.showZoteroPaneProgressMeter(null, true);
try {
await Zotero.Tags.removeAutomaticFromLibrary(
this.libraryID,
(progress, progressMax) => {
Zotero.updateZoteroPaneProgressMeter(
Math.round(progress / progressMax * 100)
);
}
);
}
finally {
Zotero.hideZoteroPaneOverlays();
}
}
}.bind(this))();
]]></body>
</method>
<method name="_insertClickableTag">
<parameter name="tagsBox"/>
<parameter name="tagData"/>
@ -1113,6 +1166,7 @@
<toolbarbutton id="view-settings-menu" tooltiptext="&zotero.toolbar.actions.label;"
image="chrome://zotero/skin/tag-selector-menu.png" type="menu">
<menupopup id="view-settings-popup"
onpopupshowing="document.getBindingParent(this)._updateDeleteAutomaticMenuOption()"
onpopupshown="/*
This is necessary to fix a bug with Display All Tags not
being checked if enabled before menuu is shown (OS X only?)
@ -1136,6 +1190,11 @@
this.setAttribute('checked', !displayAll);
document.getBindingParent(this).filterToScope = !displayAll;
event.stopPropagation();"/>
<menuseparator/>
<menuitem id="delete-automatic-tags" label="&zotero.tagSelector.deleteAutomaticInLibrary;" type="checkbox"
oncommand="document.getBindingParent(this)._deleteAutomatic();
this.setAttribute('checked', false);
event.stopPropagation();"/>
</menupopup>
</toolbarbutton>
</hbox>

View file

@ -304,64 +304,93 @@ Zotero.Tags = new function() {
/**
* @return {Promise}
*/
this.removeFromLibrary = Zotero.Promise.coroutine(function* (libraryID, tagIDs) {
this.removeFromLibrary = Zotero.Promise.coroutine(function* (libraryID, tagIDs, onProgress) {
var d = new Date();
tagIDs = Zotero.flattenArguments(tagIDs);
var deletedNames = [];
var oldItemIDs = [];
var done = 0;
yield Zotero.DB.executeTransaction(function* () {
var notifierPairs = [];
var notifierData = {};
for (let i=0; i<tagIDs.length; i++) {
let tagID = tagIDs[i];
let name = this.getName(tagID);
if (name === false) {
continue;
}
deletedNames.push(name);
yield Zotero.Utilities.Internal.forEachChunkAsync(
tagIDs,
100,
async function (chunk) {
await Zotero.DB.executeTransaction(function* () {
var oldItemIDs = [];
var notifierPairs = [];
var notifierData = {};
var a = new Date();
var sql = 'SELECT tagID, itemID FROM itemTags JOIN items USING (itemID) '
+ 'WHERE libraryID=? AND tagID IN ('
+ Array(chunk.length).fill('?').join(', ')
+ ') ORDER BY tagID';
var chunkTagItems = yield Zotero.DB.queryAsync(sql, [libraryID, ...chunk]);
var i = 0;
chunk.sort((a, b) => a - b);
for (let tagID of chunk) {
let name = this.getName(tagID);
if (name === false) {
continue;
}
deletedNames.push(name);
// Since we're performing the DELETE query directly,
// get the list of items that will need their tags reloaded,
// and generate data for item-tag notifications
let itemIDs = []
while (i < chunkTagItems.length && chunkTagItems[i].tagID == tagID) {
itemIDs.push(chunkTagItems[i].itemID);
i++;
}
for (let itemID of itemIDs) {
let pair = itemID + "-" + tagID;
notifierPairs.push(pair);
notifierData[pair] = {
libraryID: libraryID,
tag: name
};
}
oldItemIDs = oldItemIDs.concat(itemIDs);
}
if (oldItemIDs.length) {
Zotero.Notifier.queue('remove', 'item-tag', notifierPairs, notifierData);
}
var sql = "DELETE FROM itemTags WHERE tagID IN ("
+ Array(chunk.length).fill('?').join(', ') + ") AND itemID IN "
+ "(SELECT itemID FROM items WHERE libraryID=?)";
yield Zotero.DB.queryAsync(sql, chunk.concat([libraryID]));
yield this.purge(chunk);
// Update internal timestamps on all items that had these tags
yield Zotero.Utilities.Internal.forEachChunkAsync(
Zotero.Utilities.arrayUnique(oldItemIDs),
Zotero.DB.MAX_BOUND_PARAMETERS - 1,
Zotero.Promise.coroutine(function* (chunk2) {
var placeholders = Array(chunk2.length).fill('?').join(',');
sql = 'UPDATE items SET synced=0, clientDateModified=? '
+ 'WHERE itemID IN (' + placeholders + ')'
yield Zotero.DB.queryAsync(sql, [Zotero.DB.transactionDateTime].concat(chunk2));
yield Zotero.Items.reload(oldItemIDs, ['primaryData', 'tags'], true);
})
);
done += chunk.length;
}.bind(this));
// Since we're performing the DELETE query directly,
// get the list of items that will need their tags reloaded,
// and generate data for item-tag notifications
let tagItems = yield this.getTagItems(libraryID, tagID);
for (let j = 0; j < tagItems.length; j++) {
let itemID = tagItems[i];
let pair = itemID + "-" + tagID;
notifierPairs.push(pair);
notifierData[pair] = {
libraryID: libraryID,
tag: name
};
if (onProgress) {
onProgress(done, tagIDs.length);
}
oldItemIDs = oldItemIDs.concat(tagItems);
}
if (oldItemIDs.length) {
Zotero.Notifier.queue('remove', 'item-tag', notifierPairs, notifierData);
}
var sql = "DELETE FROM itemTags WHERE tagID IN ("
+ tagIDs.map(x => '?').join(', ') + ") AND itemID IN "
+ "(SELECT itemID FROM items WHERE libraryID=?)";
yield Zotero.DB.queryAsync(sql, tagIDs.concat([libraryID]));
yield this.purge(tagIDs);
// Update internal timestamps on all items that had these tags
yield Zotero.Utilities.Internal.forEachChunkAsync(
Zotero.Utilities.arrayUnique(oldItemIDs),
Zotero.DB.MAX_BOUND_PARAMETERS - 1,
Zotero.Promise.coroutine(function* (chunk) {
let placeholders = chunk.map(() => '?').join(',');
sql = 'UPDATE items SET synced=0, clientDateModified=? '
+ 'WHERE itemID IN (' + placeholders + ')'
yield Zotero.DB.queryAsync(sql, [Zotero.DB.transactionDateTime].concat(chunk));
yield Zotero.Items.reload(oldItemIDs, ['primaryData', 'tags'], true);
})
);
}.bind(this));
}.bind(this)
);
// Also delete tag color setting
//
@ -371,9 +400,35 @@ Zotero.Tags = new function() {
for (let i=0; i<deletedNames.length; i++) {
yield this.setColor(libraryID, deletedNames[i], false);
}
Zotero.debug(`Removed ${tagIDs.length} ${Zotero.Utilities.pluralize(tagIDs.length, 'tag')} `
+ `in ${new Date() - d} ms`);
});
/**
* @param {Integer} libraryID
* @return {Integer[]} - An array of tagIDs
*/
this.getAutomaticInLibrary = function (libraryID) {
var sql = "SELECT DISTINCT tagID FROM itemTags JOIN items USING (itemID) "
+ "WHERE type=1 AND libraryID=?"
return Zotero.DB.columnQueryAsync(sql, libraryID);
};
/**
* Remove all automatic tags in the given library
*/
this.removeAutomaticFromLibrary = async function (libraryID, onProgress) {
var tagIDs = await this.getAutomaticInLibrary(libraryID);
if (onProgress) {
onProgress(0, tagIDs.length);
}
return this.removeFromLibrary(libraryID, tagIDs, onProgress);
};
/**
* Delete obsolete tags from database
*
@ -381,6 +436,8 @@ Zotero.Tags = new function() {
* @return {Promise}
*/
this.purge = Zotero.Promise.coroutine(function* (tagIDs) {
var d = new Date();
if (!_initialized) {
throw new Zotero.Exception.UnloadedDataException("Tags not yet loaded");
}
@ -399,9 +456,17 @@ Zotero.Tags = new function() {
if (tagIDs) {
let sql = "CREATE TEMPORARY TABLE tagDelete (tagID INT PRIMARY KEY)";
yield Zotero.DB.queryAsync(sql);
for (let i=0; i<tagIDs.length; i++) {
yield Zotero.DB.queryAsync("INSERT OR IGNORE INTO tagDelete VALUES (?)", tagIDs[i]);
}
yield Zotero.Utilities.Internal.forEachChunkAsync(
tagIDs,
Zotero.DB.MAX_BOUND_PARAMETERS,
function (chunk) {
return Zotero.DB.queryAsync(
"INSERT OR IGNORE INTO tagDelete VALUES "
+ Array(chunk.length).fill('(?)').join(', '),
chunk
);
}
);
sql = "SELECT tagID AS id, name FROM tagDelete JOIN tags USING (tagID) "
+ "WHERE tagID NOT IN (SELECT tagID FROM itemTags)";
var toDelete = yield Zotero.DB.queryAsync(sql);
@ -409,8 +474,7 @@ Zotero.Tags = new function() {
// Look for orphaned tags
else {
var sql = "CREATE TEMPORARY TABLE tagDelete AS "
+ "SELECT tagID FROM tags WHERE tagID "
+ "NOT IN (SELECT tagID FROM itemTags)";
+ "SELECT tagID FROM tags WHERE tagID NOT IN (SELECT tagID FROM itemTags)";
yield Zotero.DB.queryAsync(sql);
sql = "CREATE INDEX tagDelete_tagID ON tagDelete(tagID)";
@ -452,6 +516,9 @@ Zotero.Tags = new function() {
Zotero.Notifier.queue('delete', 'tag', ids, notifierData);
Zotero.Prefs.set('purge.tags', false);
Zotero.debug(`Purged ${toDelete.length} ${Zotero.Utilities.pluralize(toDelete.length, 'tag')} `
+ `in ${new Date() - d} ms`);
});

View file

@ -156,6 +156,7 @@
<!ENTITY zotero.tagSelector.loadingTags "Loading tags…">
<!ENTITY zotero.tagSelector.showAutomatic "Show Automatic">
<!ENTITY zotero.tagSelector.displayAllInLibrary "Display All Tags in This Library">
<!ENTITY zotero.tagSelector.deleteAutomaticInLibrary "Delete Automatic Tags in This Library…">
<!ENTITY zotero.tagSelector.clearAll "Deselect All">
<!ENTITY zotero.tagSelector.assignColor "Assign Color…">
<!ENTITY zotero.tagSelector.renameTag "Rename Tag…">

View file

@ -234,6 +234,8 @@ pane.tagSelector.rename.title = Rename Tag
pane.tagSelector.rename.message = Please enter a new name for this tag.\n\nThe tag will be changed in all associated items.
pane.tagSelector.delete.title = Delete Tag
pane.tagSelector.delete.message = Are you sure you want to delete this tag?\n\nThe tag will be removed from all items.
pane.tagSelector.deleteAutomatic.title = Delete Automatic Tags
pane.tagSelector.deleteAutomatic.message = Are you sure you want to delete %1$S automatic tag in this library?;Are you sure you want to delete %1$S automatic tags in this library?
pane.tagSelector.numSelected.none = 0 tags selected
pane.tagSelector.numSelected.singular = %S tag selected
pane.tagSelector.numSelected.plural = %S tags selected

View file

@ -39,6 +39,36 @@ describe("Zotero.Tags", function () {
});
describe("#removeFromLibrary()", function () {
it("should remove tags in given library", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
var groupLibraryID = (yield getGroup()).libraryID;
var tags = [];
var items = [];
yield Zotero.DB.executeTransaction(function* () {
for (let i = 0; i < 10; i++) {
let tagName = Zotero.Utilities.randomString();
tags.push(tagName);
let item = createUnsavedDataObject('item');
item.addTag(tagName);
yield item.save();
items.push(item);
}
});
var groupTagName = Zotero.Utilities.randomString();
var groupItem = createUnsavedDataObject('item', { libraryID: groupLibraryID });
groupItem.addTag(groupTagName);
yield groupItem.saveTx();
var tagIDs = tags.map(tag => Zotero.Tags.getID(tag));
yield Zotero.Tags.removeFromLibrary(libraryID, tagIDs);
items.forEach(item => assert.lengthOf(item.getTags(), 0));
// Group item should still have the tag
assert.lengthOf(groupItem.getTags(), 1);
})
it("should reload tags of associated items", function* () {
var libraryID = Zotero.Libraries.userLibraryID;