1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00
This commit is contained in:
Laurent Cozic 2017-06-03 17:20:17 +01:00
parent 1a84417fb8
commit 24846c5db3
5 changed files with 179 additions and 117 deletions

View File

@ -1,4 +1,3 @@
import { Registry } from 'src/registry.js';
import { Log } from 'src/log.js';
import { Setting } from 'src/models/setting.js';
import { Change } from 'src/models/change.js';
@ -27,130 +26,137 @@ class Synchronizer {
return this.api_;
}
processState(state) {
// if (this.state() == state) {
// Log.info('Sync: cannot switch to same state: ' + state);
// return;
// }
Log.info('Sync: processing: ' + state);
this.state_ = state;
if (state == 'downloadChanges') {
let maxRevId = null;
let hasMore = false;
this.api().get('synchronizer', { last_id: Setting.value('sync.lastRevId') }).then((syncOperations) => {
hasMore = syncOperations.has_more;
let chain = [];
for (let i = 0; i < syncOperations.items.length; i++) {
let syncOp = syncOperations.items[i];
if (syncOp.id > maxRevId) maxRevId = syncOp.id;
processState_uploadChanges() {
Change.all().then((changes) => {
let mergedChanges = Change.mergeChanges(changes);
let chain = [];
let processedChangeIds = [];
for (let i = 0; i < mergedChanges.length; i++) {
let c = mergedChanges[i];
chain.push(() => {
let p = null;
let ItemClass = null;
if (syncOp.item_type == 'folder') {
let path = null;
if (c.item_type == BaseModel.ITEM_TYPE_FOLDER) {
ItemClass = Folder;
} else if (syncOp.item_type == 'note') {
path = 'folders';
} else if (c.item_type == BaseModel.ITEM_TYPE_NOTE) {
ItemClass = Note;
path = 'notes';
}
if (syncOp.type == 'create') {
chain.push(() => {
let item = ItemClass.fromApiResult(syncOp.item);
// TODO: automatically handle NULL fields by checking type and default value of field
if ('parent_id' in item && !item.parent_id) item.parent_id = '';
return ItemClass.save(item, { isNew: true, trackChanges: false });
if (c.type == Change.TYPE_NOOP) {
p = Promise.resolve();
} else if (c.type == Change.TYPE_CREATE) {
p = ItemClass.load(c.item_id).then((item) => {
return this.api().put(path + '/' + item.id, null, item);
});
}
if (syncOp.type == 'update') {
chain.push(() => {
return ItemClass.load(syncOp.item_id).then((item) => {
if (!item) return;
item = ItemClass.applyPatch(item, syncOp.item);
return ItemClass.save(item, { trackChanges: false });
});
} else if (c.type == Change.TYPE_UPDATE) {
p = ItemClass.load(c.item_id).then((item) => {
return this.api().patch(path + '/' + item.id, null, item);
});
} else if (c.type == Change.TYPE_DELETE) {
p = this.api().delete(path + '/' + c.item_id);
}
if (syncOp.type == 'delete') {
chain.push(() => {
return ItemClass.delete(syncOp.item_id, { trackChanges: false });
});
}
}
return promiseChain(chain);
}).then(() => {
Log.info('All items synced. has_more = ', hasMore);
if (maxRevId) {
Setting.setValue('sync.lastRevId', maxRevId);
return Setting.saveAll();
}
}).then(() => {
if (hasMore) {
this.processState('downloadChanges');
} else {
this.processState('uploadingChanges');
}
}).catch((error) => {
Log.warn('Sync error', error);
});
} else if (state == 'uploadingChanges') {
Change.all().then((changes) => {
let mergedChanges = Change.mergeChanges(changes);
let chain = [];
let processedChangeIds = [];
for (let i = 0; i < mergedChanges.length; i++) {
let c = mergedChanges[i];
chain.push(() => {
let p = null;
let ItemClass = null;
let path = null;
if (c.item_type == BaseModel.ITEM_TYPE_FOLDER) {
ItemClass = Folder;
path = 'folders';
} else if (c.item_type == BaseModel.ITEM_TYPE_NOTE) {
ItemClass = Note;
path = 'notes';
}
if (c.type == Change.TYPE_NOOP) {
p = Promise.resolve();
} else if (c.type == Change.TYPE_CREATE) {
p = ItemClass.load(c.item_id).then((item) => {
return this.api().put(path + '/' + item.id, null, item);
});
} else if (c.type == Change.TYPE_UPDATE) {
p = ItemClass.load(c.item_id).then((item) => {
return this.api().patch(path + '/' + item.id, null, item);
});
} else if (c.type == Change.TYPE_DELETE) {
p = this.api().delete(path + '/' + c.item_id);
}
return p.then(() => {
return p.then(() => {
processedChangeIds = processedChangeIds.concat(c.ids);
}).catch((error) => {
Log.warn('Failed applying changes', c.ids, error.message, error.type);
// This is fine - trying to apply changes to an object that has been deleted
if (error.type == 'NotFoundException') {
processedChangeIds = processedChangeIds.concat(c.ids);
}).catch((error) => {
Log.warn('Failed applying changes', c.ids, error.message, error.type);
// This is fine - trying to apply changes to an object that has been deleted
if (error.type == 'NotFoundException') {
processedChangeIds = processedChangeIds.concat(c.ids);
} else {
throw error;
}
} else {
throw error;
}
});
});
}
return promiseChain(chain).catch((error) => {
Log.warn('Synchronization was interrupted due to an error:', error);
}).then(() => {
Log.info('IDs to delete: ', processedChangeIds);
Change.deleteMultiple(processedChangeIds);
});
}).then(() => {
this.processState('downloadChanges');
});
}
processState_downloadChanges() {
let maxRevId = null;
let hasMore = false;
this.api().get('synchronizer', { last_id: Setting.value('sync.lastRevId') }).then((syncOperations) => {
hasMore = syncOperations.has_more;
let chain = [];
for (let i = 0; i < syncOperations.items.length; i++) {
let syncOp = syncOperations.items[i];
if (syncOp.id > maxRevId) maxRevId = syncOp.id;
let ItemClass = null;
if (syncOp.item_type == 'folder') {
ItemClass = Folder;
} else if (syncOp.item_type == 'note') {
ItemClass = Note;
}
if (syncOp.type == 'create') {
chain.push(() => {
let item = ItemClass.fromApiResult(syncOp.item);
// TODO: automatically handle NULL fields by checking type and default value of field
if ('parent_id' in item && !item.parent_id) item.parent_id = '';
return ItemClass.save(item, { isNew: true, trackChanges: false });
});
}
if (syncOp.type == 'update') {
chain.push(() => {
return ItemClass.load(syncOp.item_id).then((item) => {
if (!item) return;
item = ItemClass.applyPatch(item, syncOp.item);
return ItemClass.save(item, { trackChanges: false });
});
});
}
return promiseChain(chain).catch((error) => {
Log.warn('Synchronization was interrupted due to an error:', error);
}).then(() => {
Log.info('IDs to delete: ', processedChangeIds);
Change.deleteMultiple(processedChangeIds);
});
}).then(() => {
if (syncOp.type == 'delete') {
chain.push(() => {
return ItemClass.delete(syncOp.item_id, { trackChanges: false });
});
}
}
return promiseChain(chain);
}).then(() => {
Log.info('All items synced. has_more = ', hasMore);
if (maxRevId) {
Setting.setValue('sync.lastRevId', maxRevId);
return Setting.saveAll();
}
}).then(() => {
if (hasMore) {
this.processState('downloadChanges');
} else {
this.processState('idle');
});
}
}).catch((error) => {
Log.warn('Sync error', error);
});
}
processState(state) {
Log.info('Sync: processing: ' + state);
this.state_ = state;
if (state == 'uploadChanges') {
processState_uploadChanges();
} else if (state == 'downloadChanges') {
processState_downloadChanges();
} else if (state == 'idle') {
// Nothing
} else {
throw new Error('Invalid state: ' . state);
}
}
@ -167,7 +173,7 @@ class Synchronizer {
return;
}
this.processState('downloadChanges');
this.processState('uploadChanges');
}
}

View File

@ -1,2 +1,3 @@
#!/bin/bash
php phpunit-5.7.20.phar --bootstrap vendor/autoload.php tests/Model/
# php phpunit-5.7.20.phar --bootstrap vendor/autoload.php tests/Model/
php phpunit-5.7.20.phar --filter testConflict ChangeTest tests/Model/ChangeTest.php --bootstrap vendor/autoload.php tests/Model/

View File

@ -47,13 +47,8 @@ class Change extends BaseModel {
}
$itemIdToChange[$change->item_id] = $change;
// echo BaseModel::hex($change->item_id) . ' ' . $change->id . ' ' . Change::enumName('type', $change->type) . "\n";
}
// die();
$output = array();
foreach ($itemIdToChange as $itemId => $change) {
if (in_array($itemId, $createdItems) && in_array($itemId, $deletedItems)) {

View File

@ -30,7 +30,7 @@ class Note extends BaseItem {
'source_url' => null,
);
static public function filter($data) {
static public function filter($data, $keepId = false) {
$output = parent::filter($data);
if (array_key_exists('longitude', $output)) $output['longitude'] = (string)number_format($output['longitude'], 8);
if (array_key_exists('latitude', $output)) $output['latitude'] = (string)number_format($output['latitude'], 8);

View File

@ -48,6 +48,66 @@ class ChangeTest extends BaseTestCase {
$this->assertEquals($r, $text2);
}
public function testConflict() {
// Scenario where two different clients change the same note at the same time.
//
// Client 1: 'abcd efgh ijkl' => 'XXXX'
// Client 2: 'abcd efgh ijkl' => 'YYYY'
// Expected: 'cd CLIENT1 efgh ijkl FROMCLIENT2'
$text1 = 'abcd efgh ijkl';
$itemId = $this->createModelId('note');
$change = new Change();
$change->user_id = $this->user()->id;
$change->client_id = $this->clientId(1);
$change->item_type = BaseItem::enumId('type', 'note');
$change->item_field = 'body';
$change->type = Change::enumId('type', 'create');
$change->item_id = $itemId;
$change->createDelta($text1);
$change->save();
$changeId1 = $change->id;
$text2 = 'XXXX';
$change = new Change();
$change->user_id = $this->user()->id;
$change->client_id = $this->clientId(2);
$change->item_type = BaseItem::enumId('type', 'note');
$change->item_field = 'body';
$change->type = Change::enumId('type', 'update');
$change->item_id = $itemId;
$change->previous_id = $changeId1;
$change->createDelta($text2);
$change->save();
$changeId2 = $change->id;
$text3 = 'YYYY';
$change = new Change();
$change->user_id = $this->user()->id;
$change->client_id = $this->clientId(1);
$change->item_type = BaseItem::enumId('type', 'note');
$change->item_field = 'body';
$change->type = Change::enumId('type', 'update');
$change->item_id = $itemId;
$change->previous_id = $changeId1;
$change->createDelta($text3);
$change->save();
$changeId3 = $change->id;
$r = Change::fullFieldText($itemId, 'body');
var_dump($r);die();
$this->assertEquals($r, 'cd CLIENT1 efgh ijkl FROMCLIENT2');
}
public function testSame() {
$note = new Note();
$note->fromPublicArray(array('body' => 'test'));