You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	sync
This commit is contained in:
		| @@ -1,4 +1,3 @@ | |||||||
| import { Registry } from 'src/registry.js'; |  | ||||||
| import { Log } from 'src/log.js'; | import { Log } from 'src/log.js'; | ||||||
| import { Setting } from 'src/models/setting.js'; | import { Setting } from 'src/models/setting.js'; | ||||||
| import { Change } from 'src/models/change.js'; | import { Change } from 'src/models/change.js'; | ||||||
| @@ -27,130 +26,137 @@ class Synchronizer { | |||||||
| 		return this.api_; | 		return this.api_; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	processState(state) { | 	processState_uploadChanges() { | ||||||
| 		// if (this.state() == state) { | 		Change.all().then((changes) => { | ||||||
| 		// 	Log.info('Sync: cannot switch to same state: ' + state); | 			let mergedChanges = Change.mergeChanges(changes); | ||||||
| 		// 	return; | 			let chain = []; | ||||||
| 		// } | 			let processedChangeIds = []; | ||||||
|  | 			for (let i = 0; i < mergedChanges.length; i++) { | ||||||
| 		Log.info('Sync: processing: ' + state); | 				let c = mergedChanges[i]; | ||||||
| 		this.state_ = state; | 				chain.push(() => { | ||||||
|  | 					let p = null; | ||||||
| 		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; |  | ||||||
|  |  | ||||||
| 					let ItemClass = null;					 | 					let ItemClass = null;					 | ||||||
| 					if (syncOp.item_type == 'folder') { | 					let path = null; | ||||||
|  | 					if (c.item_type == BaseModel.ITEM_TYPE_FOLDER) { | ||||||
| 						ItemClass = Folder; | 						ItemClass = Folder; | ||||||
| 					} else if (syncOp.item_type == 'note') { | 						path = 'folders'; | ||||||
|  | 					} else if (c.item_type == BaseModel.ITEM_TYPE_NOTE) { | ||||||
| 						ItemClass = Note; | 						ItemClass = Note; | ||||||
|  | 						path = 'notes'; | ||||||
| 					} | 					} | ||||||
|  |  | ||||||
| 					if (syncOp.type == 'create') { | 					if (c.type == Change.TYPE_NOOP) { | ||||||
| 						chain.push(() => { | 						p = Promise.resolve(); | ||||||
| 							let item = ItemClass.fromApiResult(syncOp.item); | 					} else if (c.type == Change.TYPE_CREATE) { | ||||||
| 							// TODO: automatically handle NULL fields by checking type and default value of field | 						p = ItemClass.load(c.item_id).then((item) => { | ||||||
| 							if ('parent_id' in item && !item.parent_id) item.parent_id = ''; | 							return this.api().put(path + '/' + item.id, null, item); | ||||||
| 							return ItemClass.save(item, { isNew: true, trackChanges: false }); |  | ||||||
| 						}); | 						}); | ||||||
| 					} | 					} else if (c.type == Change.TYPE_UPDATE) { | ||||||
|  | 						p = ItemClass.load(c.item_id).then((item) => { | ||||||
| 					if (syncOp.type == 'update') { | 							return this.api().patch(path + '/' + item.id, null, item); | ||||||
| 						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_DELETE) { | ||||||
|  | 						p = this.api().delete(path + '/' + c.item_id); | ||||||
| 					} | 					} | ||||||
|  |  | ||||||
| 					if (syncOp.type == 'delete') { | 					return p.then(() => { | ||||||
| 						chain.push(() => { | 						processedChangeIds = processedChangeIds.concat(c.ids); | ||||||
| 							return ItemClass.delete(syncOp.item_id, { trackChanges: false }); | 					}).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') { | ||||||
| 				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(() => { |  | ||||||
| 							processedChangeIds = processedChangeIds.concat(c.ids); | 							processedChangeIds = processedChangeIds.concat(c.ids); | ||||||
| 						}).catch((error) => { | 						} else { | ||||||
| 							Log.warn('Failed applying changes', c.ids, error.message, error.type); | 							throw error; | ||||||
| 							// 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; |  | ||||||
| 							} | 			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) => { | 				if (syncOp.type == 'delete') { | ||||||
| 					Log.warn('Synchronization was interrupted due to an error:', error); | 					chain.push(() => { | ||||||
| 				}).then(() => { | 						return ItemClass.delete(syncOp.item_id, { trackChanges: false }); | ||||||
| 					Log.info('IDs to delete: ', processedChangeIds); | 					}); | ||||||
| 					Change.deleteMultiple(processedChangeIds); | 				} | ||||||
| 				}); | 			} | ||||||
| 			}).then(() => { | 			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'); | 				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; | 			return; | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		this.processState('downloadChanges'); | 		this.processState('uploadChanges'); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,2 +1,3 @@ | |||||||
| #!/bin/bash | #!/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/ | ||||||
| @@ -47,13 +47,8 @@ class Change extends BaseModel { | |||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			$itemIdToChange[$change->item_id] = $change; | 			$itemIdToChange[$change->item_id] = $change; | ||||||
|  |  | ||||||
| 			// echo BaseModel::hex($change->item_id) . ' ' . $change->id . ' ' . Change::enumName('type', $change->type) . "\n"; |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		// die(); |  | ||||||
|  |  | ||||||
|  |  | ||||||
| 		$output = array(); | 		$output = array(); | ||||||
| 		foreach ($itemIdToChange as $itemId => $change) { | 		foreach ($itemIdToChange as $itemId => $change) { | ||||||
| 			if (in_array($itemId, $createdItems) && in_array($itemId, $deletedItems)) { | 			if (in_array($itemId, $createdItems) && in_array($itemId, $deletedItems)) { | ||||||
|   | |||||||
| @@ -30,7 +30,7 @@ class Note extends BaseItem { | |||||||
| 		'source_url' => null, | 		'source_url' => null, | ||||||
| 	); | 	); | ||||||
|  |  | ||||||
| 	static public function filter($data) { | 	static public function filter($data, $keepId = false) { | ||||||
| 		$output = parent::filter($data); | 		$output = parent::filter($data); | ||||||
| 		if (array_key_exists('longitude', $output)) $output['longitude'] = (string)number_format($output['longitude'], 8); | 		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); | 		if (array_key_exists('latitude', $output)) $output['latitude'] = (string)number_format($output['latitude'], 8); | ||||||
|   | |||||||
| @@ -48,6 +48,66 @@ class ChangeTest extends BaseTestCase { | |||||||
| 		$this->assertEquals($r, $text2); | 		$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() { | 	public function testSame() { | ||||||
| 		$note = new Note(); | 		$note = new Note(); | ||||||
| 		$note->fromPublicArray(array('body' => 'test')); | 		$note->fromPublicArray(array('body' => 'test')); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user