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; this.blurHandler = this.hideEditor;
break; 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': case 'fieldmerge':
this.hideEmptyFields = true; this.hideEmptyFields = true;
this._fieldAlternatives = {}; this._fieldAlternatives = {};
@ -1909,9 +1896,7 @@
if(field === 'creator') { if(field === 'creator') {
// Reset creator mode settings here so that flex attribute gets reset // Reset creator mode settings here so that flex attribute gets reset
this.switchCreatorMode(row, (otherFields.fieldMode ? 1 : 0), true); this.switchCreatorMode(row, (otherFields.fieldMode ? 1 : 0), true);
Zotero.debug("HERE");
if(Zotero.ItemTypes.getName(this.item.itemTypeID) === "bookSection") { if(Zotero.ItemTypes.getName(this.item.itemTypeID) === "bookSection") {
Zotero.debug("YES");
var creatorTypeLabels = document.getAnonymousNodes(this)[0].getElementsByClassName("creator-type-label"); var creatorTypeLabels = document.getAnonymousNodes(this)[0].getElementsByClassName("creator-type-label");
Zotero.debug(creatorTypeLabels[creatorTypeLabels.length-1] + ""); Zotero.debug(creatorTypeLabels[creatorTypeLabels.length-1] + "");
document.getElementById("zotero-author-guidance").show({ document.getElementById("zotero-author-guidance").show({

View file

@ -44,73 +44,36 @@
]]> ]]>
</constructor> </constructor>
<field name="_type"/> <field name="_data"/>
<property name="type" onget="return this._type;"> <property name="data" onget="return this._data;">
<setter> <setter>
<![CDATA[ <![CDATA[
this._type = val; this._data = 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.refresh(); this.refresh();
]]> ]]>
</setter> </setter>
</property> </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> <setter>
<![CDATA[ <![CDATA[
// TODO: Make sure object is the correct type switch (val) {
this._mergepane.ref = 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> </setter>
</property> </property>
@ -119,51 +82,43 @@
<property name="rightCaption" onset="this._rightpane.caption.label = val"/> <property name="rightCaption" onset="this._rightpane.caption.label = val"/>
<property name="mergeCaption" onset="this._mergepane.caption.label = val"/> <property name="mergeCaption" onset="this._mergepane.caption.label = val"/>
<field name="_leftpane"/>
<property name="leftpane" onget="return this._leftpane"/> <property name="leftpane" onget="return this._leftpane"/>
<field name="_rightpane"/>
<property name="rightpane" onget="return this._rightpane"/> <property name="rightpane" onget="return this._rightpane"/>
<field name="_mergepane"/>
<property name="mergepane" onget="return this._mergepane"/> <property name="mergepane" onget="return this._mergepane"/>
<property name="onSelectionChange"/> <property name="onSelectionChange"/>
<field name="_leftpane"/>
<field name="_rightpane"/>
<field name="_mergepane"/>
<method name="refresh"> <method name="refresh">
<body> <body>
<![CDATA[ <![CDATA[
// Set merge pane to most recently changed object if (this._data.left.deleted && this._data.right.deleted) {
// If one object was deleted, set merge pane to other throw new Exception("'left' and 'right' cannot both be deleted");
// 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._leftpane.ref == 'deleted' || dm2 > dm1) { // Check for note or attachment
var mergeItem = this._rightpane.original; this.type = this._getTypeFromObject(
this._leftpane.removeAttribute("selected"); this._data.left.deleted ? this._data.right : this._data.left
this._rightpane.setAttribute("selected", "true"); );
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 { else {
var mergeItem = this._leftpane.original; this.choosePane(this._rightpane);
this._rightpane.removeAttribute("selected");
this._leftpane.setAttribute("selected", "true");
} }
this._mergepane.ref = mergeItem;
/* /*
Code to display only the different values -- not used Code to display only the different values -- not used
@ -224,22 +179,51 @@
</body> </body>
</method> </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"/> <parameter name="obj"/>
<body> <body>
<![CDATA[ <![CDATA[
if (!(obj instanceof Zotero.Item)) { if (!obj.itemType) {
throw ("obj is not a Zotero.Item in merge.xml"); Zotero.debug(obj, 1);
throw new Error("obj is not item JSON");
} }
if (obj.isAttachment()) { if (obj.itemType == 'attachment' || obj.itemType == 'note') {
return 'attachment'; return obj.itemType;
}
else if (obj.isNote()) {
return 'note';
}
else {
return 'item';
} }
return 'item';
]]> ]]>
</body> </body>
</method> </method>
@ -248,7 +232,7 @@
<parameter name="id"/> <parameter name="id"/>
<body> <body>
<![CDATA[ <![CDATA[
return document.getAnonymousNodes(this)[0].getElementsByAttribute('id',id)[0]; return document.getAnonymousNodes(this)[0].getElementsByAttribute('anonid',id)[0];
]]> ]]>
</body> </body>
</method> </method>
@ -256,9 +240,9 @@
<content> <content>
<xul:hbox id="merge-group" flex="1"> <xul:hbox id="merge-group" flex="1">
<xul:zoteromergepane id="leftpane" flex="1"/> <xul:zoteromergepane anonid="leftpane" flex="1"/>
<xul:zoteromergepane id="rightpane" flex="1"/> <xul:zoteromergepane anonid="rightpane" flex="1"/>
<xul:zoteromergepane id="mergepane" flex="1"/> <xul:zoteromergepane anonid="mergepane" flex="1"/>
</xul:hbox> </xul:hbox>
</content> </content>
</binding> </binding>
@ -273,11 +257,20 @@
<constructor> <constructor>
<![CDATA[ <![CDATA[
this.parent = document.getBindingParent(this.parentNode); 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> </constructor>
<property name="type" onget="return this.parent.type" readonly="true"/> <property name="type" onget="return this.parent.type" readonly="true"/>
<property name="caption" onget="return this._id('caption')" 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"/> <property name="objectbox" onget="return this._id('objectbox')" readonly="true"/>
<field name="_deleted"/> <field name="_deleted"/>
@ -291,7 +284,7 @@
placeholder.hidden = !!val; placeholder.hidden = !!val;
} }
else { else {
this._id('objectbox').hidden = !!true; this._id('objectbox').hidden = true;
} }
var deleteBox = this._id('delete-box'); var deleteBox = this._id('delete-box');
deleteBox.hidden = !val; deleteBox.hidden = !val;
@ -299,10 +292,13 @@
</setter> </setter>
</property> </property>
<property name="ref" onget="return this._deleted ? 'deleted' : this.objectbox.ref;"> <field name="_data"/>
<property name="data" onget="return this._data">
<setter> <setter>
<![CDATA[ <![CDATA[
if (val == 'deleted') { this._data = val;
if (val.deleted) {
this.deleted = true; this.deleted = true;
return; return;
} }
@ -324,7 +320,7 @@
elementName = 'zoteronoteeditor'; elementName = 'zoteronoteeditor';
break; break;
case 'storagefile': case 'file':
elementName = 'zoterostoragefilebox'; elementName = 'zoterostoragefilebox';
break; break;
@ -344,149 +340,64 @@
oldObjBox.parentNode.replaceChild(objbox, oldObjBox); oldObjBox.parentNode.replaceChild(objbox, oldObjBox);
} }
objbox.setAttribute("id", "objectbox"); objbox.setAttribute("anonid", "objectbox");
objbox.setAttribute("flex", "1"); objbox.setAttribute("flex", "1");
if (this.getAttribute('id') == 'mergepane') { objbox.mode = 'view';
objbox.mode = 'mergeedit';
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 { else {
objbox.mode = 'merge'; button.hidden = true;
objbox.clickHandler = this.chooseObj;
} }
// Type-specific settings // Store JSON
switch (this.type) { this._data = val;
case 'attachment':
case 'note':
case 'storagefile':
objbox.buttonCaption = Zotero.getString('sync.conflict.chooseThisVersion');
break;
}
objbox.ref = val; // Create item from JSON for metadata box
var item = new Zotero.Item(val.itemType);
item.fromJSON(val);
objbox.ref = item;
]]> ]]>
</setter> </setter>
</property> </property>
<field name="original"/> <!-- original object -->
<field name="parent"/> <field name="parent"/>
<method name="chooseObj"> <method name="click">
<parameter name="obj"/> <body><![CDATA[
<body> this.pane.click();
<![CDATA[ ]]></body>
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> </method>
<method name="_id"> <method name="_id">
<parameter name="id"/> <parameter name="id"/>
<body> <body>
<![CDATA[ <![CDATA[
if (!document.getAnonymousNodes(this)[0].getElementsByAttribute('id', id).length) { var elems = document.getAnonymousNodes(this)[0].getElementsByAttribute('anonid', id);
return false; return elems.length ? elems[0] : false;
}
return document.getAnonymousNodes(this)[0].getElementsByAttribute('id', id)[0];
]]> ]]>
</body> </body>
</method> </method>
</implementation> </implementation>
<content> <content>
<xul:groupbox id="merge-pane" flex="1"> <xul:vbox flex="1">
<xul:caption id="caption"/> <xul:groupbox anonid="merge-pane" flex="1">
<xul:box id="object-placeholder"/> <xul:caption anonid="caption"/>
<xul:hbox id="delete-box" hidden="true" flex="1" <xul:box anonid="object-placeholder"/>
onclick="document.getBindingParent(this).chooseObj(this)"> <xul:hbox anonid="delete-box" hidden="true" flex="1">
<xul:label value="&zotero.merge.deleted;"/> <xul:label value="&zotero.merge.deleted;"/>
</xul:hbox> </xul:hbox>
</xul:groupbox> </xul:groupbox>
<xul:button anonid="choose-button" hidden="true"/>
</xul:vbox>
</content> </content>
</binding> </binding>
</bindings> </bindings>

View file

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

View file

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

View file

@ -24,24 +24,17 @@
*/ */
var Zotero_Merge_Window = new function () { 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 _wizard = null;
var _wizardPage = null; var _wizardPage = null;
var _mergeGroup = null; var _mergeGroup = null;
var _numObjects = null; var _numObjects = null;
var _initialized = false;
var _io = null; var _io = null;
var _objects = null; var _conflicts = null;
var _merged = []; var _merged = [];
var _pos = -1; var _pos = -1;
function init() { this.init = function () {
_wizard = document.getElementsByTagName('wizard')[0]; _wizard = document.getElementsByTagName('wizard')[0];
_wizardPage = document.getElementsByTagName('wizardpage')[0]; _wizardPage = document.getElementsByTagName('wizardpage')[0];
_mergeGroup = document.getElementsByTagName('zoteromergegroup')[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')); _wizard.getButton('cancel').setAttribute('label', Zotero.getString('sync.cancel'));
_io = window.arguments[0]; _io = window.arguments[0].wrappedJSObject;
_objects = _io.dataIn.objects; _conflicts = _io.dataIn.conflicts;
if (!_objects.length) { if (!_conflicts.length) {
// TODO: handle no objects // TODO: handle no conflicts
return; 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.leftCaption = _io.dataIn.captions[0];
_mergeGroup.rightCaption = _io.dataIn.captions[1]; _mergeGroup.rightCaption = _io.dataIn.captions[1];
_mergeGroup.mergeCaption = _io.dataIn.captions[2]; _mergeGroup.mergeCaption = _io.dataIn.captions[2];
_resolveAllCheckbox = document.getElementById('resolve-all'); _resolveAllCheckbox = document.getElementById('resolve-all');
if (_conflicts.length == 1) {
_resolveAllCheckbox.hidden = true;
}
else {
_mergeGroup.onSelectionChange = _updateResolveAllCheckbox;
}
_numObjects = document.getElementById('zotero-merge-num-objects'); _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(); this.onNext();
} }
function onBack() { this.onBack = function () {
_merged[_pos] = _getCurrentMergeInfo();
_pos--; _pos--;
if (_pos == 0) { if (_pos == 0) {
_wizard.canRewind = false; _wizard.canRewind = false;
} }
_merged[_pos + 1] = _getCurrentMergeObject(); _updateGroup();
_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();
}
var nextButton = _wizard.getButton("next"); var nextButton = _wizard.getButton("next");
@ -134,36 +100,26 @@ var Zotero_Merge_Window = new function () {
} }
function onNext() { this.onNext = function () {
if (_pos + 1 == _objects.length || _resolveAllCheckbox.checked) { // At end or resolving all
if (_pos + 1 == _conflicts.length || _resolveAllCheckbox.checked) {
return true; return true;
} }
// First page
if (_pos == -1) {
_wizard.canRewind = false;
}
// Subsequent pages
else {
_wizard.canRewind = true;
_merged[_pos] = _getCurrentMergeInfo();
}
_pos++; _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 { try {
_mergeGroup.left = _objects[_pos][0]; _updateGroup();
_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");
}
} }
catch (e) { catch (e) {
_error(e); _error(e);
@ -172,10 +128,6 @@ var Zotero_Merge_Window = new function () {
_updateResolveAllCheckbox(); _updateResolveAllCheckbox();
if (_mergeGroup.type == 'item') {
_updateChangedCreators();
}
if (_isLastConflict()) { if (_isLastConflict()) {
_showFinishButton(); _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 using one side for all remaining, update merge object
if (!_isLastConflict() && _resolveAllCheckbox.checked) { if (!_isLastConflict() && _resolveAllCheckbox.checked) {
let useRemote = _mergeGroup.rightpane.getAttribute("selected") == "true"; let side = _mergeGroup.rightpane.getAttribute("selected") == "true" ? 'right' : 'left'
for (let i = _pos; i < _objects.length; i++) { for (let i = _pos; i < _conflicts.length; i++) {
_merged[i] = _getMergeObject( _merged[i] = {
_objects[i][useRemote ? 1 : 0], data: _getMergeDataWithSide(i, side),
_objects[i][0], selected: side
_objects[i][1] };
);
} }
} }
else { 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; _io.dataOut = _merged;
return true; return true;
} }
function onCancel() { this.onCancel = function () {
// if already merged, ask // 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() { function _updateResolveAllCheckbox() {
if (_mergeGroup.rightpane.getAttribute("selected") == 'true') { if (_mergeGroup.rightpane.getAttribute("selected") == 'true') {
var label = 'sync.merge.resolveAllRemote'; var label = 'resolveAllRemoteFields';
} }
else { 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() { 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) { function _setInstructionsString(buttonName) {
switch (_mergeGroup.type) { switch (_mergeGroup.type) {
case 'storagefile': case 'file':
var msg = Zotero.getString('sync.conflict.fileChanged'); var msg = 'fileChanged';
break; break;
default: default:
// TODO: cf. localization: maybe not always call it 'item' // TODO: maybe don't always call it 'item'
var msg = Zotero.getString('sync.conflict.itemChanged'); var msg = 'itemChanged';
} }
msg += " " + Zotero.getString('sync.conflict.chooseVersionToKeep', buttonName); msg = Zotero.getString('sync.conflict.' + msg, buttonName)
document.getElementById('zotero-merge-instructions').value = msg; document.getElementById('zotero-merge-instructions').value = msg;
} }

View file

@ -27,13 +27,6 @@ var noteEditor;
var notifierUnregisterID; var notifierUnregisterID;
function onLoad() { 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 = document.getElementById('zotero-note-editor');
noteEditor.mode = 'edit'; noteEditor.mode = 'edit';
noteEditor.focus(); noteEditor.focus();

View file

@ -631,7 +631,7 @@ Zotero.Item.prototype.setField = function(field, value, loadIn) {
this._disabledCheck(); this._disabledCheck();
if (value === undefined) { if (value === undefined) {
throw new Error("Value cannot be undefined"); throw new Error(`'${field}' value cannot be undefined`);
} }
if (typeof value == 'string') { 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("UPDATE syncDeleteLog SET libraryID=1 WHERE libraryID=0");
yield Zotero.DB.queryAsync("ALTER TABLE syncDeleteLog RENAME TO syncDeleteLogOld"); 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("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 * FROM syncDeleteLogOld"); 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("DROP INDEX IF EXISTS syncDeleteLog_timestamp");
yield Zotero.DB.queryAsync("CREATE INDEX syncDeleteLog_synced ON syncDeleteLog(synced)"); yield Zotero.DB.queryAsync("CREATE INDEX syncDeleteLog_synced ON syncDeleteLog(synced)");
// TODO: Something special for tag deletions? // 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 syncDeleteLog WHERE syncObjectTypeID IN (2, 5, 6)");
//yield Zotero.DB.queryAsync("DELETE FROM syncObjectTypes 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("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("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 * FROM storageDeleteLogOld"); 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("DROP INDEX IF EXISTS storageDeleteLog_timestamp");
yield Zotero.DB.queryAsync("CREATE INDEX storageDeleteLog_synced ON storageDeleteLog(synced)"); yield Zotero.DB.queryAsync("CREATE INDEX storageDeleteLog_synced ON storageDeleteLog(synced)");

View file

@ -37,7 +37,8 @@ Zotero.Sync.EventListeners.ChangeListener = new function () {
return; 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) { if (type == 'item' && Zotero.Sync.Storage.WebDAV.includeUserFiles) {
var storageSQL = "REPLACE INTO storageDeleteLog VALUES (?, ?, 0)"; 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); Zotero.debug("Processing " + objectTypePlural + " in sync cache for " + libraryName);
var conflicts = [];
var numSaved = 0; var numSaved = 0;
var numSkipped = 0; var numSkipped = 0;
@ -340,18 +341,6 @@ Zotero.Sync.Data.Local = {
continue; 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 no conflicts, apply remote changes automatically
if (!result.conflicts.length) { if (!result.conflicts.length) {
Zotero.DataObjectUtilities.applyChanges( Zotero.DataObjectUtilities.applyChanges(
@ -362,21 +351,17 @@ Zotero.Sync.Data.Local = {
continue; continue;
} }
Zotero.debug('======DIFF========'); if (objectType != 'item') {
Zotero.debug(cachedJSON); throw new Error(`Unexpected conflict on ${objectType} object`);
Zotero.debug(jsonDataLocal); }
Zotero.debug(jsonData);
Zotero.debug(result);
throw new Error("Conflict");
conflicts.push({
// TODO left: jsonDataLocal,
right: jsonData,
// reconcile changes automatically if we can changes: result.changes,
conflicts: result.conflicts
// if we can't: });
// if it's a search or collection, use most recent version continue;
// if it's an item,
} }
let saved = yield this._saveObjectFromJSON(obj, jsonData, options); let saved = yield this._saveObjectFromJSON(obj, jsonData, options);
@ -387,11 +372,20 @@ Zotero.Sync.Data.Local = {
isNewObject = true; isNewObject = true;
// Check if object has been deleted locally // 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) { switch (objectType) {
case 'item': case 'item':
throw new Error("Unimplemented"); conflicts.push({
break; left: {
deleted: true,
dateDeleted: Zotero.Date.dateToSQL(dateDeleted, true)
},
right: jsonData
});
continue;
// Auto-restore some locally deleted objects that have changed remotely // Auto-restore some locally deleted objects that have changed remotely
case 'collection': case 'collection':
@ -433,6 +427,63 @@ Zotero.Sync.Data.Local = {
yield this.processSyncCacheForObjectType(libraryID, objectType, options); 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); data = yield this._getUnwrittenData(libraryID, objectType);
Zotero.debug("Skipping " + data.length + " " Zotero.debug("Skipping " + data.length + " "
+ (data.length == 1 ? objectType : objectTypePlural) + (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 // Classic sync
// //
@ -460,8 +546,10 @@ Zotero.Sync.Data.Local = {
_saveObjectFromJSON: Zotero.Promise.coroutine(function* (obj, json, options) { _saveObjectFromJSON: Zotero.Promise.coroutine(function* (obj, json, options) {
try { try {
yield obj.fromJSON(json); yield obj.fromJSON(json);
obj.version = json.version; if (!options.saveAsChanged) {
obj.synced = true; obj.version = json.version;
obj.synced = true;
}
Zotero.debug("SAVING " + json.key + " WITH SYNCED"); Zotero.debug("SAVING " + json.key + " WITH SYNCED");
Zotero.debug(obj.version); Zotero.debug(obj.version);
yield obj.save({ 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 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=?"; + "AND syncObjectTypeID=?";
var count = yield Zotero.DB.valueQueryAsync(sql, [libraryID, key, syncObjectTypeID]); var date = yield Zotero.DB.valueQueryAsync(sql, [libraryID, key, syncObjectTypeID]);
return !!count; return date ? Zotero.Date.sqlToDate(date, true) : false;
}), }),

View file

@ -789,12 +789,6 @@ sync.cancel = Cancel Sync
sync.openSyncPreferences = Open Sync Preferences sync.openSyncPreferences = Open Sync Preferences
sync.resetGroupAndSync = Reset Group and Sync sync.resetGroupAndSync = Reset Group and Sync
sync.removeGroupsAndSync = Remove Groups 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 = 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. 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.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.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.localItem = Local Item
sync.conflict.itemChanged = The following item has been changed in multiple locations. sync.conflict.remoteItem = Remote Item
sync.conflict.chooseVersionToKeep = Choose the version you would like to keep, and then click %S. 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.conflict.chooseThisVersion = Choose this version
sync.status.notYetSynced = Not yet synced sync.status.notYetSynced = Not yet synced

View file

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

View file

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

View file

@ -86,6 +86,8 @@ function waitForWindow(uri, callback) {
} }
} }
catch (e) { catch (e) {
Zotero.logError(e);
win.close();
deferred.reject(e); deferred.reject(e);
return; return;
} }
@ -113,7 +115,7 @@ function waitForWindow(uri, callback) {
* @return {Promise} * @return {Promise}
*/ */
function waitForDialog(onOpen, button='accept', url) { 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; var failure = false;
if (onOpen) { if (onOpen) {
try { try {
@ -272,6 +274,8 @@ var createGroup = Zotero.Promise.coroutine(function* (props) {
* @param {String} [params.parentKey] * @param {String} [params.parentKey]
* @param {Boolean} [params.synced] * @param {Boolean} [params.synced]
* @param {Integer} [params.version] * @param {Integer} [params.version]
* @param {Integer} [params.dateAdded] - Allowed for items
* @param {Integer} [params.dateModified] - Allowed for items
*/ */
function createUnsavedDataObject(objectType, params = {}) { function createUnsavedDataObject(objectType, params = {}) {
if (!objectType) { if (!objectType) {
@ -298,6 +302,9 @@ function createUnsavedDataObject(objectType, params = {}) {
break; break;
} }
var allowedParams = ['parentID', 'parentKey', 'synced', 'version']; var allowedParams = ['parentID', 'parentKey', 'synced', 'version'];
if (objectType == 'item') {
allowedParams.push('dateAdded', 'dateModified');
}
allowedParams.forEach(function (param) { allowedParams.forEach(function (param) {
if (params[param] !== undefined) { if (params[param] !== undefined) {
obj[param] = params[param]; obj[param] = params[param];
@ -316,7 +323,7 @@ function getNameProperty(objectType) {
return objectType == 'item' ? 'title' : 'name'; 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) { switch (obj.objectType) {
case 'item': case 'item':
yield obj.loadItemData(); yield obj.loadItemData();
@ -329,7 +336,7 @@ var modifyDataObject = Zotero.Promise.coroutine(function* (obj, params = {}) {
default: default:
obj.name = params.name !== undefined ? params.name : Zotero.Utilities.randomString(); 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 () { it("should throw if value is undefined", function () {
var item = new Zotero.Item('book'); 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 () { 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)); assert.isFalse(Zotero.Items.exists(itemID));
// Make sure objects weren't added to sync delete log // 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' 'setting', userLibraryID, 'tagColors'
)); ));
assert.isFalse(yield Zotero.Sync.Data.Local.objectInDeleteLog( assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
'collection', userLibraryID, collectionKey 'collection', userLibraryID, collectionKey
)); ));
assert.isFalse(yield Zotero.Sync.Data.Local.objectInDeleteLog( assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
'search', userLibraryID, searchKey 'search', userLibraryID, searchKey
)); ));
assert.isFalse(yield Zotero.Sync.Data.Local.objectInDeleteLog( assert.isFalse(yield Zotero.Sync.Data.Local.getDateDeleted(
'item', userLibraryID, itemKey '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 // JSON objects 3 should be deleted and not in the delete log
assert.isFalse(objectsClass.getByLibraryAndKey(userLibraryID, objects[type][2].key)); 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 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("#_reconcileChanges()", function () {
describe("items", function () { describe("items", function () {
it("should ignore non-conflicting local changes and return remote changes", function () { it("should ignore non-conflicting local changes and return remote changes", function () {