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:
parent
941ae5499c
commit
de3b47fd78
5 changed files with 216 additions and 57 deletions
|
@ -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>
|
||||
|
|
|
@ -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`);
|
||||
});
|
||||
|
||||
|
||||
|
|
|
@ -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…">
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
Loading…
Reference in a new issue