mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
Add support for undo/redo (#5823)
--------- Co-authored-by: Dan Stillman <dstillman@zotero.org>
This commit is contained in:
parent
c92148c3ec
commit
e71bd89e0d
32 changed files with 3105 additions and 82 deletions
|
|
@ -283,7 +283,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
if (!treeRow.editingName) return;
|
||||
treeRow.ref.name = treeRow.editingName;
|
||||
delete treeRow.editingName;
|
||||
await treeRow.ref.saveTx();
|
||||
await treeRow.ref.saveTx({ undoAction: 'undo-action-rename-collection' });
|
||||
window.Zotero_Tabs.rename("zotero-pane", treeRow.ref.name);
|
||||
}
|
||||
|
||||
|
|
@ -1330,14 +1330,27 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
await row.ref.eraseTx();
|
||||
}
|
||||
if (others.length) {
|
||||
let collectionCount = others.filter(r => r.isCollection()).length;
|
||||
let searchCount = others.length - collectionCount;
|
||||
// Use the search label only when nothing but searches is selected;
|
||||
// otherwise describe the action in terms of collections
|
||||
let undoAction, undoActionArgs;
|
||||
if (searchCount && !collectionCount) {
|
||||
undoAction = 'undo-action-trash-search';
|
||||
undoActionArgs = { count: searchCount };
|
||||
}
|
||||
else {
|
||||
undoAction = 'undo-action-trash-collection';
|
||||
undoActionArgs = { count: collectionCount + searchCount };
|
||||
}
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
for (let row of others) {
|
||||
row.ref.deleted = true;
|
||||
if (row.isCollection()) {
|
||||
await row.ref.save({ deleteItems });
|
||||
await row.ref.save({ deleteItems, undoAction, undoActionArgs });
|
||||
}
|
||||
else {
|
||||
await row.ref.save();
|
||||
await row.ref.save({ undoAction, undoActionArgs });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -2260,16 +2273,21 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
// Dropping items, collections, or searches into trash
|
||||
if (targetTreeRow.isTrash()) {
|
||||
let objects = [];
|
||||
let undoAction;
|
||||
if (dataType == 'zotero/collection') {
|
||||
objects = await Zotero.Collections.getAsync(data);
|
||||
undoAction = 'undo-action-trash-collection';
|
||||
}
|
||||
else if (dataType == 'zotero/search') {
|
||||
objects = await Zotero.Searches.getAsync(data);
|
||||
undoAction = 'undo-action-trash-search';
|
||||
}
|
||||
else if (dataType == 'zotero/item') {
|
||||
objects = await Zotero.Items.getAsync(data);
|
||||
undoAction = 'undo-action-trash';
|
||||
}
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
Zotero.UndoHistory.stageAction(undoAction, { count: objects.length });
|
||||
for (let obj of objects) {
|
||||
obj.deleted = true;
|
||||
await obj.save();
|
||||
|
|
@ -2304,7 +2322,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
await Zotero.DB.executeTransaction(async () => {
|
||||
for (let droppedCollection of droppedCollections) {
|
||||
droppedCollection.parentID = targetCollectionID;
|
||||
await droppedCollection.save();
|
||||
await droppedCollection.save({ undoAction: 'undo-action-move-collection' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -2380,6 +2398,21 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
await Zotero.DB.executeTransaction(async function () {
|
||||
let collection = await Zotero.Collections.getAsync(targetCollectionID);
|
||||
await collection.addItems(ids);
|
||||
// If moving, remove from source in the same
|
||||
// transaction so it's a single undo step
|
||||
if (dropEffect == 'move' && toMove.length
|
||||
&& sourceTreeRow && sourceTreeRow.isCollection()) {
|
||||
await sourceTreeRow.ref.removeItems(toMove);
|
||||
toMove = [];
|
||||
Zotero.UndoHistory.stageAction(
|
||||
'undo-action-move-to-collection', { count: ids.length }
|
||||
);
|
||||
}
|
||||
else {
|
||||
Zotero.UndoHistory.stageAction(
|
||||
'undo-action-add-to-collection', { count: ids.length }
|
||||
);
|
||||
}
|
||||
}.bind(this));
|
||||
}
|
||||
else if (targetTreeRow.isPublications()) {
|
||||
|
|
|
|||
|
|
@ -1562,6 +1562,11 @@ class CollectionViewItemTree extends ItemTree {
|
|||
// canDrop() limits this to child items
|
||||
var rowItem = this.getRow(row).ref; // the item we are dragging over
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
Zotero.UndoHistory.stageAction(
|
||||
'undo-action-change-parent-item',
|
||||
{ count: items.length }
|
||||
);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let item = items[i];
|
||||
item.parentID = rowItem.id;
|
||||
|
|
@ -1576,6 +1581,11 @@ class CollectionViewItemTree extends ItemTree {
|
|||
// Add to all selected collections
|
||||
if (collectionRows.length) {
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
Zotero.UndoHistory.stageAction(
|
||||
'undo-action-add-to-collection',
|
||||
{ count: items.length }
|
||||
);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let item = items[i];
|
||||
var source = item.isRegularItem() ? false : item.parentItemID;
|
||||
|
|
@ -1594,12 +1604,15 @@ class CollectionViewItemTree extends ItemTree {
|
|||
// Only library roots selected -- remove from parent and make top-level
|
||||
else if (collectionTreeRow.isLibrary(true)) {
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
Zotero.UndoHistory.stageAction(
|
||||
'undo-action-convert-to-standalone',
|
||||
{ count: items.length }
|
||||
);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let item = items[i];
|
||||
if (!item.isRegularItem()) {
|
||||
item.parentID = false;
|
||||
await item.save();
|
||||
}
|
||||
item.parentID = false;
|
||||
await item.save();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1894,15 +1907,21 @@ class CollectionViewItemTree extends ItemTree {
|
|||
|
||||
// Async but no need to wait
|
||||
(async () => {
|
||||
for (let item of items) {
|
||||
if (tagRemove) {
|
||||
item.removeTag(colorData.name);
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
Zotero.UndoHistory.stageAction(
|
||||
tagRemove ? 'undo-action-remove-tag' : 'undo-action-add-tag',
|
||||
{ count: items.length }
|
||||
);
|
||||
for (let item of items) {
|
||||
if (tagRemove) {
|
||||
item.removeTag(colorData.name);
|
||||
}
|
||||
else {
|
||||
item.addTag(colorData.name);
|
||||
}
|
||||
await item.save();
|
||||
}
|
||||
else {
|
||||
item.addTag(colorData.name);
|
||||
}
|
||||
await item.saveTx();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// We handled this
|
||||
|
|
@ -2038,6 +2057,10 @@ class CollectionViewItemTree extends ItemTree {
|
|||
}
|
||||
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
Zotero.UndoHistory.stageAction(
|
||||
'undo-action-remove-from-collection',
|
||||
{ count: selectedItems.length }
|
||||
);
|
||||
for (let item of selectedItems) {
|
||||
for (let collectionID of collectionIDs) {
|
||||
item.removeFromCollection(collectionID);
|
||||
|
|
|
|||
|
|
@ -739,7 +739,11 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
ids = ids.split(',');
|
||||
var items = Zotero.Items.get(ids);
|
||||
var value = elem.textContent;
|
||||
|
||||
|
||||
Zotero.UndoHistory.stageAction(
|
||||
remove ? 'undo-action-remove-tag' : 'undo-action-add-tag',
|
||||
{ count: items.length }
|
||||
);
|
||||
for (let i=0; i<items.length; i++) {
|
||||
let item = items[i];
|
||||
if (remove) {
|
||||
|
|
@ -815,6 +819,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
if (dataOut.result.op === 'split') {
|
||||
const itemIDs = await Zotero.Tags.getTagItems(this.libraryID, oldTagID);
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
Zotero.UndoHistory.stageAction('undo-action-split-tag');
|
||||
for (const itemID of itemIDs) {
|
||||
const item = await Zotero.Items.getAsync(itemID);
|
||||
const tagType = item.getTagType(oldTagName);
|
||||
|
|
|
|||
|
|
@ -138,7 +138,13 @@
|
|||
throw new Error('Item has not been added to library');
|
||||
}
|
||||
this._item.setField('abstractNote', this._abstractField.value);
|
||||
await this._item.saveTx();
|
||||
await this._item.saveTx({
|
||||
undoAction: 'undo-action-edit-field',
|
||||
undoActionArgs: {
|
||||
field: Zotero.ItemFields.getLocalizedString('abstractNote'),
|
||||
count: 1
|
||||
}
|
||||
});
|
||||
}
|
||||
this._forceRenderAll();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -765,7 +765,13 @@
|
|||
|
||||
_handleTitleBlur = () => {
|
||||
this.item.setField('title', this._id('title').value);
|
||||
this.item.saveTx();
|
||||
this.item.saveTx({
|
||||
undoAction: 'undo-action-edit-field',
|
||||
undoActionArgs: {
|
||||
field: Zotero.ItemFields.getLocalizedString('title'),
|
||||
count: 1
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
_handleFileNameFocus = () => {
|
||||
|
|
|
|||
|
|
@ -422,7 +422,8 @@
|
|||
this.renderCustomRows(ids);
|
||||
return;
|
||||
}
|
||||
if (event == 'modify' && this.item?.id && ids.includes(this.item.id)) {
|
||||
if (event == 'modify' && this.item?.id
|
||||
&& (ids.includes(this.item.id) || this._extraItems?.some(item => ids.includes(item.id)))) {
|
||||
this._forceRenderAll();
|
||||
}
|
||||
if (event === 'select' && type === 'tab' && ids.length > 0) {
|
||||
|
|
@ -773,7 +774,7 @@
|
|||
optionsButton.addEventListener("click", onContextMenu);
|
||||
rowData.appendChild(optionsButton);
|
||||
// Options button is always created for focus management but if the field is empty, it is hidden
|
||||
if (!val) optionsButton.hidden = true;
|
||||
if (!val && !extraFieldValues.some(v => v)) optionsButton.hidden = true;
|
||||
}
|
||||
|
||||
rowData.oncontextmenu = onContextMenu;
|
||||
|
|
@ -1737,7 +1738,7 @@
|
|||
firstName.sizeToContent();
|
||||
lastName.sizeToContent();
|
||||
this.modifyCreator(rowIndex, fields);
|
||||
this.item.saveTx();
|
||||
this.item.saveTx({ undoAction: 'undo-action-edit-creator' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1759,8 +1760,13 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
// Flush any pending field edits as a separate undo step
|
||||
// before changing the item type
|
||||
if (this.saveOnEdit) {
|
||||
await this.item.saveTx();
|
||||
await this.item.saveTx({
|
||||
undoAction: 'undo-action-edit-metadata',
|
||||
undoActionArgs: { count: 1 }
|
||||
});
|
||||
}
|
||||
|
||||
var fieldsToDelete = this.item.getFieldsNotInType(itemTypeID, true);
|
||||
|
|
@ -1817,7 +1823,7 @@
|
|||
this.item.setType(itemTypeID);
|
||||
|
||||
if (this.saveOnEdit) {
|
||||
await this.item.saveTx();
|
||||
await this.item.saveTx({ undoAction: 'undo-action-change-type' });
|
||||
}
|
||||
else {
|
||||
this._forceRenderAll();
|
||||
|
|
@ -2096,7 +2102,7 @@
|
|||
return;
|
||||
}
|
||||
this.item.removeCreator(index);
|
||||
await this.item.saveTx();
|
||||
await this.item.saveTx({ undoAction: 'undo-action-remove-creator' });
|
||||
}
|
||||
|
||||
removeUnsavedCreatorRow(onlyIfEmpty = false) {
|
||||
|
|
@ -2301,11 +2307,11 @@
|
|||
var fields = this.getCreatorFields(row);
|
||||
fields[creatorField] = creator[creatorField];
|
||||
fields[otherField] = creator[otherField];
|
||||
|
||||
|
||||
this.modifyCreator(creatorIndex, fields);
|
||||
if (this.saveOnEdit) {
|
||||
this.ignoreBlur = true;
|
||||
this.item.saveTx().then(() => {
|
||||
this.item.saveTx({ undoAction: 'undo-action-edit-creator' }).then(() => {
|
||||
this.ignoreBlur = false;
|
||||
});
|
||||
}
|
||||
|
|
@ -2396,7 +2402,7 @@
|
|||
this._selectField = `itembox-field-value-creator-${newCreator.position}-lastName`;
|
||||
|
||||
if (this.saveOnEdit) {
|
||||
this.item.saveTx();
|
||||
this.item.saveTx({ undoAction: 'undo-action-edit-creator' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2469,10 +2475,14 @@
|
|||
var [field, creatorIndex, creatorField] = fieldName.split('-');
|
||||
|
||||
// Creator fields
|
||||
let isCreatorField = false;
|
||||
let isCreatorUnsaved = false;
|
||||
if (field == 'creator') {
|
||||
isCreatorField = true;
|
||||
var row = textbox.closest('.meta-row');
|
||||
|
||||
var otherFields = this.getCreatorFields(row);
|
||||
isCreatorUnsaved = otherFields.isUnsaved;
|
||||
otherFields[creatorField] = value;
|
||||
this.modifyCreator(creatorIndex, otherFields);
|
||||
|
||||
|
|
@ -2546,7 +2556,20 @@
|
|||
}
|
||||
|
||||
if (this.saveOnEdit) {
|
||||
await this._saveItems();
|
||||
let saveOptions = {};
|
||||
if (isCreatorField) {
|
||||
saveOptions.undoAction = isCreatorUnsaved
|
||||
? 'undo-action-add-creator'
|
||||
: 'undo-action-edit-creator';
|
||||
}
|
||||
else {
|
||||
saveOptions.undoAction = 'undo-action-edit-field';
|
||||
saveOptions.undoActionArgs = {
|
||||
field: Zotero.ItemFields.getLocalizedString(fieldName),
|
||||
count: 1 + this._extraItems.length
|
||||
};
|
||||
}
|
||||
await this._saveItems(saveOptions);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2582,7 +2605,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
async _saveItems() {
|
||||
async _saveItems(saveOptions = {}) {
|
||||
// Cache item and extra items to avoid a race condition where, after `hideEditor`,
|
||||
// while we yield for `await Zotero.DB.executeTransaction`, itemBox is rendered for
|
||||
// the new item and this.item is no longer relevant
|
||||
|
|
@ -2590,9 +2613,9 @@
|
|||
let extraItems = this._extraItems;
|
||||
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
await item.save();
|
||||
await item.save(saveOptions);
|
||||
for (let extraItem of extraItems) {
|
||||
await extraItem.save();
|
||||
await extraItem.save(saveOptions);
|
||||
}
|
||||
});
|
||||
if (extraItems.length) {
|
||||
|
|
@ -2621,10 +2644,16 @@
|
|||
});
|
||||
|
||||
if (this.saveOnEdit) {
|
||||
await this._saveItems();
|
||||
await this._saveItems({
|
||||
undoAction: 'undo-action-edit-field',
|
||||
undoActionArgs: {
|
||||
field: Zotero.ItemFields.getLocalizedString(fieldName),
|
||||
count: 1 + this._extraItems.length
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Make sure that irrelevant creators +/- buttons are disabled
|
||||
_updateCreatorButtonsStatus() {
|
||||
|
|
@ -2724,7 +2753,7 @@
|
|||
this.modifyCreator(creatorIndex, fields);
|
||||
|
||||
if (this.saveOnEdit) {
|
||||
await this.item.saveTx();
|
||||
await this.item.saveTx({ undoAction: 'undo-action-edit-creator' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2747,7 +2776,7 @@
|
|||
var fields = this.getCreatorFields(row);
|
||||
this.modifyCreator(creatorIndex, fields);
|
||||
if (this.saveOnEdit) {
|
||||
await this.item.saveTx();
|
||||
await this.item.saveTx({ undoAction: 'undo-action-edit-creator' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2861,7 +2890,7 @@
|
|||
this.item.setCreator(i, creators[i]);
|
||||
}
|
||||
if (this.saveOnEdit && !skipSave) {
|
||||
this.item.saveTx();
|
||||
this.item.saveTx({ undoAction: 'undo-action-reorder-creator' });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3217,7 +3246,7 @@
|
|||
|
||||
this.modifyCreator(index, fields);
|
||||
if (this.saveOnEdit) {
|
||||
await this.item.saveTx();
|
||||
await this.item.saveTx({ undoAction: 'undo-action-edit-creator' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -201,9 +201,15 @@
|
|||
if (newValue.toLowerCase().startsWith(shortTitleVal.toLowerCase())) {
|
||||
this._item.setField('shortTitle', newValue.substring(0, shortTitleVal.length));
|
||||
}
|
||||
await this._item.saveTx();
|
||||
await this._item.saveTx({
|
||||
undoAction: 'undo-action-edit-field',
|
||||
undoActionArgs: {
|
||||
field: Zotero.ItemFields.getLocalizedString(this._titleFieldID),
|
||||
count: 1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async save() {
|
||||
if (!this.editable) {
|
||||
return;
|
||||
|
|
@ -213,7 +219,13 @@
|
|||
throw new Error('Item has not been added to library');
|
||||
}
|
||||
this._item.setField(this._titleFieldID, this.titleField.value);
|
||||
await this._item.saveTx();
|
||||
await this._item.saveTx({
|
||||
undoAction: 'undo-action-edit-field',
|
||||
undoActionArgs: {
|
||||
field: Zotero.ItemFields.getLocalizedString(this._titleFieldID),
|
||||
count: 1
|
||||
}
|
||||
});
|
||||
}
|
||||
this._forceRenderAll();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,7 +148,10 @@ import { getCSSIcon } from 'components/icons';
|
|||
Zotero.getString('pane.items.removeFromOther', [obj.name])
|
||||
)) {
|
||||
contextItem.removeFromCollection(obj.id);
|
||||
contextItem.saveTx();
|
||||
contextItem.saveTx({
|
||||
undoAction: 'undo-action-remove-from-collection',
|
||||
undoActionArgs: { count: 1 }
|
||||
});
|
||||
}
|
||||
});
|
||||
row.append(remove);
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ import { getCSSItemTypeIcon } from 'components/icons';
|
|||
return;
|
||||
}
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
Zotero.UndoHistory.stageAction('undo-action-add-related');
|
||||
for (let relItem of relItems) {
|
||||
if (this._item.addRelatedItem(relItem)) {
|
||||
await this._item.save({
|
||||
|
|
@ -197,6 +198,7 @@ import { getCSSItemTypeIcon } from 'components/icons';
|
|||
let item = await Zotero.Items.getAsync(id);
|
||||
if (item) {
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
Zotero.UndoHistory.stageAction('undo-action-remove-related');
|
||||
if (this._item.removeRelatedItem(item)) {
|
||||
await this._item.save({
|
||||
skipDateModifiedUpdate: true
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@
|
|||
this.remove(tagName);
|
||||
try {
|
||||
item.removeTag(tagName);
|
||||
this._pendingRemovalCount++;
|
||||
// Save item after a debounce to avoid triggering multiple
|
||||
// save operations. If there are many tags in the library,
|
||||
// db transaction may not keep up with UI changes, and cause
|
||||
|
|
@ -466,7 +467,7 @@
|
|||
this.add(value);
|
||||
try {
|
||||
this.item.replaceTag(oldValue, value);
|
||||
await this.item.saveTx();
|
||||
await this.item.saveTx({ undoAction: 'undo-action-change-tag' });
|
||||
}
|
||||
catch (e) {
|
||||
this._forceRenderAll();
|
||||
|
|
@ -483,7 +484,10 @@
|
|||
nextRowElem?.focus();
|
||||
}
|
||||
this.item.removeTag(oldValue);
|
||||
await this.item.saveTx();
|
||||
await this.item.saveTx({
|
||||
undoAction: 'undo-action-remove-tag',
|
||||
undoActionArgs: { count: 1 }
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
this._forceRenderAll();
|
||||
|
|
@ -503,7 +507,10 @@
|
|||
}
|
||||
|
||||
tags.forEach(tag => this.item.addTag(tag));
|
||||
await this.item.saveTx();
|
||||
await this.item.saveTx({
|
||||
undoAction: 'undo-action-add-tag',
|
||||
undoActionArgs: { count: 1 }
|
||||
});
|
||||
}
|
||||
// Single tag at end
|
||||
else {
|
||||
|
|
@ -518,7 +525,10 @@
|
|||
this.add(value);
|
||||
this.item.addTag(value);
|
||||
try {
|
||||
await this.item.saveTx();
|
||||
await this.item.saveTx({
|
||||
undoAction: 'undo-action-add-tag',
|
||||
undoActionArgs: { count: 1 }
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
this._forceRenderAll();
|
||||
|
|
@ -602,7 +612,9 @@
|
|||
removeAll = () => {
|
||||
if (Services.prompt.confirm(null, "", Zotero.getString('pane.item.tags.removeAll'))) {
|
||||
this.item.setTags([]);
|
||||
this.item.saveTx();
|
||||
this.item.saveTx({
|
||||
undoAction: 'undo-action-remove-all-tags'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -652,8 +664,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
_pendingRemovalCount = 0;
|
||||
|
||||
_saveItemDebounced = Zotero.Utilities.debounce(async (item) => {
|
||||
await item.saveTx();
|
||||
let count = this._pendingRemovalCount;
|
||||
this._pendingRemovalCount = 0;
|
||||
await item.saveTx({
|
||||
undoAction: 'undo-action-remove-tags-from-item',
|
||||
undoActionArgs: { count }
|
||||
});
|
||||
});
|
||||
|
||||
_id(id) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ export function mergeItems(item, otherItems) {
|
|||
Zotero.debug("Merging items");
|
||||
|
||||
return Zotero.DB.executeTransaction(async function () {
|
||||
Zotero.UndoHistory.stageAction(
|
||||
'undo-action-merge-items',
|
||||
{ count: otherItems.length + 1 }
|
||||
);
|
||||
var toSave = {};
|
||||
toSave[item.id] = item;
|
||||
|
||||
|
|
|
|||
|
|
@ -237,9 +237,51 @@ const ZoteroStandalone = new function () {
|
|||
this.updateQuickCopyOptions();
|
||||
// goUpdateGlobalEditMenuItems(true) is necessary to update Edit menu when contenteditable is focused
|
||||
window.goUpdateGlobalEditMenuItems(true);
|
||||
this._updateUndoRedoLabels();
|
||||
|
||||
this.onUpdateCustomMenus(event, 'edit');
|
||||
};
|
||||
|
||||
this._updateUndoRedoLabels = function () {
|
||||
let undoItem = document.getElementById('menu_undo');
|
||||
let redoItem = document.getElementById('menu_redo');
|
||||
if (!undoItem || !redoItem) return;
|
||||
|
||||
// When a native text-editing controller handles undo/redo
|
||||
// (e.g. focused input), show generic labels and let it take over
|
||||
let nativeUndo = Zotero.UndoHistory.hasNativeUndo(document);
|
||||
let nativeRedo = Zotero.UndoHistory.hasNativeRedo(document);
|
||||
|
||||
let undoAction = !nativeUndo && Zotero.UndoHistory.getUndoAction();
|
||||
if (undoAction) {
|
||||
let actionLabel = Zotero.ftl.formatValueSync(
|
||||
undoAction.action, undoAction.actionArgs || undefined
|
||||
);
|
||||
let fullLabel = Zotero.ftl.formatValueSync(
|
||||
'menu-edit-undo-action', { action: actionLabel }
|
||||
);
|
||||
undoItem.removeAttribute('data-l10n-id');
|
||||
undoItem.setAttribute('label', fullLabel);
|
||||
}
|
||||
else {
|
||||
document.l10n.setAttributes(undoItem, 'text-action-undo');
|
||||
}
|
||||
|
||||
let redoAction = !nativeRedo && Zotero.UndoHistory.getRedoAction();
|
||||
if (redoAction) {
|
||||
let actionLabel = Zotero.ftl.formatValueSync(
|
||||
redoAction.action, redoAction.actionArgs || undefined
|
||||
);
|
||||
let fullLabel = Zotero.ftl.formatValueSync(
|
||||
'menu-edit-redo-action', { action: actionLabel }
|
||||
);
|
||||
redoItem.removeAttribute('data-l10n-id');
|
||||
redoItem.setAttribute('label', fullLabel);
|
||||
}
|
||||
else {
|
||||
document.l10n.setAttributes(redoItem, 'text-action-redo');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds new item menu
|
||||
|
|
|
|||
|
|
@ -386,6 +386,7 @@ Zotero.Collection.prototype._finalizeSave = async function (env) {
|
|||
};
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param {Number} itemID
|
||||
* @return {Promise}
|
||||
|
|
@ -616,6 +617,18 @@ Zotero.Collection.prototype.trash = async function (env) {
|
|||
if (env.options && env.options.skipDeleteLog) {
|
||||
env.notifierData[c.id].skipDeleteLog = true;
|
||||
}
|
||||
// Record undo data for descendent collections
|
||||
if (Zotero.UndoHistory && !c.deleted) {
|
||||
Zotero.UndoHistory.stageChange({
|
||||
objectType: 'collection',
|
||||
id: c.id,
|
||||
libraryID: c.libraryID,
|
||||
key: c.key,
|
||||
fields: {
|
||||
deleted: { old: false, new: true }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Descendent items
|
||||
|
|
|
|||
|
|
@ -884,6 +884,96 @@ Zotero.DataObject.prototype._markForReload = function (dataType) {
|
|||
}
|
||||
|
||||
|
||||
Zotero.DataObject.UNDO_SKIP_FIELDS = new Set(['version', 'synced', 'clientDateModified', 'dateModified']);
|
||||
|
||||
/**
|
||||
* Build a change record for UndoHistory from the current pending changes.
|
||||
* Called during save() before _saveData() clears change tracking.
|
||||
*
|
||||
* @return {Object|null} - ChangeRecord or null if nothing undoable
|
||||
*/
|
||||
Zotero.DataObject.prototype._getUndoData = function () {
|
||||
let skipFields = Zotero.DataObject.UNDO_SKIP_FIELDS;
|
||||
let fields = {};
|
||||
|
||||
// Fields tracked via _previousData (old-style: old value stored)
|
||||
for (let field of Object.keys(this._previousData)) {
|
||||
if (skipFields.has(field)) continue;
|
||||
// Skip non-scalar fields like relations
|
||||
if (typeof this._previousData[field] === 'object' && this._previousData[field] !== null) {
|
||||
continue;
|
||||
}
|
||||
fields[field] = {
|
||||
old: this._previousData[field],
|
||||
new: this['_' + field]
|
||||
};
|
||||
}
|
||||
|
||||
// Fields tracked via _changedData (new-style: new value stored)
|
||||
for (let field of Object.keys(this._changedData)) {
|
||||
if (skipFields.has(field)) continue;
|
||||
if (field === 'deleted') {
|
||||
fields[field] = {
|
||||
old: this._deleted,
|
||||
new: this._changedData[field]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.keys(fields).length) return null;
|
||||
|
||||
return {
|
||||
objectType: this._objectType,
|
||||
id: this._id,
|
||||
libraryID: this._libraryID,
|
||||
key: this._key,
|
||||
fields
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Whether the object still holds the values captured in an undo snapshot for
|
||||
* the given side -- the read-and-compare counterpart of _getUndoData(). Used by
|
||||
* Zotero.UndoHistory to avoid replaying a snapshot over an external change.
|
||||
*
|
||||
* @param {Object} fields -- a change record's `fields` map (field -> { old, new })
|
||||
* @param {String} side -- 'new' (undo) or 'old' (redo)
|
||||
* @return {Boolean} -- true if every field still matches the recorded value
|
||||
*/
|
||||
Zotero.DataObject.prototype.matchesUndoSnapshot = function (fields, side) {
|
||||
for (let field of Object.keys(fields)) {
|
||||
if (!this._undoFieldMatches(field, fields[field][side])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Whether a single field still holds a recorded undo value. Overridden by
|
||||
* Zotero.Item for item-specific fields; the base handles the primary scalar
|
||||
* fields shared by all data objects.
|
||||
*
|
||||
* @param {String} field
|
||||
* @param {*} recorded -- the recorded value for the side being checked
|
||||
* @return {Boolean}
|
||||
*/
|
||||
Zotero.DataObject.prototype._undoFieldMatches = function (field, recorded) {
|
||||
if (field === 'deleted') {
|
||||
return this._deleted === recorded;
|
||||
}
|
||||
if (field === 'name') {
|
||||
return this._name === recorded;
|
||||
}
|
||||
if (field === 'parentKey') {
|
||||
return this._parentKey === recorded;
|
||||
}
|
||||
return this['_' + field] === recorded;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param {String} [op='edit'] - Operation to check; if not provided, check edit privileges for
|
||||
* library
|
||||
|
|
@ -907,6 +997,11 @@ Zotero.DataObject.prototype.isEditable = function (_op = 'edit') {
|
|||
* @param {Boolean} [options.skipNotifier] - Don't trigger Zotero.Notifier events
|
||||
* @param {Boolean} [options.skipSelect] - Don't select object automatically in trees
|
||||
* @param {Boolean} [options.skipSyncedUpdate] - Don't automatically set 'synced' to false
|
||||
* @param {String} [options.undoAction] - Fluent message ID for the undo entry's action label
|
||||
* (e.g. 'undo-action-edit-creator'); without this the save
|
||||
* is captured but discarded at commit
|
||||
* @param {Object} [options.undoActionArgs] - Fluent message arguments for the action label
|
||||
* (e.g. { count: 3 })
|
||||
* @return {Promise<Integer|Boolean>} Promise for itemID of new item,
|
||||
* TRUE on item update, or FALSE if item was unchanged
|
||||
*/
|
||||
|
|
@ -934,17 +1029,17 @@ Zotero.DataObject.prototype.save = async function (options = {}) {
|
|||
].forEach(x => env.options[x] = true);
|
||||
}
|
||||
|
||||
var proceed = await this._initSave(env);
|
||||
if (!proceed) return false;
|
||||
|
||||
if (env.isNew) {
|
||||
Zotero.debug('Saving data for new ' + this._objectType + ' to database', 4);
|
||||
}
|
||||
else {
|
||||
Zotero.debug('Updating database with new ' + this._objectType + ' data', 4);
|
||||
}
|
||||
|
||||
try {
|
||||
var proceed = await this._initSave(env);
|
||||
if (!proceed) return false;
|
||||
|
||||
if (env.isNew) {
|
||||
Zotero.debug('Saving data for new ' + this._objectType + ' to database', 4);
|
||||
}
|
||||
else {
|
||||
Zotero.debug('Updating database with new ' + this._objectType + ' data', 4);
|
||||
}
|
||||
|
||||
if (Zotero.DataObject.prototype._finalizeSave == this._finalizeSave) {
|
||||
throw new Error("_finalizeSave not implemented for Zotero." + this._ObjectType);
|
||||
}
|
||||
|
|
@ -969,7 +1064,14 @@ Zotero.DataObject.prototype.save = async function (options = {}) {
|
|||
env.notifierData.changed[field] = this['_' + field];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Capture undo data before _saveData clears change tracking.
|
||||
// Capture is unconditional for non-isNew saves; whether it lands on the
|
||||
// undo stack depends on a stageAction() call within the same transaction.
|
||||
if (Zotero.UndoHistory && !env.isNew) {
|
||||
env.undoData = this._getUndoData();
|
||||
}
|
||||
|
||||
// Create transaction
|
||||
let result
|
||||
if (env.options.tx) {
|
||||
|
|
@ -977,6 +1079,12 @@ Zotero.DataObject.prototype.save = async function (options = {}) {
|
|||
Zotero.DataObject.prototype._saveData.call(this, env);
|
||||
await this._saveData(env);
|
||||
await Zotero.DataObject.prototype._finalizeSave.call(this, env);
|
||||
if (env.undoData) {
|
||||
Zotero.UndoHistory.stageChange(env.undoData);
|
||||
if (env.options.undoAction) {
|
||||
Zotero.UndoHistory.stageAction(env.options.undoAction, env.options.undoActionArgs);
|
||||
}
|
||||
}
|
||||
return this._finalizeSave(env);
|
||||
}.bind(this), env.transactionOptions);
|
||||
}
|
||||
|
|
@ -987,6 +1095,12 @@ Zotero.DataObject.prototype.save = async function (options = {}) {
|
|||
await this._saveData(env);
|
||||
await Zotero.DataObject.prototype._finalizeSave.call(this, env);
|
||||
result = this._finalizeSave(env);
|
||||
if (env.undoData) {
|
||||
Zotero.UndoHistory.stageChange(env.undoData);
|
||||
if (env.options.undoAction) {
|
||||
Zotero.UndoHistory.stageAction(env.options.undoAction, env.options.undoActionArgs);
|
||||
}
|
||||
}
|
||||
}
|
||||
this._postSave(env);
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -880,6 +880,217 @@ Zotero.Item.prototype.setField = function (field, value, loadIn) {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to correctly resolve item data fields via _itemData[fieldID]
|
||||
*/
|
||||
Zotero.Item.prototype._getUndoData = function () {
|
||||
let skipFields = Zotero.DataObject.UNDO_SKIP_FIELDS;
|
||||
let fields = {};
|
||||
|
||||
// Fields tracked via _previousData
|
||||
for (let field of Object.keys(this._previousData)) {
|
||||
if (skipFields.has(field)) continue;
|
||||
// 'itemType' is a derived name, not directly settable -- handled below as itemTypeID
|
||||
if (field === 'itemType') continue;
|
||||
// Collections are an array but need explicit undo tracking
|
||||
if (field === 'collections') {
|
||||
fields[field] = {
|
||||
old: this._previousData[field],
|
||||
new: this._collections
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (field === 'note') {
|
||||
fields[field] = {
|
||||
old: this._previousData[field],
|
||||
new: this._noteText
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (field === 'relations') {
|
||||
fields[field] = {
|
||||
old: this._previousData[field],
|
||||
new: this._relations.map(r => [...r])
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (typeof this._previousData[field] === 'object' && this._previousData[field] !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let fieldID = Zotero.ItemFields.getID(field);
|
||||
if (fieldID) {
|
||||
// Item data field -- new value is in _itemData.
|
||||
// After a type change, lost fields are no longer in _itemData.
|
||||
let newValue = this._itemData[fieldID];
|
||||
fields[field] = {
|
||||
old: this._previousData[field],
|
||||
new: newValue !== undefined ? newValue : false
|
||||
};
|
||||
}
|
||||
else {
|
||||
// Primary data field -- new value is on the instance property
|
||||
fields[field] = {
|
||||
old: this._previousData[field],
|
||||
new: this['_' + field]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Detect item type change and store with numeric IDs
|
||||
if (this._changed.primaryData && this._changed.primaryData.itemTypeID
|
||||
&& this._previousData.itemType) {
|
||||
fields.itemTypeID = {
|
||||
old: Zotero.ItemTypes.getID(this._previousData.itemType),
|
||||
new: this._itemTypeID
|
||||
};
|
||||
}
|
||||
|
||||
// Fields tracked via _changedData (e.g. deleted, tags)
|
||||
for (let field of Object.keys(this._changedData)) {
|
||||
if (skipFields.has(field)) continue;
|
||||
if (field === 'deleted') {
|
||||
fields[field] = {
|
||||
old: this._deleted,
|
||||
new: this._changedData[field]
|
||||
};
|
||||
}
|
||||
else if (field === 'tags') {
|
||||
fields[field] = {
|
||||
old: this._tags,
|
||||
new: this._changedData[field]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Creators tracked via _changed.creators
|
||||
if (this._changed.creators) {
|
||||
// Old creators were saved in _previousData.creators by _markFieldChange
|
||||
let oldCreators = this._previousData.creators || {};
|
||||
let newCreators = {};
|
||||
for (let i = 0; i < this._creators.length; i++) {
|
||||
newCreators[i] = Object.assign({}, this._creators[i]);
|
||||
}
|
||||
fields.creators = {
|
||||
old: oldCreators,
|
||||
new: newCreators
|
||||
};
|
||||
}
|
||||
|
||||
if (!Object.keys(fields).length) return null;
|
||||
|
||||
return {
|
||||
objectType: this._objectType,
|
||||
id: this._id,
|
||||
libraryID: this._libraryID,
|
||||
key: this._key,
|
||||
fields
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @see Zotero.DataObject.prototype._undoFieldMatches
|
||||
*
|
||||
* Mirrors how _getUndoData() (above) captures each item field, and reuses the
|
||||
* canonical change-detection helpers so the staleness check and save-time
|
||||
* change detection stay in agreement.
|
||||
*/
|
||||
Zotero.Item.prototype._undoFieldMatches = function (field, recorded) {
|
||||
switch (field) {
|
||||
case 'collections':
|
||||
return !Zotero.DataObjectUtilities._collectionsChanged(this._collections, recorded);
|
||||
|
||||
case 'tags':
|
||||
if (!Array.isArray(recorded)) {
|
||||
return this._tags === recorded;
|
||||
}
|
||||
return !Zotero.DataObjectUtilities._tagsChanged(this._tags, recorded);
|
||||
|
||||
case 'relations':
|
||||
return this._undoRelationsMatch(recorded);
|
||||
|
||||
case 'creators':
|
||||
return this._undoCreatorsMatch(recorded);
|
||||
|
||||
case 'note':
|
||||
return this._noteText === recorded;
|
||||
|
||||
case 'itemTypeID':
|
||||
return this._itemTypeID === recorded;
|
||||
}
|
||||
|
||||
let fieldID = Zotero.ItemFields.getID(field);
|
||||
if (fieldID) {
|
||||
return this._undoItemDataMatches(fieldID, recorded);
|
||||
}
|
||||
// Primary scalar field (e.g. dateAdded) -- defer to the base implementation
|
||||
return Zotero.DataObject.prototype._undoFieldMatches.call(this, field, recorded);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Compare a recorded item-data value against the live one. An empty field reads
|
||||
* back as false, null, undefined, or '' depending on the path, so treat all of
|
||||
* those as equal -- like setField()'s own change check -- to avoid mistaking an
|
||||
* unchanged value for an external edit.
|
||||
*
|
||||
* @param {Integer} fieldID
|
||||
* @param {*} recorded
|
||||
* @return {Boolean}
|
||||
*/
|
||||
Zotero.Item.prototype._undoItemDataMatches = function (fieldID, recorded) {
|
||||
let current = this._itemData ? this._itemData[fieldID] : undefined;
|
||||
let emptyCurrent = current === undefined || current === null || current === false || current === '';
|
||||
let emptyRecorded = recorded === undefined || recorded === null || recorded === false || recorded === '';
|
||||
if (emptyCurrent || emptyRecorded) {
|
||||
return emptyCurrent === emptyRecorded;
|
||||
}
|
||||
return current === recorded;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Index-keyed creator comparison (reordering counts as a change), against the
|
||||
* { index -> creatorData } shape _getUndoData() records.
|
||||
*
|
||||
* @param {Object} recorded
|
||||
* @return {Boolean}
|
||||
*/
|
||||
Zotero.Item.prototype._undoCreatorsMatch = function (recorded) {
|
||||
recorded = recorded || {};
|
||||
if (this._creators.length !== Object.keys(recorded).length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < this._creators.length; i++) {
|
||||
if (!Zotero.Creators.equals(this._creators[i], recorded[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Order-independent comparison of the flat [predicate, object] pair arrays
|
||||
* _getUndoData() records for relations.
|
||||
*
|
||||
* @param {Array} recorded
|
||||
* @return {Boolean}
|
||||
*/
|
||||
Zotero.Item.prototype._undoRelationsMatch = function (recorded) {
|
||||
if (!Array.isArray(recorded)) {
|
||||
return false;
|
||||
}
|
||||
let current = this._relations.map(r => [...r]);
|
||||
if (current.length !== recorded.length) {
|
||||
return false;
|
||||
}
|
||||
let key = pair => pair[0] + "\t" + pair[1];
|
||||
return Zotero.Utilities.arrayEquals(current.map(key).sort(), recorded.map(key).sort());
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
* Get the title for an item for display in the interface
|
||||
*
|
||||
|
|
@ -1540,6 +1751,14 @@ Zotero.Item.prototype._saveData = async function (env) {
|
|||
if (Zotero.ItemFields.getID('accessDate') == fieldID
|
||||
&& (this.getField(fieldID)) == 'CURRENT_TIMESTAMP') {
|
||||
value = Zotero.DB.transactionDateTime;
|
||||
// The undo snapshot captured the unresolved sentinel as this
|
||||
// field's 'new' value. Replace it with the timestamp we're
|
||||
// actually writing so staleness detection can compare against
|
||||
// the stored value once the item reloads it
|
||||
if (env.undoData && env.undoData.fields.accessDate
|
||||
&& env.undoData.fields.accessDate.new === 'CURRENT_TIMESTAMP') {
|
||||
env.undoData.fields.accessDate.new = value;
|
||||
}
|
||||
}
|
||||
|
||||
let valueID = await Zotero.DB.valueQueryAsync(valueSQL, [value], { debug: true })
|
||||
|
|
|
|||
|
|
@ -1012,10 +1012,11 @@ Zotero.Items = function () {
|
|||
|
||||
this.trash = async function (ids) {
|
||||
Zotero.DB.requireTransaction();
|
||||
|
||||
|
||||
var libraryIDs = new Set();
|
||||
ids = Zotero.flattenArguments(ids);
|
||||
var items = [];
|
||||
var undoableCount = 0;
|
||||
for (let id of ids) {
|
||||
let item = this.get(id);
|
||||
if (!item) {
|
||||
|
|
@ -1023,18 +1024,35 @@ Zotero.Items = function () {
|
|||
Zotero.Notifier.queue('trash', 'item', id);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (!item.isEditable()) {
|
||||
throw new Error(item._ObjectType + " " + item.libraryKey + " is not editable");
|
||||
}
|
||||
|
||||
|
||||
if (!Zotero.Libraries.get(item.libraryID).hasTrash) {
|
||||
throw new Error(Zotero.Libraries.getName(item.libraryID) + " does not have a trash");
|
||||
}
|
||||
|
||||
|
||||
// Record undo data before modifying state
|
||||
if (Zotero.UndoHistory && !item.deleted) {
|
||||
Zotero.UndoHistory.stageChange({
|
||||
objectType: 'item',
|
||||
id: item.id,
|
||||
libraryID: item.libraryID,
|
||||
key: item.key,
|
||||
fields: {
|
||||
deleted: { old: false, new: true }
|
||||
}
|
||||
});
|
||||
undoableCount++;
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
libraryIDs.add(item.libraryID);
|
||||
}
|
||||
if (Zotero.UndoHistory && undoableCount) {
|
||||
Zotero.UndoHistory.stageAction('undo-action-trash', { count: undoableCount });
|
||||
}
|
||||
|
||||
var parentItemIDs = new Set();
|
||||
items.forEach(item => {
|
||||
|
|
|
|||
|
|
@ -683,6 +683,13 @@ Zotero.Library.prototype._eraseData = async function (env) {
|
|||
await Zotero.DB.queryAsync("DELETE FROM libraries WHERE libraryID=?", this.libraryID);
|
||||
// TODO: Emit event so this doesn't have to be here
|
||||
await Zotero.Fulltext.clearLibraryVersion(this.libraryID);
|
||||
|
||||
// Discard undo/redo history that references this library
|
||||
if (Zotero.UndoHistory) {
|
||||
Zotero.DB.addCurrentCallback('commit', function () {
|
||||
Zotero.UndoHistory.clearForLibrary(this.libraryID);
|
||||
}.bind(this));
|
||||
}
|
||||
};
|
||||
|
||||
Zotero.Library.prototype._finalizeErase = async function (env) {
|
||||
|
|
|
|||
|
|
@ -800,6 +800,10 @@ Zotero.Tags = new function () {
|
|||
return Zotero.DB.executeTransaction(async function () {
|
||||
// If all items already have the tag, remove it from all items
|
||||
if (tagID && items.every(x => x.hasTag(tagName))) {
|
||||
Zotero.UndoHistory.stageAction(
|
||||
'undo-action-remove-tag',
|
||||
{ count: items.length }
|
||||
);
|
||||
for (let item of items) {
|
||||
if (item.removeTag(tagName)) {
|
||||
await item.save();
|
||||
|
|
@ -809,6 +813,10 @@ Zotero.Tags = new function () {
|
|||
}
|
||||
// Otherwise add to all items
|
||||
else {
|
||||
Zotero.UndoHistory.stageAction(
|
||||
'undo-action-add-tag',
|
||||
{ count: items.length }
|
||||
);
|
||||
for (let item of items) {
|
||||
if (item.addTag(tagName)) {
|
||||
await item.save();
|
||||
|
|
@ -825,6 +833,10 @@ Zotero.Tags = new function () {
|
|||
*/
|
||||
this.removeColoredTagsFromItems = async function (items) {
|
||||
return Zotero.DB.executeTransaction(async function () {
|
||||
Zotero.UndoHistory.stageAction(
|
||||
'undo-action-remove-tag',
|
||||
{ count: items.length }
|
||||
);
|
||||
for (let item of items) {
|
||||
let colors = this.getColors(item.libraryID);
|
||||
let tags = item.getTags();
|
||||
|
|
|
|||
|
|
@ -136,7 +136,10 @@ class ReaderInstance {
|
|||
let item = Zotero.Items.getByLibraryAndKey(libraryID, key);
|
||||
if (item && item.isEditable()) {
|
||||
item.annotationColor = color;
|
||||
await item.saveTx({ skipDateModifiedUpdate: true, notifierQueue });
|
||||
await item.saveTx({
|
||||
skipDateModifiedUpdate: true,
|
||||
notifierQueue
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -804,6 +804,7 @@ Zotero.Sync.Data.Engine.prototype._restoreRestoredCollectionItems = async functi
|
|||
if (o.deleted) {
|
||||
o.deleted = false
|
||||
await o.saveTx();
|
||||
Zotero.Sync.Data.Local.markRemoteChangesApplied();
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
|
@ -816,6 +817,7 @@ Zotero.Sync.Data.Engine.prototype._restoreRestoredCollectionItems = async functi
|
|||
+ `to restored collection ${collection.libraryKey}`);
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
await collection.addItems(addToCollection);
|
||||
Zotero.Sync.Data.Local.markRemoteChangesApplied();
|
||||
}.bind(this));
|
||||
}
|
||||
if (addToQueue.length) {
|
||||
|
|
@ -911,6 +913,7 @@ Zotero.Sync.Data.Engine.prototype._downloadDeletions = async function (since, ne
|
|||
await obj.eraseTx({
|
||||
skipDeleteLog: true
|
||||
});
|
||||
Zotero.Sync.Data.Local.markRemoteChangesApplied();
|
||||
continue;
|
||||
}
|
||||
conflicts.push({
|
||||
|
|
@ -960,12 +963,13 @@ Zotero.Sync.Data.Engine.prototype._downloadDeletions = async function (since, ne
|
|||
await obj.erase({
|
||||
skipEditCheck: true
|
||||
});
|
||||
Zotero.Sync.Data.Local.markRemoteChangesApplied();
|
||||
}
|
||||
}.bind(this));
|
||||
}.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (toDelete.length) {
|
||||
await Zotero.Utilities.Internal.forEachChunkAsync(
|
||||
toDelete,
|
||||
|
|
@ -977,6 +981,7 @@ Zotero.Sync.Data.Engine.prototype._downloadDeletions = async function (since, ne
|
|||
skipEditCheck: true,
|
||||
skipDeleteLog: true
|
||||
});
|
||||
Zotero.Sync.Data.Local.markRemoteChangesApplied();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,21 @@ Zotero.Sync.Data.Local = {
|
|||
_loginManagerRealmLegacy: 'Zotero Web API',
|
||||
_lastSyncTime: null,
|
||||
_lastClassicSyncTime: null,
|
||||
|
||||
// Item/Collection-only -- synced settings can't affect the undo stack
|
||||
_remoteChangesApplied: false,
|
||||
|
||||
get remoteChangesApplied() {
|
||||
return this._remoteChangesApplied;
|
||||
},
|
||||
|
||||
resetRemoteChangesApplied: function () {
|
||||
this._remoteChangesApplied = false;
|
||||
},
|
||||
|
||||
markRemoteChangesApplied: function () {
|
||||
this._remoteChangesApplied = true;
|
||||
},
|
||||
|
||||
init: async function () {
|
||||
await this._loadLastSyncTime();
|
||||
if (!_lastSyncTime) {
|
||||
|
|
@ -354,7 +368,7 @@ Zotero.Sync.Data.Local = {
|
|||
var library = Zotero.Libraries.get(libraryID);
|
||||
library.libraryVersion = -1;
|
||||
await library.saveTx();
|
||||
|
||||
|
||||
await this.resetUnsyncedLibraryFiles(libraryID);
|
||||
},
|
||||
|
||||
|
|
@ -414,8 +428,9 @@ Zotero.Sync.Data.Local = {
|
|||
skipDeleteLog: true
|
||||
}
|
||||
);
|
||||
this.markRemoteChangesApplied();
|
||||
}
|
||||
|
||||
|
||||
// Deleted objects
|
||||
keys = await Zotero.Sync.Data.Local.getDeleted(objectType, libraryID);
|
||||
await this.removeObjectsFromDeleteLog(objectType, libraryID, keys);
|
||||
|
|
@ -1512,6 +1527,7 @@ Zotero.Sync.Data.Local = {
|
|||
await obj.erase({
|
||||
notifierQueue
|
||||
});
|
||||
Zotero.Sync.Data.Local.markRemoteChangesApplied();
|
||||
}
|
||||
catch (e) {
|
||||
results.push({
|
||||
|
|
@ -1685,6 +1701,7 @@ Zotero.Sync.Data.Local = {
|
|||
obj.synced = true;
|
||||
}
|
||||
await obj.save(saveOptions);
|
||||
this.markRemoteChangesApplied();
|
||||
let cacheJSON = options.cacheObject ? options.cacheObject : json.data;
|
||||
await this.saveCacheObject(obj.objectType, obj.libraryID, cacheJSON);
|
||||
// Delete older versions of the object in the cache
|
||||
|
|
|
|||
|
|
@ -116,13 +116,13 @@ Zotero.Sync.Runner_Module = function (options = {}) {
|
|||
this.sync = Zotero.serial(function (options = {}) {
|
||||
return this._sync(options);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
this._sync = async function (options) {
|
||||
// Clear message list
|
||||
_errors = [];
|
||||
_tooltipMessages = [];
|
||||
|
||||
|
||||
// Shouldn't be possible because of serial()
|
||||
if (_syncInProgress) {
|
||||
let msg = Zotero.getString('sync.error.syncInProgress');
|
||||
|
|
@ -132,6 +132,10 @@ Zotero.Sync.Runner_Module = function (options = {}) {
|
|||
}
|
||||
_syncInProgress = true;
|
||||
_stopping = false;
|
||||
|
||||
// Reset remote-change tracking for this sync; the undo stack is
|
||||
// cleared lazily at the end only if remote mutations were applied.
|
||||
Zotero.Sync.Data.Local.resetRemoteChangesApplied();
|
||||
|
||||
try {
|
||||
await Zotero.Notifier.trigger('start', 'sync', []);
|
||||
|
|
@ -321,7 +325,14 @@ Zotero.Sync.Runner_Module = function (options = {}) {
|
|||
}
|
||||
finally {
|
||||
await this.end(options);
|
||||
|
||||
|
||||
// Clear undo history if this iteration applied remote changes.
|
||||
// Done before any restart/queued recursive call so the inner
|
||||
// sync's reset doesn't lose the decision made here.
|
||||
if (Zotero.Sync.Data.Local.remoteChangesApplied) {
|
||||
Zotero.UndoHistory.clear();
|
||||
}
|
||||
|
||||
if (options.restartSync) {
|
||||
delete options.restartSync;
|
||||
Zotero.debug("Restarting sync");
|
||||
|
|
@ -334,7 +345,7 @@ Zotero.Sync.Runner_Module = function (options = {}) {
|
|||
await this._sync(JSON.parse(_queuedSyncOptions.shift()));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Zotero.debug("Done syncing");
|
||||
Zotero.Notifier.trigger('finish', 'sync', librariesToSync || []);
|
||||
}
|
||||
|
|
|
|||
519
chrome/content/zotero/xpcom/undoHistory.js
Normal file
519
chrome/content/zotero/xpcom/undoHistory.js
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
/*
|
||||
***** BEGIN LICENSE BLOCK *****
|
||||
|
||||
Copyright © 2026 Corporation for Digital Scholarship
|
||||
Vienna, Virginia, USA
|
||||
https://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 *****
|
||||
*/
|
||||
|
||||
/**
|
||||
* In-memory undo/redo stack for DataObject field changes.
|
||||
*
|
||||
* Hooks into DB transaction lifecycle to batch all saves within a single
|
||||
* executeTransaction() into one undo step. Only tracks modifications to
|
||||
* existing objects.
|
||||
*
|
||||
* Capture is opt-in via a two-call staging protocol within a transaction:
|
||||
* - stageChange(record) -- append a change record to the pending entry
|
||||
* - stageAction(action, args) -- attach an action label
|
||||
* Both must be called within the same transaction for the entry to land on
|
||||
* the undo stack. A transaction that stages changes without an action (or
|
||||
* vice versa, with no staged changes) is silently discarded at commit.
|
||||
* DataObject.save() calls stageChange unconditionally for non-isNew saves;
|
||||
* stageAction is opt-in via save({ undoAction, undoActionArgs }) or by an
|
||||
* outer caller invoking Zotero.UndoHistory.stageAction() directly inside
|
||||
* the transaction.
|
||||
*/
|
||||
Zotero.UndoHistory = {
|
||||
_undoStack: [],
|
||||
_redoStack: [],
|
||||
_pendingEntry: null,
|
||||
_maxSteps: 100,
|
||||
_opQueue: Promise.resolve(),
|
||||
|
||||
init() {
|
||||
// default (100) when unset or non-numeric. 0 (or less) disables undo/redo entirely.
|
||||
let steps = Zotero.Prefs.get('undoHistory.steps');
|
||||
this._maxSteps = Number.isInteger(steps) ? steps : 100;
|
||||
this.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* @return {Boolean}
|
||||
*/
|
||||
isEnabled() {
|
||||
return this._maxSteps > 0;
|
||||
},
|
||||
|
||||
clear() {
|
||||
this._undoStack = [];
|
||||
this._redoStack = [];
|
||||
this._pendingEntry = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Discard both stacks if any entry references an object in the given library.
|
||||
* @param {Integer} libraryID
|
||||
*/
|
||||
clearForLibrary(libraryID) {
|
||||
let affectsLibrary = entry => entry.changes.some(change => change.libraryID === libraryID);
|
||||
if (this._undoStack.some(affectsLibrary) || this._redoStack.some(affectsLibrary)) {
|
||||
this.clear();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Return a window controller for cmd_undo/cmd_redo that defers to
|
||||
* native text-editing controllers when they are active.
|
||||
* Caller should append it to window.controllers.
|
||||
*
|
||||
* @param {Document} doc
|
||||
* @return {Object}
|
||||
*/
|
||||
getController(doc) {
|
||||
return {
|
||||
supportsCommand: cmd => cmd === 'cmd_undo' || cmd === 'cmd_redo',
|
||||
isCommandEnabled: (cmd) => {
|
||||
// Defer to native text-editing controllers when they can
|
||||
// handle undo/redo (e.g. focused input/textarea)
|
||||
if (this._hasNativeCommand(doc, cmd)) return false;
|
||||
if (cmd === 'cmd_undo') return this.canUndo();
|
||||
if (cmd === 'cmd_redo') return this.canRedo();
|
||||
return false;
|
||||
},
|
||||
doCommand: (cmd) => {
|
||||
if (cmd === 'cmd_undo') this.undo();
|
||||
else if (cmd === 'cmd_redo') this.redo();
|
||||
},
|
||||
onEvent: () => {}
|
||||
};
|
||||
},
|
||||
|
||||
canUndo() {
|
||||
return this._undoStack.length > 0;
|
||||
},
|
||||
|
||||
canRedo() {
|
||||
return this._redoStack.length > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Run an undo/redo operation only after all previously queued ones have
|
||||
* settled, so each step's staleness check sees the fully committed result
|
||||
* of the step before it. Returns the operation's own result; a failure in
|
||||
* one operation doesn't stall the queue for the next.
|
||||
*
|
||||
* @param {Function} fn -- async operation returning Promise<Boolean>
|
||||
* @return {Promise<Boolean>}
|
||||
*/
|
||||
_enqueue(fn) {
|
||||
this._opQueue = this._opQueue.then(fn, fn);
|
||||
return this._opQueue;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check whether the focused element has a native controller (e.g.
|
||||
* text-editing) that supports undo/redo, meaning UndoHistory should defer.
|
||||
* Checks the focused element's own controllers directly to avoid
|
||||
* re-entrancy with the command dispatcher.
|
||||
*
|
||||
* @param {Document} doc
|
||||
* @return {Boolean}
|
||||
*/
|
||||
hasNativeUndo(doc) {
|
||||
return this._hasNativeCommand(doc, 'cmd_undo');
|
||||
},
|
||||
|
||||
hasNativeRedo(doc) {
|
||||
return this._hasNativeCommand(doc, 'cmd_redo');
|
||||
},
|
||||
|
||||
_hasNativeCommand(doc, cmd) {
|
||||
// If focus is in a child window (e.g. note-editor or reader iframe),
|
||||
// it handles its own undo/redo internally
|
||||
let focusedWindow = doc.commandDispatcher.focusedWindow;
|
||||
if (focusedWindow && focusedWindow !== doc.defaultView) {
|
||||
return true;
|
||||
}
|
||||
let el = doc.commandDispatcher.focusedElement;
|
||||
if (!el) return false;
|
||||
// Iframes (note-editor, reader) handle their own undo/redo
|
||||
// internally but don't expose XUL controllers for it
|
||||
if (el.tagName === 'iframe' || el.tagName === 'IFRAME') return true;
|
||||
let controllers;
|
||||
try {
|
||||
controllers = el.controllers;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
if (!controllers) return false;
|
||||
for (let i = 0; i < controllers.getControllerCount(); i++) {
|
||||
let ctrl = controllers.getControllerAt(i);
|
||||
if (ctrl.supportsCommand(cmd)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Undo the most recent change entry. Serialized through a queue.
|
||||
*
|
||||
* @return {Promise<Boolean>} -- true if an entry was undone
|
||||
*/
|
||||
undo() {
|
||||
return this._enqueue(() => this._undo());
|
||||
},
|
||||
|
||||
_undo() {
|
||||
// Undo restores the 'old' snapshot; decline if a covered object no
|
||||
// longer holds the recorded 'new' value (an outside writer changed it).
|
||||
return this._apply({
|
||||
fromStack: '_undoStack',
|
||||
toStack: '_redoStack',
|
||||
staleSide: 'new',
|
||||
applySide: 'old',
|
||||
label: 'undo'
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Redo the most recently undone entry. Serialized through the same queue as undo()
|
||||
*
|
||||
* @return {Promise<Boolean>} -- true if an entry was redone
|
||||
*/
|
||||
redo() {
|
||||
return this._enqueue(() => this._redo());
|
||||
},
|
||||
|
||||
_redo() {
|
||||
// Redo reapplies the 'new' snapshot; decline if a covered object no
|
||||
// longer holds the recorded 'old' value (mirrors the check in _undo).
|
||||
return this._apply({
|
||||
fromStack: '_redoStack',
|
||||
toStack: '_undoStack',
|
||||
staleSide: 'old',
|
||||
applySide: 'new',
|
||||
label: 'redo'
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Pop the top entry off one stack, write back the recorded snapshot for the
|
||||
* given side, and -- on success -- push the entry onto the opposite stack.
|
||||
* Shared implementation behind _undo() (applySide 'old') and _redo()
|
||||
* (applySide 'new').
|
||||
*
|
||||
* The staleness check and the apply share one transaction so they're atomic.
|
||||
* If any object no longer holds its recorded `staleSide` value, an outside
|
||||
* writer changed it and replaying would clobber that change, so we decline
|
||||
* and discard history. A mid-apply failure is likewise untrustworthy, so we
|
||||
* realign memory with the rolled-back DB and discard.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {String} opts.fromStack -- name of the stack to pop the entry from
|
||||
* @param {String} opts.toStack -- name of the stack to push the entry to on success
|
||||
* @param {String} opts.staleSide -- recorded side the object must still hold ('new'/'old')
|
||||
* @param {String} opts.applySide -- recorded side to write back ('old'/'new')
|
||||
* @param {String} opts.label -- 'undo' or 'redo', for debug logging
|
||||
* @return {Promise<Boolean>} -- true if an entry was applied
|
||||
*/
|
||||
async _apply({ fromStack, toStack, staleSide, applySide, label }) {
|
||||
let entry = this[fromStack].pop();
|
||||
if (!entry) return false;
|
||||
let stale = false;
|
||||
try {
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
if (this._entryIsStale(entry, staleSide)) {
|
||||
stale = true;
|
||||
return;
|
||||
}
|
||||
for (let change of entry.changes) {
|
||||
let obj = this._getObject(change);
|
||||
if (!obj) continue;
|
||||
// Apply itemTypeID first so setType() migrates fields before
|
||||
// the type-specific fields are restored on the correct type
|
||||
if (change.fields.itemTypeID) {
|
||||
this._applyFieldValue(obj, 'itemTypeID', change.fields.itemTypeID[applySide]);
|
||||
}
|
||||
for (let [field, values] of Object.entries(change.fields)) {
|
||||
if (field === 'itemTypeID') continue;
|
||||
this._applyFieldValue(obj, field, values[applySide]);
|
||||
}
|
||||
await obj.save({ skipSelect: true });
|
||||
}
|
||||
});
|
||||
if (stale) {
|
||||
Zotero.debug(`UndoHistory: declining stale ${label} entry`);
|
||||
this.clear();
|
||||
return false;
|
||||
}
|
||||
this[toStack].push(entry);
|
||||
return true;
|
||||
}
|
||||
catch (e) {
|
||||
Zotero.debug(`UndoHistory: ${label} failed: ` + e);
|
||||
// Realign memory with the rolled-back DB before clearing history.
|
||||
await this._reloadEntryObjects(entry);
|
||||
// A failure means the object drifted out from under our snapshots,
|
||||
// so the rest of the stack can't be trusted either. Discard history
|
||||
// rather than risk applying stale values.
|
||||
this.clear();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// -- Transaction lifecycle callbacks --
|
||||
|
||||
_onTransactionBegin(_id) {
|
||||
this._pendingEntry = null;
|
||||
},
|
||||
|
||||
_onTransactionCommit(_id) {
|
||||
// Only push entries that staged both changes and an action;
|
||||
// anything else (orphan captures, action without changes) is dropped
|
||||
if (this._pendingEntry && this._pendingEntry.changes.length && this._pendingEntry.action) {
|
||||
this._undoStack.push(this._pendingEntry);
|
||||
this._redoStack = [];
|
||||
if (this._undoStack.length > this._maxSteps) {
|
||||
this._undoStack.splice(0, this._undoStack.length - this._maxSteps);
|
||||
}
|
||||
}
|
||||
this._pendingEntry = null;
|
||||
},
|
||||
|
||||
_onTransactionRollback(_id) {
|
||||
this._pendingEntry = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Stage an action label on the pending entry. Must be called inside a
|
||||
* transaction. Together with one or more stageChange() calls in the same
|
||||
* transaction, this is what makes the staged changes land on the undo
|
||||
* stack at commit -- a transaction that doesn't call stageAction has its
|
||||
* staged changes silently discarded.
|
||||
*
|
||||
* If called more than once in the same transaction, the last call wins.
|
||||
*
|
||||
* @param {String} action -- Fluent message ID (e.g. 'undo-action-add-tag')
|
||||
* @param {Object} [actionArgs] -- Fluent message arguments (e.g. { count: 3 })
|
||||
*/
|
||||
stageAction(action, actionArgs) {
|
||||
if (!this.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
Zotero.DB.requireTransaction();
|
||||
if (!this._pendingEntry) {
|
||||
this._pendingEntry = { changes: [], action: null, actionArgs: null };
|
||||
}
|
||||
this._pendingEntry.action = action;
|
||||
this._pendingEntry.actionArgs = actionArgs || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the action description for the top of the undo stack
|
||||
*
|
||||
* @return {{ action: String, actionArgs: Object }|null}
|
||||
*/
|
||||
getUndoAction() {
|
||||
let entry = this._undoStack[this._undoStack.length - 1];
|
||||
if (!entry || !entry.action) return null;
|
||||
return { action: entry.action, actionArgs: entry.actionArgs };
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the action description for the top of the redo stack
|
||||
*
|
||||
* @return {{ action: String, actionArgs: Object }|null}
|
||||
*/
|
||||
getRedoAction() {
|
||||
let entry = this._redoStack[this._redoStack.length - 1];
|
||||
if (!entry || !entry.action) return null;
|
||||
return { action: entry.action, actionArgs: entry.actionArgs };
|
||||
},
|
||||
|
||||
/**
|
||||
* Stage a change record. Must be called inside a transaction; without
|
||||
* a matching stageAction() in the same transaction, the record is
|
||||
* discarded at commit.
|
||||
*
|
||||
* Records for the same (objectType, id) are coalesced field-by-field:
|
||||
* first-write-wins for `old`, last-write-wins for `new`. This lets a
|
||||
* loop that saves the same object multiple times produce one composite
|
||||
* record spanning the whole transaction.
|
||||
*
|
||||
* @param {Object} changeRecord
|
||||
*/
|
||||
stageChange(changeRecord) {
|
||||
if (!this.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
Zotero.DB.requireTransaction();
|
||||
if (!this._pendingEntry) {
|
||||
this._pendingEntry = { changes: [], action: null, actionArgs: null };
|
||||
}
|
||||
let existing = this._pendingEntry.changes.find(
|
||||
c => c.objectType === changeRecord.objectType && c.id === changeRecord.id);
|
||||
if (existing) {
|
||||
for (let [field, vals] of Object.entries(changeRecord.fields)) {
|
||||
if (existing.fields[field]) {
|
||||
existing.fields[field].new = vals.new;
|
||||
}
|
||||
else {
|
||||
existing.fields[field] = vals;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
this._pendingEntry.changes.push(changeRecord);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Realign in-memory state with the DB after a failed apply. The apply runs
|
||||
* many saves in one transaction; if a later one throws, the transaction
|
||||
* rolls back, but objects saved earlier already hold their reverted values
|
||||
* in memory. Reload each covered object so memory matches the committed DB.
|
||||
* Objects that no longer resolve (e.g. erased) or fail to reload are skipped.
|
||||
*
|
||||
* @param {Object} entry
|
||||
*/
|
||||
async _reloadEntryObjects(entry) {
|
||||
for (let change of entry.changes) {
|
||||
let obj = this._getObject(change);
|
||||
if (!obj) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await obj.reload(null, true);
|
||||
}
|
||||
catch (e) {
|
||||
Zotero.debug('UndoHistory: failed to reload object after apply failure: ' + e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve a change record to a live DataObject
|
||||
*
|
||||
* @param {Object} change
|
||||
* @return {Zotero.DataObject|null}
|
||||
*/
|
||||
_getObject(change) {
|
||||
let objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(change.objectType);
|
||||
return objectsClass ? objectsClass.get(change.id) : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply a value to the appropriate setter on an object
|
||||
*
|
||||
* @param {Zotero.DataObject} obj
|
||||
* @param {String} field
|
||||
* @param {*} value
|
||||
*/
|
||||
_applyFieldValue(obj, field, value) {
|
||||
if (field === 'deleted') {
|
||||
obj.deleted = value;
|
||||
}
|
||||
else if (field === 'name') {
|
||||
obj.name = value;
|
||||
}
|
||||
else if (field === 'parentKey') {
|
||||
// parentID setter routes through _setParentKey, which marks
|
||||
// `parentKey` in _previousData (not parentID)
|
||||
obj.parentKey = value;
|
||||
}
|
||||
else if (field === 'collections') {
|
||||
obj.setCollections(value);
|
||||
}
|
||||
else if (field === 'tags') {
|
||||
obj.setTags(value);
|
||||
}
|
||||
else if (field === 'note') {
|
||||
obj.setNote(value);
|
||||
}
|
||||
else if (field === 'creators') {
|
||||
// value is an object mapping orderIndex -> creator data (or empty object)
|
||||
let maxIndex = -1;
|
||||
for (let idx of Object.keys(value)) {
|
||||
let i = parseInt(idx);
|
||||
let creatorData = value[i];
|
||||
obj.setCreator(i, creatorData);
|
||||
if (i > maxIndex) maxIndex = i;
|
||||
}
|
||||
// Remove any creators beyond the restored set
|
||||
while (obj.hasCreatorAt(maxIndex + 1)) {
|
||||
obj.removeCreator(maxIndex + 1);
|
||||
}
|
||||
}
|
||||
else if (field === 'relations') {
|
||||
// value is a flat array of [predicate, object] pairs
|
||||
let relObj = {};
|
||||
for (let [predicate, object] of value) {
|
||||
if (!relObj[predicate]) relObj[predicate] = [];
|
||||
relObj[predicate].push(object);
|
||||
}
|
||||
obj.setRelations(relObj);
|
||||
}
|
||||
else if (field === 'itemTypeID') {
|
||||
obj.setType(value);
|
||||
}
|
||||
else if (obj instanceof Zotero.Item) {
|
||||
obj.setField(field, value);
|
||||
}
|
||||
else {
|
||||
obj[field] = value;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether an object covered by the entry no longer holds the value we
|
||||
* recorded for the given side ('new' for undo, 'old' for redo) -- meaning
|
||||
* something outside this history changed it and replaying would clobber that
|
||||
* change. Objects that no longer resolve (e.g. erased) are skipped.
|
||||
*
|
||||
* @param {Object} entry
|
||||
* @param {String} side -- 'new' (undo) or 'old' (redo)
|
||||
* @return {Boolean}
|
||||
*/
|
||||
_entryIsStale(entry, side) {
|
||||
for (let change of entry.changes) {
|
||||
let obj = this._getObject(change);
|
||||
if (!obj) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let matches;
|
||||
try {
|
||||
matches = obj.matchesUndoSnapshot(change.fields, side);
|
||||
}
|
||||
catch (e) {
|
||||
// Can't verify the snapshot, so we can't rule out an external change
|
||||
Zotero.debug('UndoHistory: could not verify snapshot for staleness; declining entry: ' + e);
|
||||
return true;
|
||||
}
|
||||
if (!matches) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -540,6 +540,12 @@ const { CommandLineOptions } = ChromeUtils.importESModule("chrome://zotero/conte
|
|||
Zotero.DB.addCallback('begin', id => Zotero.Notifier.begin(id));
|
||||
Zotero.DB.addCallback('commit', id => Zotero.Notifier.commit(null, id));
|
||||
Zotero.DB.addCallback('rollback', id => Zotero.Notifier.reset(id));
|
||||
|
||||
// Initialize undo history and add its callbacks to the DB layer
|
||||
Zotero.UndoHistory.init();
|
||||
Zotero.DB.addCallback('begin', id => Zotero.UndoHistory._onTransactionBegin(id));
|
||||
Zotero.DB.addCallback('commit', id => Zotero.UndoHistory._onTransactionCommit(id));
|
||||
Zotero.DB.addCallback('rollback', id => Zotero.UndoHistory._onTransactionRollback(id));
|
||||
|
||||
try {
|
||||
// Require >=2.1b3 database to ensure proper locking
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ const xpcomFilesLocal = [
|
|||
'locateManager',
|
||||
'mime',
|
||||
'notifier',
|
||||
'undoHistory',
|
||||
'fileHandlers',
|
||||
'osKeyStore',
|
||||
'plugins',
|
||||
|
|
|
|||
|
|
@ -105,7 +105,11 @@ var ZoteroPane = new function () {
|
|||
}
|
||||
|
||||
_loaded = true;
|
||||
|
||||
|
||||
// Register a window controller for global undo/redo. Appending (rather
|
||||
// than inserting at 0) ensures text-editing controllers take priority.
|
||||
window.controllers.appendController(Zotero.UndoHistory.getController(document));
|
||||
|
||||
var zp = document.getElementById('zotero-pane');
|
||||
Zotero.UIProperties.registerRoot(zp);
|
||||
zp.addEventListener('UIPropertiesChanged', () => {
|
||||
|
|
@ -2579,6 +2583,7 @@ var ZoteroPane = new function () {
|
|||
skipDateModifiedUpdate: true
|
||||
};
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
Zotero.UndoHistory.stageAction('undo-action-add-related');
|
||||
for (let index1 = 0; index1 < selectedItems.length; index1++) {
|
||||
for (let index2 = index1 + 1; index2 < selectedItems.length; index2++) {
|
||||
let item1 = selectedItems[index1];
|
||||
|
|
@ -2716,6 +2721,18 @@ var ZoteroPane = new function () {
|
|||
let isSelected = object => selectedObjects.includes(object);
|
||||
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
let undoAction;
|
||||
if (selectedObjects.every(o => o instanceof Zotero.Item)) {
|
||||
undoAction = 'undo-action-restore-items';
|
||||
}
|
||||
else if (selectedObjects.every(o => o instanceof Zotero.Collection)) {
|
||||
undoAction = 'undo-action-restore-collection';
|
||||
}
|
||||
else {
|
||||
undoAction = 'undo-action-restore-objects';
|
||||
}
|
||||
Zotero.UndoHistory.stageAction(undoAction, { count: selectedObjects.length });
|
||||
|
||||
for (let row = 0; row < this.itemsView.rowCount; row++) {
|
||||
// Only look at top-level items
|
||||
if (this.itemsView.getLevel(row) !== 0) {
|
||||
|
|
@ -2801,6 +2818,7 @@ var ZoteroPane = new function () {
|
|||
Zotero.hideZoteroPaneOverlays();
|
||||
}
|
||||
await Zotero.purgeDataObjects();
|
||||
Zotero.UndoHistory.clear();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -2856,7 +2874,7 @@ var ZoteroPane = new function () {
|
|||
selected.parentID = target.id;
|
||||
}
|
||||
|
||||
await selected.saveTx();
|
||||
await selected.saveTx({ undoAction: 'undo-action-move-collection' });
|
||||
};
|
||||
|
||||
// Copy selected collection into another collection or library.
|
||||
|
|
@ -4706,8 +4724,14 @@ var ZoteroPane = new function () {
|
|||
collection = Zotero.Collections.get(id);
|
||||
}
|
||||
|
||||
await Zotero.DB.executeTransaction(
|
||||
() => collection.addItems(items.map(item => item.id)));
|
||||
let ids = items.map(item => item.id);
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
Zotero.UndoHistory.stageAction(
|
||||
'undo-action-add-to-collection',
|
||||
{ count: ids.length }
|
||||
);
|
||||
await collection.addItems(ids);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -5612,6 +5636,11 @@ var ZoteroPane = new function () {
|
|||
// If "Convert to Standalone Attachment" is selected, make all attachments top-level items
|
||||
if (shouldConvertToStandaloneAttachment) {
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
Zotero.UndoHistory.stageAction(
|
||||
'undo-action-convert-to-standalone',
|
||||
{ count: selectedItems.length }
|
||||
);
|
||||
|
||||
for (let item of selectedItems) {
|
||||
let parent = Zotero.Items.get(item.parentID);
|
||||
if (parent) {
|
||||
|
|
@ -5634,6 +5663,11 @@ var ZoteroPane = new function () {
|
|||
if (!newParentItem.length) return;
|
||||
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
Zotero.UndoHistory.stageAction(
|
||||
'undo-action-change-parent-item',
|
||||
{ count: selectedItems.length }
|
||||
);
|
||||
|
||||
for (let item of selectedItems) {
|
||||
item.parentID = newParentItem[0].id;
|
||||
await item.save({ skipSelect: true });
|
||||
|
|
@ -6695,12 +6729,13 @@ var ZoteroPane = new function () {
|
|||
return [];
|
||||
}));
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
Zotero.UndoHistory.stageAction('undo-action-normalize-attachment-titles');
|
||||
for (let attachment of attachments) {
|
||||
if (attachment.getField('title').replace(/\.[^.]+$/, '') !== attachment.attachmentFilename.replace(/\.[^.]+$/, '')) {
|
||||
Zotero.debug(`Skipping attachment with modified title: ${attachment.getField('title')}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
let forceFirstOfType = !!attachment.parentItemID
|
||||
&& await attachment.parentItem.getBestAttachment() === attachment;
|
||||
attachment.setAutoAttachmentTitle({ forceFirstOfType });
|
||||
|
|
|
|||
|
|
@ -1073,3 +1073,88 @@ item-pane-batch-editing-header = { $count ->
|
|||
|
||||
item-pane-batch-editing-done =
|
||||
.label = { general-done }
|
||||
|
||||
undo-action-edit-metadata = { $count ->
|
||||
[one] Edit Metadata
|
||||
*[other] Edit Metadata for { $count } Items
|
||||
}
|
||||
undo-action-edit-field = { $count ->
|
||||
[one] Edit of “{ $field }”
|
||||
*[other] Edit of “{ $field }” for { $count } Items
|
||||
}
|
||||
undo-action-normalize-attachment-titles = Normalize Attachment Title
|
||||
undo-action-trash = { $count ->
|
||||
[one] Trash Item
|
||||
*[other] Trash { $count } Items
|
||||
}
|
||||
undo-action-restore-items = { $count ->
|
||||
[one] Restore Item
|
||||
*[other] Restore { $count } Items
|
||||
}
|
||||
undo-action-trash-collection = { $count ->
|
||||
[one] Trash Collection
|
||||
*[other] Trash { $count } Collections
|
||||
}
|
||||
undo-action-trash-search = { $count ->
|
||||
[one] Trash Saved Search
|
||||
*[other] Trash { $count } Saved Searches
|
||||
}
|
||||
undo-action-restore-collection = { $count ->
|
||||
[one] Restore Collection
|
||||
*[other] Restore { $count } Collections
|
||||
}
|
||||
undo-action-restore-objects = { $count ->
|
||||
[one] Restore Object
|
||||
*[other] Restore { $count } Objects
|
||||
}
|
||||
undo-action-add-to-collection = { $count ->
|
||||
[one] Add to Collection
|
||||
*[other] Add { $count } Items to Collection
|
||||
}
|
||||
undo-action-remove-from-collection = { $count ->
|
||||
[one] Remove from Collection
|
||||
*[other] Remove { $count } Items from Collection
|
||||
}
|
||||
undo-action-move-to-collection = { $count ->
|
||||
[one] Move to Collection
|
||||
*[other] Move { $count } Items to Collection
|
||||
}
|
||||
undo-action-rename-collection = Rename Collection
|
||||
undo-action-move-collection = Move Collection
|
||||
undo-action-add-tag = { $count ->
|
||||
[one] Add Tag
|
||||
*[other] Add Tag to { $count } Items
|
||||
}
|
||||
undo-action-change-tag = Change Tag
|
||||
undo-action-split-tag = Split Tag
|
||||
undo-action-remove-tag = { $count ->
|
||||
[one] Remove Tag
|
||||
*[other] Remove Tag from { $count } Items
|
||||
}
|
||||
undo-action-remove-tags-from-item = { $count ->
|
||||
[one] Remove Tag
|
||||
*[other] Remove { $count } Tags
|
||||
}
|
||||
undo-action-remove-all-tags = Remove All Tags
|
||||
undo-action-edit-note = Edit Note
|
||||
undo-action-add-creator = Add Creator
|
||||
undo-action-remove-creator = Remove Creator
|
||||
undo-action-edit-creator = Edit Creator
|
||||
undo-action-reorder-creator = Reorder Creator
|
||||
undo-action-change-type = Change Item Type
|
||||
undo-action-change-parent-item = { $count ->
|
||||
[one] Change Parent Item
|
||||
*[other] Change Parent for { $count } Items
|
||||
}
|
||||
undo-action-convert-to-standalone = { $count ->
|
||||
[one] Convert to Standalone
|
||||
*[other] Convert { $count } Items to Standalone
|
||||
}
|
||||
undo-action-add-related = Add Related
|
||||
undo-action-remove-related = Remove Related
|
||||
undo-action-merge-items = { $count ->
|
||||
[one] Merge Item
|
||||
*[other] Merge { $count } Items
|
||||
}
|
||||
menu-edit-undo-action = Undo { $action }
|
||||
menu-edit-redo-action = Redo { $action }
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
// http://www.zotero.org/documentation/hidden_prefs
|
||||
|
||||
pref("extensions.zotero.firstRun2", true);
|
||||
pref("extensions.zotero.undoHistory.steps", 100);
|
||||
|
||||
pref("extensions.zotero.saveRelativeAttachmentPath", false);
|
||||
pref("extensions.zotero.baseAttachmentPath", "");
|
||||
|
|
|
|||
|
|
@ -995,13 +995,13 @@ describe("Zotero.CollectionTree", function () {
|
|||
assert.equal(zp.itemsView.rowCount, 0);
|
||||
|
||||
await select(win, collection2);
|
||||
|
||||
|
||||
// Target collection should have item
|
||||
assert.equal(zp.itemsView.rowCount, 1);
|
||||
var treeRow = zp.itemsView.getRow(0);
|
||||
assert.equal(treeRow.ref.id, item.id);
|
||||
});
|
||||
|
||||
|
||||
it("should add a multiple-library item selection to a collection, copying out-of-library items", async function () {
|
||||
await Zotero.Users.setCurrentUserID(1);
|
||||
await Zotero.Users.setName(1, 'Name');
|
||||
|
|
@ -1080,6 +1080,41 @@ describe("Zotero.CollectionTree", function () {
|
|||
assert.isFalse(await canDrop('item', 'L' + userLibraryID, [item1.id, item2.id]));
|
||||
});
|
||||
|
||||
it("should record an undo step when adding an item to a collection", async function () {
|
||||
var collection = await createDataObject('collection');
|
||||
var item = await createDataObject('item', false, { skipSelect: true });
|
||||
Zotero.UndoHistory.clear();
|
||||
|
||||
// Add observer to wait for collection add
|
||||
var deferred = Zotero.Promise.defer();
|
||||
var observerID = Zotero.Notifier.registerObserver({
|
||||
notify: function (event, type, ids, extraData) {
|
||||
if (type == 'collection-item' && event == 'add'
|
||||
&& ids[0] == collection.id + "-" + item.id) {
|
||||
setTimeout(function () {
|
||||
deferred.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 'collection-item', 'test');
|
||||
|
||||
await onDrop('item', 'C' + collection.id, [item.id], deferred.promise);
|
||||
|
||||
Zotero.Notifier.unregisterObserver(observerID);
|
||||
|
||||
assert.include(item.getCollections(), collection.id);
|
||||
assert.isTrue(Zotero.UndoHistory.canUndo());
|
||||
var action = Zotero.UndoHistory.getUndoAction();
|
||||
assert.equal(action.action, 'undo-action-add-to-collection');
|
||||
assert.equal(action.actionArgs.count, 1);
|
||||
|
||||
await Zotero.UndoHistory.undo();
|
||||
assert.notInclude(item.getCollections(), collection.id);
|
||||
|
||||
await Zotero.UndoHistory.redo();
|
||||
assert.include(item.getCollections(), collection.id);
|
||||
});
|
||||
|
||||
describe("My Publications", function () {
|
||||
function getItemModifyPromise(item) {
|
||||
// Add observer to wait for item modification
|
||||
|
|
|
|||
|
|
@ -3081,6 +3081,82 @@ describe("Item pane", function () {
|
|||
|
||||
await group.eraseTx();
|
||||
});
|
||||
it("should undo and redo a batch field edit", async function () {
|
||||
let item1 = await createDataObject('item', { itemType: 'journalArticle' });
|
||||
item1.setField('publicationTitle', 'Journal Alpha');
|
||||
await item1.saveTx();
|
||||
|
||||
let item2 = await createDataObject('item', { itemType: 'journalArticle' });
|
||||
item2.setField('publicationTitle', 'Journal Beta');
|
||||
await item2.saveTx();
|
||||
|
||||
let item3 = await createDataObject('item', { itemType: 'journalArticle' });
|
||||
item3.setField('publicationTitle', 'Journal Gamma');
|
||||
await item3.saveTx();
|
||||
|
||||
await ZoteroPane.selectItems([item1.id, item2.id, item3.id]);
|
||||
Zotero.UndoHistory.clear();
|
||||
|
||||
let itemPane = win.ZoteroPane.itemPane;
|
||||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||||
|
||||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||||
batchEditEnableBtn.click();
|
||||
await itemDetails._renderPromise;
|
||||
|
||||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||||
let pubTitleField = itemBox.querySelector('editable-text[fieldname="publicationTitle"]');
|
||||
assert.ok(pubTitleField, "publicationTitle field should exist");
|
||||
|
||||
assert.equal(pubTitleField.value, '', "field value should be empty before edit");
|
||||
assert.equal(pubTitleField.placeholder, Zotero.getString('item-pane-batch-editing-multiple-values-placeholder'), "field should show Multiple placeholder before edit");
|
||||
|
||||
pubTitleField._ignoredWindowInactiveBlur = false;
|
||||
await activateZoteroPane();
|
||||
await Zotero.Promise.delay(50);
|
||||
pubTitleField.focus();
|
||||
|
||||
// Options sorted alphabetically: Alpha, Beta, Gamma + "no value" option
|
||||
await waitForCallback(() => pubTitleField.ref.mController.matchCount === 4, 100, 500);
|
||||
// Select "Journal Alpha" from autocomplete (first entry)
|
||||
let modifyPromise = waitForItemEvent('modify');
|
||||
pubTitleField.ref.dispatchEvent(new KeyboardEvent(
|
||||
'keydown', { key: "ArrowDown", code: 'ArrowDown', keyCode: KeyboardEvent.DOM_VK_DOWN, bubbles: true }
|
||||
));
|
||||
await Zotero.Promise.delay(50);
|
||||
pubTitleField.ref.dispatchEvent(new KeyboardEvent(
|
||||
'keydown', { key: "Enter", code: "Enter", keyCode: KeyboardEvent.DOM_VK_RETURN, bubbles: true }
|
||||
));
|
||||
await modifyPromise;
|
||||
// waitForItemEvent resolves during Notifier.commit, but UndoHistory's
|
||||
// commit callback runs after -- wait a tick for it to complete.
|
||||
await Zotero.Promise.delay(0);
|
||||
|
||||
assert.equal(item1.getField('publicationTitle'), 'Journal Alpha');
|
||||
assert.equal(item2.getField('publicationTitle'), 'Journal Alpha');
|
||||
assert.equal(item3.getField('publicationTitle'), 'Journal Alpha');
|
||||
assert.isTrue(Zotero.UndoHistory.canUndo(), "should be able to undo");
|
||||
|
||||
// Undo should revert all items
|
||||
await Zotero.UndoHistory.undo();
|
||||
assert.equal(item1.getField('publicationTitle'), 'Journal Alpha',
|
||||
"item1 should be unchanged (already had the selected value)");
|
||||
assert.equal(item2.getField('publicationTitle'), 'Journal Beta',
|
||||
"item2 should revert to original");
|
||||
assert.equal(item3.getField('publicationTitle'), 'Journal Gamma',
|
||||
"item3 should revert to original");
|
||||
|
||||
// Re-query since render() rebuilds the DOM
|
||||
pubTitleField = itemBox.querySelector('editable-text[fieldname="publicationTitle"]');
|
||||
assert.equal(pubTitleField.value, '', "field value should be empty after undo");
|
||||
assert.equal(pubTitleField.placeholder, Zotero.getString('item-pane-batch-editing-multiple-values-placeholder'), "field should show Multiple placeholder after undo");
|
||||
|
||||
// Redo should re-apply to all items
|
||||
await Zotero.UndoHistory.redo();
|
||||
assert.equal(item1.getField('publicationTitle'), 'Journal Alpha');
|
||||
assert.equal(item2.getField('publicationTitle'), 'Journal Alpha');
|
||||
assert.equal(item3.getField('publicationTitle'), 'Journal Alpha');
|
||||
});
|
||||
it("should transform title case for all items in batch edit mode", async function () {
|
||||
let titleCaseTitle = "The Great Gatsby";
|
||||
let sentenceCaseTitle = "to kill a mockingbird";
|
||||
|
|
@ -3144,6 +3220,66 @@ describe("Item pane", function () {
|
|||
assert.equal(item1.getField('title'), "The Great Gatsby", "item1 should remain in title case");
|
||||
assert.equal(item2.getField('title'), "To Kill a Mockingbird", "item2 should be transformed to title case");
|
||||
});
|
||||
|
||||
it("should show options button and transform case when primary item field is empty", async function () {
|
||||
// Item 1 has no seriesTitle, items 2 and 3 do
|
||||
let item1 = await createDataObject('item', { itemType: 'journalArticle' });
|
||||
await item1.saveTx();
|
||||
|
||||
let item2 = await createDataObject('item', { itemType: 'journalArticle' });
|
||||
item2.setField('seriesTitle', 'advances in neural information processing');
|
||||
await item2.saveTx();
|
||||
|
||||
let item3 = await createDataObject('item', { itemType: 'journalArticle' });
|
||||
item3.setField('seriesTitle', 'proceedings of the ACM conference');
|
||||
await item3.saveTx();
|
||||
|
||||
await ZoteroPane.selectItems([item1.id, item2.id, item3.id]);
|
||||
|
||||
let itemPane = win.ZoteroPane.itemPane;
|
||||
let itemDetails = ZoteroPane.itemPane._itemDetails;
|
||||
|
||||
let batchEditEnableBtn = doc.getElementById('batch-edit-prompt-enable');
|
||||
batchEditEnableBtn.click();
|
||||
await itemDetails._renderPromise;
|
||||
|
||||
let itemBox = itemPane.querySelector('#zotero-editpane-info-box');
|
||||
let optionsButton = itemBox.querySelector('#itembox-field-seriesTitle-options');
|
||||
assert.ok(optionsButton, "options button should exist for seriesTitle");
|
||||
assert.isFalse(optionsButton.hidden, "options button should be visible when extra items have values");
|
||||
|
||||
// Open the context menu via the options button
|
||||
let menuPromise = new Promise((resolve) => {
|
||||
let observer = new MutationObserver((mutations) => {
|
||||
for (let mutation of mutations) {
|
||||
for (let node of mutation.addedNodes) {
|
||||
if (node.tagName === 'menupopup') {
|
||||
observer.disconnect();
|
||||
resolve(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(itemBox.querySelector('#info-box > popupset'), { childList: true });
|
||||
});
|
||||
|
||||
optionsButton.click();
|
||||
let menupopup = await menuPromise;
|
||||
|
||||
let titleCaseMenuItem = Array.from(menupopup.querySelectorAll('menuitem'))
|
||||
.find(mi => mi.getAttribute('label') === Zotero.getString('zotero.item.textTransform.titlecase'));
|
||||
assert.ok(titleCaseMenuItem, "title case menu item should exist");
|
||||
|
||||
let modifyPromise = waitForItemEvent('modify');
|
||||
titleCaseMenuItem.click();
|
||||
await modifyPromise;
|
||||
|
||||
assert.equal(item1.getField('seriesTitle'), '', "item1 should remain empty");
|
||||
assert.equal(item2.getField('seriesTitle'), 'Advances in Neural Information Processing',
|
||||
"item2 should be transformed to title case");
|
||||
assert.equal(item3.getField('seriesTitle'), 'Proceedings of the ACM Conference',
|
||||
"item3 should be transformed to title case");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not focus read-only fields with multiple values", async function () {
|
||||
|
|
|
|||
1579
test/tests/undoHistoryTest.js
Normal file
1579
test/tests/undoHistoryTest.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -862,8 +862,31 @@ describe("ZoteroPane", function () {
|
|||
assert.isTrue(item.deleted);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
describe("#emptyTrash()", function () {
|
||||
it("should clear the undo/redo history", async function () {
|
||||
// Record an undo entry
|
||||
Zotero.UndoHistory.clear();
|
||||
var collection = await createDataObject('collection', { name: 'Original' });
|
||||
collection.name = 'Renamed';
|
||||
await collection.saveTx({ undoAction: 'undo-action-rename-collection' });
|
||||
assert.isTrue(Zotero.UndoHistory.canUndo());
|
||||
|
||||
// Put something in the trash to empty
|
||||
await createDataObject('item', { deleted: true });
|
||||
|
||||
await selectTrash(win);
|
||||
var promise = waitForDialog();
|
||||
await zp.emptyTrash();
|
||||
await promise;
|
||||
|
||||
assert.isFalse(Zotero.UndoHistory.canUndo());
|
||||
assert.isFalse(Zotero.UndoHistory.canRedo());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("#setVirtual()", function () {
|
||||
var cv;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue