Update conflict resolution for API syncing

This will appear much less frequently, since non-conflicting field changes on
both sides can be resolved automatically, but genuine field conflicts still
require manual conflict resolution.

The merge pane is no longer editable, since the itembox code to do that is
async and can't run in a modal window, but it's not really necessary,
particularly with conflicts happening less frequently.

TODO:

- Remote item deletions
- File conflicts
- Maybe handle some edge cases where the conflicted items fail to save
This commit is contained in:
Dan Stillman 2015-08-06 04:04:37 -04:00
parent 7075300a17
commit 0aecaad761
17 changed files with 839 additions and 550 deletions

View file

@ -82,19 +82,6 @@
this.blurHandler = this.hideEditor;
break;
case 'merge':
this.clickByItem = true;
break;
case 'mergeedit':
this.clickable = true;
this.editable = true;
this.saveOnEdit = false;
this.showTypeMenu = true;
this.clickHandler = this.showEditor;
this.blurHandler = this.hideEditor;
break;
case 'fieldmerge':
this.hideEmptyFields = true;
this._fieldAlternatives = {};
@ -1909,9 +1896,7 @@
if(field === 'creator') {
// Reset creator mode settings here so that flex attribute gets reset
this.switchCreatorMode(row, (otherFields.fieldMode ? 1 : 0), true);
Zotero.debug("HERE");
if(Zotero.ItemTypes.getName(this.item.itemTypeID) === "bookSection") {
Zotero.debug("YES");
var creatorTypeLabels = document.getAnonymousNodes(this)[0].getElementsByClassName("creator-type-label");
Zotero.debug(creatorTypeLabels[creatorTypeLabels.length-1] + "");
document.getElementById("zotero-author-guidance").show({

View file

@ -44,73 +44,36 @@
]]>
</constructor>
<field name="_type"/>
<property name="type" onget="return this._type;">
<field name="_data"/>
<property name="data" onget="return this._data;">
<setter>
<![CDATA[
this._type = val;
var hbox = document.getAnonymousNodes(this)[0];
hbox.setAttribute('mergetype', val);
]]>
</setter>
</property>
<property name="left" onget="return this._leftpane.ref;">
<setter>
<![CDATA[
// TODO: Make sure object is the correct type
if (val == 'deleted') {
this._leftpane.ref = 'deleted';
return;
}
// Check for note or attachment
if (val instanceof Zotero.Item) {
this.type = this._getTypeFromItem(val);
}
// Clone object so changes in merge pane don't affect it
this._leftpane.ref = val.copy();
this._leftpane.original = val;
]]>
</setter>
</property>
<property name="right" onget="return this._rightpane.ref;">
<setter>
<![CDATA[
// TODO: make sure left is set
if (!this._leftpane.ref) {
throw ("Left object must be set before setting <zoteromergegroup>.right");
}
if (val == 'deleted') {
this._rightpane.ref = 'deleted';
}
else {
// TODO: Make sure object is the correct type
// Check for note or attachment if not already set
if (this._leftpane.ref == 'deleted' && val instanceof Zotero.Item) {
this.type = this._getTypeFromItem(val);
}
// Clone object so changes in merge pane don't affect it
this._rightpane.ref = val.copy();
this._rightpane.original = val;
}
this._data = val;
this.refresh();
]]>
</setter>
</property>
<property name="merge" onget="return this._mergepane.ref">
<property name="merged" onget="return this._mergepane.data"/>
<field name="_type"/>
<property name="type" onget="return this._type;">
<setter>
<![CDATA[
// TODO: Make sure object is the correct type
this._mergepane.ref = val;
switch (val) {
case 'item':
case 'attachment':
case 'note':
case 'file':
break;
default:
throw new Exception(`Unsupported merge object type '${type}'`);
}
this._type = val;
var hbox = document.getAnonymousNodes(this)[0];
hbox.setAttribute('mergetype', val);
]]>
</setter>
</property>
@ -119,51 +82,43 @@
<property name="rightCaption" onset="this._rightpane.caption.label = val"/>
<property name="mergeCaption" onset="this._mergepane.caption.label = val"/>
<field name="_leftpane"/>
<property name="leftpane" onget="return this._leftpane"/>
<field name="_rightpane"/>
<property name="rightpane" onget="return this._rightpane"/>
<field name="_mergepane"/>
<property name="mergepane" onget="return this._mergepane"/>
<property name="onSelectionChange"/>
<field name="_leftpane"/>
<field name="_rightpane"/>
<field name="_mergepane"/>
<method name="refresh">
<body>
<![CDATA[
// Set merge pane to most recently changed object
// If one object was deleted, set merge pane to other
// TODO: use delete timestamp
if (this._leftpane.ref != 'deleted'
&& this._rightpane.ref != 'deleted') {
var dm1 = this._leftpane.ref.getField('dateModified');
if (dm1) {
dm1 = Zotero.Date.sqlToDate(dm1);
}
var dm2 = this._rightpane.ref.getField('dateModified');
if (dm2) {
dm2 = Zotero.Date.sqlToDate(dm2);
}
if (this._data.left.deleted && this._data.right.deleted) {
throw new Exception("'left' and 'right' cannot both be deleted");
}
if (this._leftpane.ref == 'deleted' || dm2 > dm1) {
var mergeItem = this._rightpane.original;
this._leftpane.removeAttribute("selected");
this._rightpane.setAttribute("selected", "true");
// Check for note or attachment
this.type = this._getTypeFromObject(
this._data.left.deleted ? this._data.right : this._data.left
);
var showButton = this.type != 'item';
this._leftpane.showButton = showButton;
this._rightpane.showButton = showButton;
this._leftpane.data = this._data.left;
this._rightpane.data = this._data.right;
this._mergepane.type = this.type;
this._mergepane.data = this._data.merge;
if (this._data.selected == 'left') {
this.choosePane(this._leftpane);
}
else {
var mergeItem = this._leftpane.original;
this._rightpane.removeAttribute("selected");
this._leftpane.setAttribute("selected", "true");
this.choosePane(this._rightpane);
}
this._mergepane.ref = mergeItem;
/*
Code to display only the different values -- not used
@ -224,22 +179,51 @@
</body>
</method>
<method name="_getTypeFromItem">
<method name="choosePane">
<parameter name="pane"/>
<body>
<![CDATA[
if (pane.getAttribute('anonid') == 'leftpane') {
var position = 'left';
var otherPane = this._rightpane;
}
else {
var position = 'right';
var otherPane = this._leftpane;
}
pane.removeAttribute("selected");
otherPane.removeAttribute("selected");
pane.setAttribute("selected", "true");
if (pane.deleted) {
this._mergepane.deleted = true;
}
else {
this._mergepane.data = pane.data;
}
if (this.onSelectionChange) {
this.onSelectionChange();
}
]]>
</body>
</method>
<method name="_getTypeFromObject">
<parameter name="obj"/>
<body>
<![CDATA[
if (!(obj instanceof Zotero.Item)) {
throw ("obj is not a Zotero.Item in merge.xml");
if (!obj.itemType) {
Zotero.debug(obj, 1);
throw new Error("obj is not item JSON");
}
if (obj.isAttachment()) {
return 'attachment';
}
else if (obj.isNote()) {
return 'note';
}
else {
return 'item';
if (obj.itemType == 'attachment' || obj.itemType == 'note') {
return obj.itemType;
}
return 'item';
]]>
</body>
</method>
@ -248,7 +232,7 @@
<parameter name="id"/>
<body>
<![CDATA[
return document.getAnonymousNodes(this)[0].getElementsByAttribute('id',id)[0];
return document.getAnonymousNodes(this)[0].getElementsByAttribute('anonid',id)[0];
]]>
</body>
</method>
@ -256,9 +240,9 @@
<content>
<xul:hbox id="merge-group" flex="1">
<xul:zoteromergepane id="leftpane" flex="1"/>
<xul:zoteromergepane id="rightpane" flex="1"/>
<xul:zoteromergepane id="mergepane" flex="1"/>
<xul:zoteromergepane anonid="leftpane" flex="1"/>
<xul:zoteromergepane anonid="rightpane" flex="1"/>
<xul:zoteromergepane anonid="mergepane" flex="1"/>
</xul:hbox>
</content>
</binding>
@ -273,11 +257,20 @@
<constructor>
<![CDATA[
this.parent = document.getBindingParent(this.parentNode);
this.isMergePane = this.getAttribute('anonid') == 'mergepane';
if (!this.isMergePane) {
this.pane.onclick = function () {
this.parent.choosePane(this);
}.bind(this);
}
]]>
</constructor>
<property name="type" onget="return this.parent.type" readonly="true"/>
<property name="caption" onget="return this._id('caption')" readonly="true"/>
<field name="showButton"/>
<property name="pane" onget="return document.getAnonymousNodes(this)[0]"/>
<property name="objectbox" onget="return this._id('objectbox')" readonly="true"/>
<field name="_deleted"/>
@ -291,7 +284,7 @@
placeholder.hidden = !!val;
}
else {
this._id('objectbox').hidden = !!true;
this._id('objectbox').hidden = true;
}
var deleteBox = this._id('delete-box');
deleteBox.hidden = !val;
@ -299,10 +292,13 @@
</setter>
</property>
<property name="ref" onget="return this._deleted ? 'deleted' : this.objectbox.ref;">
<field name="_data"/>
<property name="data" onget="return this._data">
<setter>
<![CDATA[
if (val == 'deleted') {
this._data = val;
if (val.deleted) {
this.deleted = true;
return;
}
@ -324,7 +320,7 @@
elementName = 'zoteronoteeditor';
break;
case 'storagefile':
case 'file':
elementName = 'zoterostoragefilebox';
break;
@ -344,149 +340,64 @@
oldObjBox.parentNode.replaceChild(objbox, oldObjBox);
}
objbox.setAttribute("id", "objectbox");
objbox.setAttribute("anonid", "objectbox");
objbox.setAttribute("flex", "1");
if (this.getAttribute('id') == 'mergepane') {
objbox.mode = 'mergeedit';
objbox.mode = 'view';
var button = this._id('choose-button');
if (this.showButton) {
button.label = Zotero.getString('sync.conflict.chooseThisVersion');
button.onclick = function () {
this.parent.choosePane(this);
}.bind(this);
button.hidden = false;
}
else {
objbox.mode = 'merge';
objbox.clickHandler = this.chooseObj;
button.hidden = true;
}
// Type-specific settings
switch (this.type) {
case 'attachment':
case 'note':
case 'storagefile':
objbox.buttonCaption = Zotero.getString('sync.conflict.chooseThisVersion');
break;
}
// Store JSON
this._data = val;
objbox.ref = val;
// Create item from JSON for metadata box
var item = new Zotero.Item(val.itemType);
item.fromJSON(val);
objbox.ref = item;
]]>
</setter>
</property>
<field name="original"/> <!-- original object -->
<field name="parent"/>
<method name="chooseObj">
<parameter name="obj"/>
<body>
<![CDATA[
var pane = Zotero.getAncestorByTagName(obj, 'zoteromergepane');
var mergegroup = document.getBindingParent(pane);
var mergepane = mergegroup.mergepane;
if (pane.getAttribute('id') == 'leftpane') {
var position = 'left';
var otherPane = mergegroup.rightpane;
}
else {
var position = 'right';
var otherPane = mergegroup.leftpane;
}
pane.removeAttribute("selected");
otherPane.removeAttribute("selected");
pane.setAttribute("selected", "true");
if (pane.ref == 'deleted') {
mergepane.deleted = true;
}
else {
mergepane.ref = pane.original;
}
if (mergegroup.onSelectionChange) {
mergegroup.onSelectionChange();
}
]]>
</body>
</method>
<!-- Unused -->
<method name="chooseField">
<parameter name="row"/>
<body>
<![CDATA[
// If used, has to be updated to handle original item
var fieldName = row.firstChild.getAttribute('fieldname');
// TODO: creator/date
var value = row.lastChild.firstChild.nodeValue
var mergegroup = document.getBindingParent(this.parentNode).parent;
var mergepane = mergegroup.mergepane;
var pane = document.getBindingParent(this.parentNode);
if (pane.getAttribute('id') == 'leftpane') {
var position = 'left';
var otherPane = mergegroup.rightpane;
}
else {
var position = 'right';
var otherPane = mergegroup.leftpane;
}
// Changing item type
if (fieldName == 'itemType') {
fieldName = 'itemTypeID';
value = row.lastChild.getAttribute('itemTypeID');
if (!mergepane.ref) {
mergepane.ref = new Zotero.Item(value);
}
else {
mergepane.objectbox.changeTypeTo(value, true);
}
pane.objectbox.clickableFields = [];
pane.objectbox.clickable = true;
var fieldIDs = Zotero.ItemFields.getItemTypeFields(value);
var fieldNames = ['itemType'];
for each(var field in fieldIDs) {
fieldNames.push(Zotero.ItemFields.getName(field));
}
otherPane.objectbox.clickableFields = fieldNames;
otherPane.objectbox.clickable = false;
pane.objectbox.refresh();
otherPane.objectbox.refresh();
}
// Changing another field
else {
mergepane.ref.setField(fieldName, value);
}
mergepane.objectbox.refresh();
]]>
</body>
<method name="click">
<body><![CDATA[
this.pane.click();
]]></body>
</method>
<method name="_id">
<parameter name="id"/>
<body>
<![CDATA[
if (!document.getAnonymousNodes(this)[0].getElementsByAttribute('id', id).length) {
return false;
}
return document.getAnonymousNodes(this)[0].getElementsByAttribute('id', id)[0];
var elems = document.getAnonymousNodes(this)[0].getElementsByAttribute('anonid', id);
return elems.length ? elems[0] : false;
]]>
</body>
</method>
</implementation>
<content>
<xul:groupbox id="merge-pane" flex="1">
<xul:caption id="caption"/>
<xul:box id="object-placeholder"/>
<xul:hbox id="delete-box" hidden="true" flex="1"
onclick="document.getBindingParent(this).chooseObj(this)">
<xul:label value="&zotero.merge.deleted;"/>
</xul:hbox>
</xul:groupbox>
<xul:vbox flex="1">
<xul:groupbox anonid="merge-pane" flex="1">
<xul:caption anonid="caption"/>
<xul:box anonid="object-placeholder"/>
<xul:hbox anonid="delete-box" hidden="true" flex="1">
<xul:label value="&zotero.merge.deleted;"/>
</xul:hbox>
</xul:groupbox>
<xul:button anonid="choose-button" hidden="true"/>
</xul:vbox>
</content>
</binding>
</bindings>

View file

@ -76,17 +76,6 @@
this.displayRelated = true;
break;
case 'merge':
this.displayButton = true;
break;
case 'mergeedit':
// Not currently implemented
// (editing works, but value isn't saved)
//this.editable = true;
this.keyDownHandler = this.handleKeyDown;
break;
default:
throw ("Invalid mode '" + val + "' in noteeditor.xml");
}
@ -111,24 +100,20 @@
<field name="_item"/>
<property name="item" onget="return this._item;">
<setter>
<![CDATA[
Zotero.spawn(function* () {
this._item = val;
// TODO: use clientDateModified instead
this._mtime = val.getField('dateModified');
var parentKey = this.item.parentKey;
if (parentKey) {
this.parentItem = Zotero.Items.getByLibraryAndKey(this.item.libraryID, parentKey);
}
this._id('links').item = this.item;
yield this.refresh();
}, this);
]]>
</setter>
<setter><![CDATA[
this._item = val;
// TODO: use clientDateModified instead
this._mtime = val.getField('dateModified');
var parentKey = this.item.parentKey;
if (parentKey) {
this.parentItem = Zotero.Items.getByLibraryAndKey(this.item.libraryID, parentKey);
}
this._id('links').item = this.item;
this.refresh();
]]></setter>
</property>
<property name="linksOnTop">
@ -187,61 +172,58 @@
<method name="refresh">
<body><![CDATA[
return Zotero.spawn(function* () {
Zotero.debug('Refreshing note editor');
var textbox = this._id('noteField');
var textboxReadOnly = this._id('noteFieldReadOnly');
var button = this._id('goButton');
if (this.editable) {
textbox.hidden = false;
textboxReadOnly.hidden = true;
}
else {
textbox.hidden = true;
textboxReadOnly.hidden = false;
textbox = textboxReadOnly;
}
//var scrollPos = textbox.inputField.scrollTop;
if (this.item) {
yield this.item.loadNote();
textbox.value = this.item.getNote();
}
else {
textbox.value = '';
}
//textbox.inputField.scrollTop = scrollPos;
this._id('linksbox').hidden = !(this.displayTags && this.displayRelated);
if (this.keyDownHandler) {
textbox.setAttribute('onkeydown',
'document.getBindingParent(this).handleKeyDown(event)');
}
else {
textbox.removeAttribute('onkeydown');
}
if (this.commandHandler) {
textbox.setAttribute('oncommand',
'document.getBindingParent(this).commandHandler()');
}
else {
textbox.removeAttribute('oncommand');
}
if (this.displayButton) {
button.label = this.buttonCaption;
button.hidden = false;
button.setAttribute('oncommand',
'document.getBindingParent(this).clickHandler(this)');
}
else {
button.hidden = true;
}
}, this);
Zotero.debug('Refreshing note editor');
var textbox = this._id('noteField');
var textboxReadOnly = this._id('noteFieldReadOnly');
var button = this._id('goButton');
if (this.editable) {
textbox.hidden = false;
textboxReadOnly.hidden = true;
}
else {
textbox.hidden = true;
textboxReadOnly.hidden = false;
textbox = textboxReadOnly;
}
//var scrollPos = textbox.inputField.scrollTop;
if (this.item) {
textbox.value = this.item.getNote();
}
else {
textbox.value = '';
}
//textbox.inputField.scrollTop = scrollPos;
this._id('linksbox').hidden = !(this.displayTags && this.displayRelated);
if (this.keyDownHandler) {
textbox.setAttribute('onkeydown',
'document.getBindingParent(this).handleKeyDown(event)');
}
else {
textbox.removeAttribute('onkeydown');
}
if (this.commandHandler) {
textbox.setAttribute('oncommand',
'document.getBindingParent(this).commandHandler()');
}
else {
textbox.removeAttribute('oncommand');
}
if (this.displayButton) {
button.label = this.buttonCaption;
button.hidden = false;
button.setAttribute('oncommand',
'document.getBindingParent(this).clickHandler(this)');
}
else {
button.hidden = true;
}
]]></body>
</method>

View file

@ -95,7 +95,8 @@
var r = "";
if (this.item) {
yield this.item.loadTags();
yield this.item.loadTags()
.tap(() => Zotero.Promise.check(this.mode));
var tags = this.item.getTags();
if (tags) {
for(var i = 0; i < tags.length; i++)
@ -210,7 +211,8 @@
return Zotero.spawn(function* () {
Zotero.debug('Reloading tags box');
yield this.item.loadTags();
yield this.item.loadTags()
.tap(() => Zotero.Promise.check(this.mode));
// Cancel field focusing while we're updating
this._reloading = true;
@ -218,6 +220,7 @@
this.id('addButton').hidden = !this.editable;
this._tagColors = yield Zotero.Tags.getColors(this.item.libraryID)
.tap(() => Zotero.Promise.check(this.mode));
var rows = this.id('tagRows');
while(rows.hasChildNodes()) {

View file

@ -24,24 +24,17 @@
*/
var Zotero_Merge_Window = new function () {
this.init = init;
this.onBack = onBack;
this.onNext = onNext;
this.onFinish = onFinish;
this.onCancel = onCancel;
var _wizard = null;
var _wizardPage = null;
var _mergeGroup = null;
var _numObjects = null;
var _initialized = false;
var _io = null;
var _objects = null;
var _conflicts = null;
var _merged = [];
var _pos = -1;
function init() {
this.init = function () {
_wizard = document.getElementsByTagName('wizard')[0];
_wizardPage = document.getElementsByTagName('wizardpage')[0];
_mergeGroup = document.getElementsByTagName('zoteromergegroup')[0];
@ -54,69 +47,42 @@ var Zotero_Merge_Window = new function () {
_wizard.getButton('cancel').setAttribute('label', Zotero.getString('sync.cancel'));
_io = window.arguments[0];
_objects = _io.dataIn.objects;
if (!_objects.length) {
// TODO: handle no objects
_io = window.arguments[0].wrappedJSObject;
_conflicts = _io.dataIn.conflicts;
if (!_conflicts.length) {
// TODO: handle no conflicts
return;
}
_mergeGroup.type = _io.dataIn.type;
_mergeGroup.onSelectionChange = _updateResolveAllCheckbox;
switch (_mergeGroup.type) {
case 'item':
case 'storagefile':
break;
default:
_error("Unsupported merge object type '" + _mergeGroup.type
+ "' in Zotero_Merge_Window.init()");
return;
}
_mergeGroup.leftCaption = _io.dataIn.captions[0];
_mergeGroup.rightCaption = _io.dataIn.captions[1];
_mergeGroup.mergeCaption = _io.dataIn.captions[2];
_resolveAllCheckbox = document.getElementById('resolve-all');
if (_conflicts.length == 1) {
_resolveAllCheckbox.hidden = true;
}
else {
_mergeGroup.onSelectionChange = _updateResolveAllCheckbox;
}
_numObjects = document.getElementById('zotero-merge-num-objects');
document.getElementById('zotero-merge-total-objects').value = _objects.length;
document.getElementById('zotero-merge-total-objects').value = _conflicts.length;
this.onNext();
}
function onBack() {
this.onBack = function () {
_merged[_pos] = _getCurrentMergeInfo();
_pos--;
if (_pos == 0) {
_wizard.canRewind = false;
}
_merged[_pos + 1] = _getCurrentMergeObject();
_numObjects.value = _pos + 1;
_mergeGroup.left = _objects[_pos][0];
_mergeGroup.right = _objects[_pos][1];
// Restore previously merged object into merge pane
_mergeGroup.merge = _merged[_pos].ref;
if (_merged[_pos].id == _mergeGroup.left.id) {
_mergeGroup.leftpane.setAttribute("selected", "true");
_mergeGroup.rightpane.removeAttribute("selected");
}
else {
_mergeGroup.leftpane.removeAttribute("selected");
_mergeGroup.rightpane.setAttribute("selected", "true");
}
_updateResolveAllCheckbox();
if (_mergeGroup.type == 'item') {
_updateChangedCreators();
}
_updateGroup();
var nextButton = _wizard.getButton("next");
@ -134,36 +100,26 @@ var Zotero_Merge_Window = new function () {
}
function onNext() {
if (_pos + 1 == _objects.length || _resolveAllCheckbox.checked) {
this.onNext = function () {
// At end or resolving all
if (_pos + 1 == _conflicts.length || _resolveAllCheckbox.checked) {
return true;
}
// First page
if (_pos == -1) {
_wizard.canRewind = false;
}
// Subsequent pages
else {
_wizard.canRewind = true;
_merged[_pos] = _getCurrentMergeInfo();
}
_pos++;
if (_pos == 0) {
_wizard.canRewind = false;
}
else {
_wizard.canRewind = true;
// Save merged object to return array
_merged[_pos - 1] = _getCurrentMergeObject();
}
// Adjust counter
_numObjects.value = _pos + 1;
try {
_mergeGroup.left = _objects[_pos][0];
_mergeGroup.right = _objects[_pos][1];
// Restore previously merged object into merge pane
if (_merged[_pos]) {
_mergeGroup.merge = _merged[_pos].ref;
_mergeGroup.leftpane.removeAttribute("selected");
_mergeGroup.rightpane.removeAttribute("selected");
}
_updateGroup();
}
catch (e) {
_error(e);
@ -172,10 +128,6 @@ var Zotero_Merge_Window = new function () {
_updateResolveAllCheckbox();
if (_mergeGroup.type == 'item') {
_updateChangedCreators();
}
if (_isLastConflict()) {
_showFinishButton();
}
@ -187,28 +139,40 @@ var Zotero_Merge_Window = new function () {
}
function onFinish() {
this.onFinish = function () {
// If using one side for all remaining, update merge object
if (!_isLastConflict() && _resolveAllCheckbox.checked) {
let useRemote = _mergeGroup.rightpane.getAttribute("selected") == "true";
for (let i = _pos; i < _objects.length; i++) {
_merged[i] = _getMergeObject(
_objects[i][useRemote ? 1 : 0],
_objects[i][0],
_objects[i][1]
);
let side = _mergeGroup.rightpane.getAttribute("selected") == "true" ? 'right' : 'left'
for (let i = _pos; i < _conflicts.length; i++) {
_merged[i] = {
data: _getMergeDataWithSide(i, side),
selected: side
};
}
}
else {
_merged[_pos] = _getCurrentMergeObject();
_merged[_pos] = _getCurrentMergeInfo();
}
_merged.forEach(function (x, i, a) {
// Add key
x.data.key = _conflicts[i].left.key || _conflicts[i].right.key;
// If selecting right item, add back version
if (x.data && x.selected == 'right') {
x.data.version = _conflicts[i].right.version;
}
else {
delete x.data.version;
}
a[i] = x.data;
})
_io.dataOut = _merged;
return true;
}
function onCancel() {
this.onCancel = function () {
// if already merged, ask
}
@ -222,19 +186,117 @@ var Zotero_Merge_Window = new function () {
}
}
function _updateGroup() {
// Adjust counter
_numObjects.value = _pos + 1;
let data = {};
Object.assign(data, _conflicts[_pos]);
var mergeInfo = _getMergeInfo(_pos);
data.merge = mergeInfo.data;
data.selected = mergeInfo.selected;
_mergeGroup.data = data;
_updateResolveAllCheckbox();
}
function _getCurrentMergeInfo() {
return {
data: _mergeGroup.merged,
selected: _mergeGroup.leftpane.getAttribute("selected") == "true" ? "left" : "right"
};
}
/**
* Get the default or previously chosen merge info for a given position
*
* @param {Integer} pos
* @return {Object} - Object with 'data' (JSON field data) and 'selected' ('left', 'right') properties
*/
function _getMergeInfo(pos) {
// If data already selected, use that
if (_merged[pos]) {
return _merged[pos];
}
// If either side was deleted, use other side
if (_conflicts[pos].left.deleted) {
let mergeInfo = {
data: {},
selected: 'right'
};
Object.assign(mergeInfo.data, _conflicts[pos].right);
return mergeInfo;
}
if (_conflicts[pos].right.deleted) {
let mergeInfo = {
data: {},
selected: 'left'
};
Object.assign(mergeInfo.data, _conflicts[pos].left);
return mergeInfo;
}
// Apply changes from each side and pick most recent version for conflicting fields
var mergeInfo = {
data: {}
};
Object.assign(mergeInfo.data, _conflicts[pos].left)
Zotero.DataObjectUtilities.applyChanges(mergeInfo.data, _conflicts[pos].changes);
if (_conflicts[pos].left.dateModified > _conflicts[pos].right.dateModified) {
var side = 0;
}
// Use remote if remote Date Modified is later or same
else {
var side = 1;
}
Zotero.DataObjectUtilities.applyChanges(mergeInfo.data, _conflicts[pos].conflicts.map(x => x[side]));
mergeInfo.selected = side ? 'right' : 'left';
return mergeInfo;
}
/**
* Get the merge data using a given side at a given position
*
* @param {Integer} pos
* @param {String} side - 'left' or 'right'
* @return {Object} - JSON field data
*/
function _getMergeDataWithSide(pos, side) {
if (!side) {
throw new Error("Side not provided");
}
if (_conflicts[pos].left.deleted || _conflicts[pos].right.deleted) {
return _conflicts[pos][side];
}
var data = {};
Object.assign(data, _conflicts[pos].left)
Zotero.DataObjectUtilities.applyChanges(data, _conflicts[pos].changes);
Zotero.DataObjectUtilities.applyChanges(
data, _conflicts[pos].conflicts.map(x => x[side == 'left' ? 0 : 1])
);
return data;
}
function _updateResolveAllCheckbox() {
if (_mergeGroup.rightpane.getAttribute("selected") == 'true') {
var label = 'sync.merge.resolveAllRemote';
var label = 'resolveAllRemoteFields';
}
else {
var label = 'sync.merge.resolveAllLocal';
var label = 'resolveAllLocalFields';
}
_resolveAllCheckbox.label = Zotero.getString(label);
// TODO: files
_resolveAllCheckbox.label = Zotero.getString('sync.conflict.' + label);
}
function _isLastConflict() {
return (_pos + 1) == _objects.length;
return (_pos + 1) == _conflicts.length;
}
@ -274,70 +336,18 @@ var Zotero_Merge_Window = new function () {
}
function _getMergeObject(ref, left, right) {
var id = ref == 'deleted'
? (left == 'deleted' ? right.id : left.id)
: ref.id;
return {
id: id,
ref: ref,
left: left,
right: right
};
}
function _getCurrentMergeObject() {
return _getMergeObject(_mergeGroup.merge, _mergeGroup.left, _mergeGroup.right);
}
// Hack to support creator reconciliation via item view
function _updateChangedCreators() {
if (_mergeGroup.type != 'item') {
_error("_updateChangedCreators called on non-item object in "
+ "Zotero_Merge_Window._updateChangedCreators()");
return;
}
if (_io.dataIn.changedCreators) {
var originalCreators = _mergeGroup.rightpane.original.getCreators();
var clonedCreators = _mergeGroup.rightpane.ref.getCreators();
var refresh = false;
for (var i in originalCreators) {
var changedCreator = _io.dataIn.changedCreators[Zotero.Creators.getLibraryKeyHash(originalCreators[i].ref)];
if (changedCreator) {
_mergeGroup.rightpane.original.setCreator(
i, changedCreator, originalCreators[i].creatorTypeID
);
clonedCreators[i].ref = changedCreator;
refresh = true;
}
}
if (refresh) {
_mergeGroup.rightpane.objectbox.refresh();
_mergeGroup.mergepane.objectbox.refresh();
}
}
}
// TEMP
function _setInstructionsString(buttonName) {
switch (_mergeGroup.type) {
case 'storagefile':
var msg = Zotero.getString('sync.conflict.fileChanged');
case 'file':
var msg = 'fileChanged';
break;
default:
// TODO: cf. localization: maybe not always call it 'item'
var msg = Zotero.getString('sync.conflict.itemChanged');
// TODO: maybe don't always call it 'item'
var msg = 'itemChanged';
}
msg += " " + Zotero.getString('sync.conflict.chooseVersionToKeep', buttonName);
msg = Zotero.getString('sync.conflict.' + msg, buttonName)
document.getElementById('zotero-merge-instructions').value = msg;
}

View file

@ -27,13 +27,6 @@ var noteEditor;
var notifierUnregisterID;
function onLoad() {
Zotero.spawn(function* () {
Zotero.debug('=-=-=');
var bar = yield Zotero.Promise.delay(1000).return('DONE');
Zotero.debug(bar);
Zotero.debug('-----');
});
noteEditor = document.getElementById('zotero-note-editor');
noteEditor.mode = 'edit';
noteEditor.focus();

View file

@ -631,7 +631,7 @@ Zotero.Item.prototype.setField = function(field, value, loadIn) {
this._disabledCheck();
if (value === undefined) {
throw new Error("Value cannot be undefined");
throw new Error(`'${field}' value cannot be undefined`);
}
if (typeof value == 'string') {

View file

@ -2209,17 +2209,18 @@ Zotero.Schema = new function(){
yield Zotero.DB.queryAsync("UPDATE syncDeleteLog SET libraryID=1 WHERE libraryID=0");
yield Zotero.DB.queryAsync("ALTER TABLE syncDeleteLog RENAME TO syncDeleteLogOld");
yield Zotero.DB.queryAsync("CREATE TABLE syncDeleteLog (\n syncObjectTypeID INT NOT NULL,\n libraryID INT NOT NULL,\n key TEXT NOT NULL,\n synced INT NOT NULL DEFAULT 0,\n UNIQUE (syncObjectTypeID, libraryID, key),\n FOREIGN KEY (syncObjectTypeID) REFERENCES syncObjectTypes(syncObjectTypeID),\n FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE\n)");
yield Zotero.DB.queryAsync("INSERT OR IGNORE INTO syncDeleteLog SELECT * FROM syncDeleteLogOld");
yield Zotero.DB.queryAsync("CREATE TABLE syncDeleteLog (\n syncObjectTypeID INT NOT NULL,\n libraryID INT NOT NULL,\n key TEXT NOT NULL,\n dateDeleted TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,\n synced INT NOT NULL DEFAULT 0,\n UNIQUE (syncObjectTypeID, libraryID, key),\n FOREIGN KEY (syncObjectTypeID) REFERENCES syncObjectTypes(syncObjectTypeID),\n FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE\n)");
yield Zotero.DB.queryAsync("INSERT OR IGNORE INTO syncDeleteLog SELECT syncObjectTypeID, libraryID, key, timestamp, 0 FROM syncDeleteLogOld");
yield Zotero.DB.queryAsync("DROP INDEX IF EXISTS syncDeleteLog_timestamp");
yield Zotero.DB.queryAsync("CREATE INDEX syncDeleteLog_synced ON syncDeleteLog(synced)");
// TODO: Something special for tag deletions?
//yield Zotero.DB.queryAsync("DELETE FROM syncDeleteLog WHERE syncObjectTypeID IN (2, 5, 6)");
//yield Zotero.DB.queryAsync("DELETE FROM syncObjectTypes WHERE syncObjectTypeID IN (2, 5, 6)");
yield Zotero.DB.queryAsync("UPDATE storageDeleteLog SET libraryID=1 WHERE libraryID=0");
yield Zotero.DB.queryAsync("ALTER TABLE storageDeleteLog RENAME TO storageDeleteLogOld");
yield Zotero.DB.queryAsync("CREATE TABLE storageDeleteLog (\n libraryID INT NOT NULL,\n key TEXT NOT NULL,\n synced INT NOT NULL DEFAULT 0,\n PRIMARY KEY (libraryID, key),\n FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE\n)");
yield Zotero.DB.queryAsync("INSERT OR IGNORE INTO storageDeleteLog SELECT * FROM storageDeleteLogOld");
yield Zotero.DB.queryAsync("CREATE TABLE storageDeleteLog (\n libraryID INT NOT NULL,\n key TEXT NOT NULL,\n dateDeleted TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,\n synced INT NOT NULL DEFAULT 0,\n PRIMARY KEY (libraryID, key),\n FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE\n)");
yield Zotero.DB.queryAsync("INSERT OR IGNORE INTO storageDeleteLog SELECT libraryID, key, timestamp, 0 FROM storageDeleteLogOld");
yield Zotero.DB.queryAsync("DROP INDEX IF EXISTS storageDeleteLog_timestamp");
yield Zotero.DB.queryAsync("CREATE INDEX storageDeleteLog_synced ON storageDeleteLog(synced)");

View file

@ -37,7 +37,8 @@ Zotero.Sync.EventListeners.ChangeListener = new function () {
return;
}
var syncSQL = "REPLACE INTO syncDeleteLog VALUES (?, ?, ?, 0)";
var syncSQL = "REPLACE INTO syncDeleteLog (syncObjectTypeID, libraryID, key, synced) "
+ "VALUES (?, ?, ?, 0)";
if (type == 'item' && Zotero.Sync.Storage.WebDAV.includeUserFiles) {
var storageSQL = "REPLACE INTO storageDeleteLog VALUES (?, ?, 0)";

View file

@ -222,6 +222,7 @@ Zotero.Sync.Data.Local = {
Zotero.debug("Processing " + objectTypePlural + " in sync cache for " + libraryName);
var conflicts = [];
var numSaved = 0;
var numSkipped = 0;
@ -340,18 +341,6 @@ Zotero.Sync.Data.Local = {
continue;
}
// Ignore conflicts from Quick Start Guide, and just use remote version
/*if (objectType == 'item'
&& jsonDataLocal.key == "ABCD2345"
&& jsonDataLocal.url.indexOf('quick_start_guide') != -1
&& jsonData.url.indexOf('quick_start_guide') != -1) {
Zotero.debug("Ignoring conflict for item '" + jsonData.title + "' "
+ "-- using remote version");
let saved = yield this._saveObjectFromJSON(obj, jsonData, options);
if (saved) numSaved++;
continue;
}*/
// If no conflicts, apply remote changes automatically
if (!result.conflicts.length) {
Zotero.DataObjectUtilities.applyChanges(
@ -362,21 +351,17 @@ Zotero.Sync.Data.Local = {
continue;
}
Zotero.debug('======DIFF========');
Zotero.debug(cachedJSON);
Zotero.debug(jsonDataLocal);
Zotero.debug(jsonData);
Zotero.debug(result);
throw new Error("Conflict");
if (objectType != 'item') {
throw new Error(`Unexpected conflict on ${objectType} object`);
}
// TODO
// reconcile changes automatically if we can
// if we can't:
// if it's a search or collection, use most recent version
// if it's an item,
conflicts.push({
left: jsonDataLocal,
right: jsonData,
changes: result.changes,
conflicts: result.conflicts
});
continue;
}
let saved = yield this._saveObjectFromJSON(obj, jsonData, options);
@ -387,11 +372,20 @@ Zotero.Sync.Data.Local = {
isNewObject = true;
// Check if object has been deleted locally
if (yield this.objectInDeleteLog(objectType, libraryID, objectKey)) {
let dateDeleted = yield this.getDateDeleted(
objectType, libraryID, objectKey
);
if (dateDeleted) {
switch (objectType) {
case 'item':
throw new Error("Unimplemented");
break;
conflicts.push({
left: {
deleted: true,
dateDeleted: Zotero.Date.dateToSQL(dateDeleted, true)
},
right: jsonData
});
continue;
// Auto-restore some locally deleted objects that have changed remotely
case 'collection':
@ -433,6 +427,63 @@ Zotero.Sync.Data.Local = {
yield this.processSyncCacheForObjectType(libraryID, objectType, options);
}
if (conflicts.length) {
conflicts.sort(function (a, b) {
var d1 = a.left.dateDeleted || a.left.dateModified;
var d2 = b.left.dateDeleted || b.left.dateModified;
if (d1 > d2) {
return 1
}
if (d1 < d2) {
return -1;
}
return 0;
})
var mergeData = this.resolveConflicts(conflicts);
if (mergeData) {
let mergeOptions = {};
Object.assign(mergeOptions, options);
// Tell _saveObjectFromJSON not to save with 'synced' set to true
mergeOptions.saveAsChanged = true;
let concurrentObjects = 50;
yield Zotero.Utilities.Internal.forEachChunkAsync(
mergeData,
concurrentObjects,
function (chunk) {
return Zotero.DB.executeTransaction(function* () {
for (let json of chunk) {
let obj = yield objectsClass.getByLibraryAndKeyAsync(
libraryID, json.key, { noCache: true }
);
// Update object with merge data
if (obj) {
if (json.deleted) {
yield obj.erase();
}
else {
yield this._saveObjectFromJSON(obj, json, mergeOptions);
}
}
// Recreate deleted object
else if (!json.deleted) {
obj = new Zotero[ObjectType];
obj.libraryID = libraryID;
obj.key = json.key;
yield obj.loadPrimaryData();
let saved = yield this._saveObjectFromJSON(obj, json, options, {
// Don't cache new items immediately, which skips reloading after save
skipCache: true
});
}
}
}.bind(this));
}.bind(this)
);
}
}
data = yield this._getUnwrittenData(libraryID, objectType);
Zotero.debug("Skipping " + data.length + " "
+ (data.length == 1 ? objectType : objectTypePlural)
@ -441,6 +492,41 @@ Zotero.Sync.Data.Local = {
}),
resolveConflicts: function (conflicts) {
var io = {
dataIn: {
captions: [
Zotero.getString('sync.conflict.localItem'),
Zotero.getString('sync.conflict.remoteItem'),
Zotero.getString('sync.conflict.mergedItem')
],
conflicts
}
};
var url = 'chrome://zotero/content/merge.xul';
var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
.getService(Components.interfaces.nsIWindowMediator);
var lastWin = wm.getMostRecentWindow("navigator:browser");
if (lastWin) {
lastWin.openDialog(url, '', 'chrome,modal,centerscreen', io);
}
else {
// When using nsIWindowWatcher, the object has to be wrapped here
// https://developer.mozilla.org/en-US/docs/Working_with_windows_in_chrome_code#Example_5_Using_nsIWindowWatcher_for_passing_an_arbritrary_JavaScript_object
io.wrappedJSObject = io;
let ww = Components.classes["@mozilla.org/embedcomp/window-watcher;1"]
.getService(Components.interfaces.nsIWindowWatcher);
ww.openWindow(null, url, '', 'chrome,modal,centerscreen,dialog', io);
}
if (io.error) {
throw io.error;
}
return io.dataOut;
},
//
// Classic sync
//
@ -460,8 +546,10 @@ Zotero.Sync.Data.Local = {
_saveObjectFromJSON: Zotero.Promise.coroutine(function* (obj, json, options) {
try {
yield obj.fromJSON(json);
obj.version = json.version;
obj.synced = true;
if (!options.saveAsChanged) {
obj.version = json.version;
obj.synced = true;
}
Zotero.debug("SAVING " + json.key + " WITH SYNCED");
Zotero.debug(obj.version);
yield obj.save({
@ -699,14 +787,14 @@ Zotero.Sync.Data.Local = {
/**
* @return {Promise<Boolean>}
* @return {Promise<Date|false>}
*/
objectInDeleteLog: Zotero.Promise.coroutine(function* (objectType, libraryID, key) {
getDateDeleted: Zotero.Promise.coroutine(function* (objectType, libraryID, key) {
var syncObjectTypeID = Zotero.Sync.Data.Utilities.getSyncObjectTypeID(objectType);
var sql = "SELECT COUNT(*) FROM syncDeleteLog WHERE libraryID=? AND key=? "
var sql = "SELECT dateDeleted FROM syncDeleteLog WHERE libraryID=? AND key=? "
+ "AND syncObjectTypeID=?";
var count = yield Zotero.DB.valueQueryAsync(sql, [libraryID, key, syncObjectTypeID]);
return !!count;
var date = yield Zotero.DB.valueQueryAsync(sql, [libraryID, key, syncObjectTypeID]);
return date ? Zotero.Date.sqlToDate(date, true) : false;
}),

View file

@ -789,12 +789,6 @@ sync.cancel = Cancel Sync
sync.openSyncPreferences = Open Sync Preferences
sync.resetGroupAndSync = Reset Group and Sync
sync.removeGroupsAndSync = Remove Groups and Sync
sync.localObject = Local Object
sync.remoteObject = Remote Object
sync.mergedObject = Merged Object
sync.merge.resolveAllLocal = Use the local version for all remaining conflicts
sync.merge.resolveAllRemote = Use the remote version for all remaining conflicts
sync.error.usernameNotSet = Username not set
sync.error.usernameNotSet.text = You must enter your zotero.org username and password in the Zotero preferences to sync with the Zotero server.
@ -845,9 +839,17 @@ sync.conflict.tagItemMerge.log = The Zotero tag '%S' has been added to and/or
sync.conflict.tag.addedToRemote = It has been added to the following remote items:
sync.conflict.tag.addedToLocal = It has been added to the following local items:
sync.conflict.fileChanged = The following file has been changed in multiple locations.
sync.conflict.itemChanged = The following item has been changed in multiple locations.
sync.conflict.chooseVersionToKeep = Choose the version you would like to keep, and then click %S.
sync.conflict.localItem = Local Item
sync.conflict.remoteItem = Remote Item
sync.conflict.mergedItem = Merged Item
sync.conflict.localFile = Local File
sync.conflict.remoteFile = Remote File
sync.conflict.resolveAllLocal = Use the local version for all remaining conflicts
sync.conflict.resolveAllRemote = Use the remote version for all remaining conflicts
sync.conflict.resolveAllLocalFields = Use local fields for all remaining conflicts
sync.conflict.resolveAllRemoteFields = Use remote fields for all remaining conflicts
sync.conflict.itemChanged = The following item has been changed in multiple locations. Click the version to use for resolving conflicting fields, and then click %S.
sync.conflict.fileChanged = The following file has been changed in multiple locations. Choose the version you would like to keep, and then click %S.
sync.conflict.chooseThisVersion = Choose this version
sync.status.notYetSynced = Not yet synced

View file

@ -68,11 +68,10 @@ zoteromergegroup {
overflow-y: auto;
}
zoteromergepane #trash-box, zoteromergepane #delete-box {
zoteromergepane *[anonid="delete-box"] {
min-width: 15em;
-moz-box-align: center;
-moz-box-pack: center;
font-weight: bold;
}
zoteromergepane[selected=true] groupbox caption {
@ -80,11 +79,6 @@ zoteromergepane[selected=true] groupbox caption {
font-weight: bold;
}
zoteromergepane[id=leftpane]:not([selected=true]):hover groupbox caption,
zoteromergepane[id=rightpane]:not([selected=true]):hover groupbox caption {
/* font-weight: bold; */
}
hbox:not([mergetype=note]) zoteromergepane:active[id=leftpane] groupbox caption,
hbox:not([mergetype=note]) zoteromergepane:active[id=rightpane] groupbox caption {
color: red;

View file

@ -309,6 +309,7 @@ CREATE TABLE syncDeleteLog (
syncObjectTypeID INT NOT NULL,
libraryID INT NOT NULL,
key TEXT NOT NULL,
dateDeleted TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
synced INT NOT NULL DEFAULT 0,
UNIQUE (syncObjectTypeID, libraryID, key),
FOREIGN KEY (syncObjectTypeID) REFERENCES syncObjectTypes(syncObjectTypeID),
@ -319,6 +320,7 @@ CREATE INDEX syncDeleteLog_synced ON syncDeleteLog(synced);
CREATE TABLE storageDeleteLog (
libraryID INT NOT NULL,
key TEXT NOT NULL,
dateDeleted TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
synced INT NOT NULL DEFAULT 0,
PRIMARY KEY (libraryID, key),
FOREIGN KEY (libraryID) REFERENCES libraries(libraryID) ON DELETE CASCADE

View file

@ -86,6 +86,8 @@ function waitForWindow(uri, callback) {
}
}
catch (e) {
Zotero.logError(e);
win.close();
deferred.reject(e);
return;
}
@ -113,7 +115,7 @@ function waitForWindow(uri, callback) {
* @return {Promise}
*/
function waitForDialog(onOpen, button='accept', url) {
return waitForWindow(url || "chrome://global/content/commonDialog.xul", Zotero.Promise.method(function (dialog, deferred) {
return waitForWindow(url || "chrome://global/content/commonDialog.xul", Zotero.Promise.method(function (dialog) {
var failure = false;
if (onOpen) {
try {
@ -272,6 +274,8 @@ var createGroup = Zotero.Promise.coroutine(function* (props) {
* @param {String} [params.parentKey]
* @param {Boolean} [params.synced]
* @param {Integer} [params.version]
* @param {Integer} [params.dateAdded] - Allowed for items
* @param {Integer} [params.dateModified] - Allowed for items
*/
function createUnsavedDataObject(objectType, params = {}) {
if (!objectType) {
@ -298,6 +302,9 @@ function createUnsavedDataObject(objectType, params = {}) {
break;
}
var allowedParams = ['parentID', 'parentKey', 'synced', 'version'];
if (objectType == 'item') {
allowedParams.push('dateAdded', 'dateModified');
}
allowedParams.forEach(function (param) {
if (params[param] !== undefined) {
obj[param] = params[param];
@ -316,7 +323,7 @@ function getNameProperty(objectType) {
return objectType == 'item' ? 'title' : 'name';
}
var modifyDataObject = Zotero.Promise.coroutine(function* (obj, params = {}) {
var modifyDataObject = Zotero.Promise.coroutine(function* (obj, params = {}, saveOptions) {
switch (obj.objectType) {
case 'item':
yield obj.loadItemData();
@ -329,7 +336,7 @@ var modifyDataObject = Zotero.Promise.coroutine(function* (obj, params = {}) {
default:
obj.name = params.name !== undefined ? params.name : Zotero.Utilities.randomString();
}
return obj.saveTx();
return obj.saveTx(saveOptions);
});
/**

View file

@ -110,7 +110,7 @@ describe("Zotero.Item", function () {
it("should throw if value is undefined", function () {
var item = new Zotero.Item('book');
assert.throws(() => item.setField('title'), "Value cannot be undefined");
assert.throws(() => item.setField('title'), "'title' value cannot be undefined");
})
it("should not mark an empty field set to an empty string as changed", function () {

View file

@ -661,16 +661,16 @@ describe("Zotero.Sync.Data.Engine", function () {
assert.isFalse(Zotero.Items.exists(itemID));
// Make sure objects weren't added to sync delete log
assert.isFalse(yield Zotero.Sync.Data.Local.objectInDeleteLog(
assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
'setting', userLibraryID, 'tagColors'
));
assert.isFalse(yield Zotero.Sync.Data.Local.objectInDeleteLog(
assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
'collection', userLibraryID, collectionKey
));
assert.isFalse(yield Zotero.Sync.Data.Local.objectInDeleteLog(
assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
'search', userLibraryID, searchKey
));
assert.isFalse(yield Zotero.Sync.Data.Local.objectInDeleteLog(
assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
'item', userLibraryID, itemKey
));
})
@ -984,7 +984,7 @@ describe("Zotero.Sync.Data.Engine", function () {
// JSON objects 3 should be deleted and not in the delete log
assert.isFalse(objectsClass.getByLibraryAndKey(userLibraryID, objects[type][2].key));
assert.isFalse(yield Zotero.Sync.Data.Local.objectInDeleteLog(
assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
type, userLibraryID, objects[type][2].key
));
}

View file

@ -31,6 +31,316 @@ describe("Zotero.Sync.Data.Local", function() {
})
})
describe("Conflict Resolution", function () {
beforeEach(function* () {
yield Zotero.DB.queryAsync("DELETE FROM syncCache");
})
after(function* () {
yield Zotero.DB.queryAsync("DELETE FROM syncCache");
})
it("should show conflict resolution window on item conflicts", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var objects = [];
var values = [];
var dateAdded = Date.now() - 86400000;
for (let i = 0; i < 2; i++) {
values.push({
left: {},
right: {}
});
// Create object in cache
let obj = objects[i] = yield createDataObject(
type,
{
version: 10,
dateAdded: Zotero.Date.dateToSQL(new Date(dateAdded), true),
// Set Date Modified values one minute apart to enforce order
dateModified: Zotero.Date.dateToSQL(
new Date(dateAdded + (i * 60000)), true
)
}
);
let jsonData = yield obj.toJSON();
jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create new version in cache, simulating a download
json.version = jsonData.version = 15;
values[i].right.title = jsonData.title = Zotero.Utilities.randomString();
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Modify object locally
yield modifyDataObject(obj, undefined, { skipDateModifiedUpdate: true });
values[i].left.title = obj.getField('title');
}
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// 1 (remote)
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
wizard.getButton('next').click();
// 2 (local)
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
// Select local object
mergeGroup.leftpane.click();
assert.equal(mergeGroup.leftpane.getAttribute('selected'), 'true');
assert.isTrue(wizard.getButton('next').hidden);
assert.isFalse(wizard.getButton('finish').hidden);
wizard.getButton('finish').click();
})
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
libraryID, type, { stopOnError: true }
);
assert.equal(objects[0].getField('title'), values[0].right.title);
assert.equal(objects[1].getField('title'), values[1].left.title);
})
it("should resolve all remaining conflicts with one side", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var objects = [];
var values = [];
var dateAdded = Date.now() - 86400000;
for (let i = 0; i < 3; i++) {
values.push({
left: {},
right: {}
});
// Create object in cache
let obj = objects[i] = yield createDataObject(
type,
{
version: 10,
dateAdded: Zotero.Date.dateToSQL(new Date(dateAdded), true),
// Set Date Modified values one minute apart to enforce order
dateModified: Zotero.Date.dateToSQL(
new Date(dateAdded + (i * 60000)), true
)
}
);
let jsonData = yield obj.toJSON();
jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create new version in cache, simulating a download
json.version = jsonData.version = 15;
values[i].right.title = jsonData.title = Zotero.Utilities.randomString();
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Modify object locally
yield modifyDataObject(obj, undefined, { skipDateModifiedUpdate: true });
values[i].left.title = obj.getField('title');
}
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
var resolveAll = doc.getElementById('resolve-all');
// 1 (remote)
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
assert.equal(
resolveAll.label,
Zotero.getString('sync.conflict.resolveAllRemoteFields')
);
wizard.getButton('next').click();
// 2 (local and Resolve All checkbox)
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
mergeGroup.leftpane.click();
assert.equal(
resolveAll.label,
Zotero.getString('sync.conflict.resolveAllLocalFields')
);
resolveAll.click();
assert.isTrue(wizard.getButton('next').hidden);
assert.isFalse(wizard.getButton('finish').hidden);
wizard.getButton('finish').click();
})
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
libraryID, type, { stopOnError: true }
);
assert.equal(objects[0].getField('title'), values[0].right.title);
assert.equal(objects[1].getField('title'), values[1].left.title);
assert.equal(objects[2].getField('title'), values[2].left.title);
})
it("should handle local item deletion, keeping deletion", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
// Create object in cache
var obj = yield createDataObject(type, { version: 10 });
var jsonData = yield obj.toJSON();
var key = jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create new version in cache, simulating a download
json.version = jsonData.version = 15;
jsonData.title = Zotero.Utilities.randomString();
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Delete object locally
yield obj.eraseTx();
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
assert.ok(mergeGroup.leftpane.pane.onclick);
mergeGroup.leftpane.pane.click();
wizard.getButton('finish').click();
})
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
libraryID, type, { stopOnError: true }
);
obj = objectsClass.getByLibraryAndKey(libraryID, key);
assert.isFalse(obj);
})
it("should handle restore locally deleted item", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
// Create object in cache
var obj = yield createDataObject(type, { version: 10 });
var jsonData = yield obj.toJSON();
var key = jsonData.key = obj.key;
jsonData.version = 10;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create new version in cache, simulating a download
json.version = jsonData.version = 15;
jsonData.title = Zotero.Utilities.randomString();
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Delete object locally
yield obj.eraseTx();
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
assert.isTrue(doc.getElementById('resolve-all').hidden);
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
wizard.getButton('finish').click();
})
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
libraryID, type, { stopOnError: true }
);
obj = objectsClass.getByLibraryAndKey(libraryID, key);
assert.ok(obj);
yield obj.loadItemData();
assert.equal(obj.getField('title'), jsonData.title);
})
it("should handle note conflict", function* () {
var libraryID = Zotero.Libraries.userLibraryID;
var type = 'item';
var objectsClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(type);
var noteText1 = "<p>A</p>";
var noteText2 = "<p>B</p>";
// Create object in cache
var obj = new Zotero.Item('note');
obj.setNote("");
obj.version = 10;
yield obj.saveTx();
var jsonData = yield obj.toJSON();
var key = jsonData.key = obj.key;
let json = {
key: obj.key,
version: jsonData.version,
data: jsonData
};
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Create new version in cache, simulating a download
json.version = jsonData.version = 15;
json.data.note = noteText2;
yield Zotero.Sync.Data.Local.saveCacheObjects(type, libraryID, [json]);
// Delete object locally
obj.setNote(noteText1);
waitForWindow('chrome://zotero/content/merge.xul', function (dialog) {
var doc = dialog.document;
var wizard = doc.documentElement;
var mergeGroup = wizard.getElementsByTagName('zoteromergegroup')[0];
// Remote version should be selected by default
assert.equal(mergeGroup.rightpane.getAttribute('selected'), 'true');
wizard.getButton('finish').click();
})
yield Zotero.Sync.Data.Local.processSyncCacheForObjectType(
libraryID, type, { stopOnError: true }
);
obj = objectsClass.getByLibraryAndKey(libraryID, key);
assert.ok(obj);
yield obj.loadNote();
assert.equal(obj.getNote(), noteText2);
})
})
describe("#_reconcileChanges()", function () {
describe("items", function () {
it("should ignore non-conflicting local changes and return remote changes", function () {