1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-11-27 08:21:03 +02:00
joplin/ReactNativeClient/src/synchronizer.js

418 lines
12 KiB
JavaScript
Raw Normal View History

2017-06-15 23:46:53 +02:00
require('babel-plugin-transform-runtime');
2017-05-18 21:58:01 +02:00
import { Log } from 'src/log.js';
import { Setting } from 'src/models/setting.js';
import { Change } from 'src/models/change.js';
import { Folder } from 'src/models/folder.js';
2017-05-20 00:16:50 +02:00
import { Note } from 'src/models/note.js';
2017-06-15 20:18:48 +02:00
import { BaseItem } from 'src/models/base-item.js';
2017-05-20 00:16:50 +02:00
import { BaseModel } from 'src/base-model.js';
2017-06-15 01:14:15 +02:00
import { promiseChain } from 'src/promise-utils.js';
import { NoteFolderService } from 'src/services/note-folder-service.js';
import { time } from 'src/time-utils.js';
2017-06-16 00:12:00 +02:00
import { sprintf } from 'sprintf-js';
2017-06-15 01:14:15 +02:00
//import { promiseWhile } from 'src/promise-utils.js';
2017-06-11 23:11:14 +02:00
import moment from 'moment';
const fs = require('fs');
const path = require('path');
2017-05-18 21:58:01 +02:00
class Synchronizer {
2017-05-19 21:12:09 +02:00
constructor(db, api) {
2017-05-18 21:58:01 +02:00
this.state_ = 'idle';
2017-05-19 21:12:09 +02:00
this.db_ = db;
this.api_ = api;
2017-05-18 21:58:01 +02:00
}
state() {
return this.state_;
}
db() {
2017-05-19 21:12:09 +02:00
return this.db_;
2017-05-18 21:58:01 +02:00
}
api() {
2017-05-19 21:12:09 +02:00
return this.api_;
2017-05-18 21:58:01 +02:00
}
2017-06-11 23:11:14 +02:00
loadParentAndItem(change) {
if (change.item_type == BaseModel.ITEM_TYPE_NOTE) {
return Note.load(change.item_id).then((note) => {
if (!note) return { parent:null, item: null };
return Folder.load(note.parent_id).then((folder) => {
return Promise.resolve({ parent: folder, item: note });
});
});
} else {
return Folder.load(change.item_id).then((folder) => {
return Promise.resolve({ parent: null, item: folder });
});
}
}
2017-06-12 23:56:27 +02:00
remoteFileByPath(remoteFiles, path) {
2017-06-11 23:11:14 +02:00
for (let i = 0; i < remoteFiles.length; i++) {
2017-06-12 23:56:27 +02:00
if (remoteFiles[i].path == path) return remoteFiles[i];
2017-06-11 23:11:14 +02:00
}
return null;
}
conflictDir(remoteFiles) {
2017-06-12 23:56:27 +02:00
let d = this.remoteFileByPath('Conflicts');
2017-06-11 23:11:14 +02:00
if (!d) {
return this.api().mkdir('Conflicts').then(() => {
return 'Conflicts';
});
} else {
return Promise.resolve('Conflicts');
}
}
moveConflict(item) {
// No need to handle folder conflicts
2017-06-15 20:18:48 +02:00
if (item.type == 'folder') return Promise.resolve();
2017-06-11 23:11:14 +02:00
return this.conflictDir().then((conflictDirPath) => {
2017-06-12 23:56:27 +02:00
let p = path.basename(item.path).split('.');
2017-06-15 20:18:48 +02:00
let pos = item.type == 'folder' ? p.length - 1 : p.length - 2;
2017-06-11 23:11:14 +02:00
p.splice(pos, 0, moment().format('YYYYMMDDThhmmss'));
2017-06-12 23:56:27 +02:00
let newPath = p.join('.');
return this.api().move(item.path, conflictDirPath + '/' + newPath);
2017-06-11 23:11:14 +02:00
});
}
2017-06-13 22:58:17 +02:00
itemByPath(items, path) {
for (let i = 0; i < items.length; i++) {
if (items[i].path == path) return items[i];
}
return null;
}
2017-06-14 00:39:45 +02:00
itemIsSameDate(item, date) {
2017-06-16 00:12:00 +02:00
return item.updatedTime === date;
2017-06-14 00:39:45 +02:00
}
2017-06-16 00:12:00 +02:00
itemIsStrictlyNewerThan(item, date) {
2017-06-13 22:58:17 +02:00
return item.updatedTime > date;
}
2017-06-16 00:12:00 +02:00
itemIsStrictlyOlderThan(item, date) {
2017-06-14 00:39:45 +02:00
return item.updatedTime < date;
2017-06-13 22:58:17 +02:00
}
2017-06-15 01:14:15 +02:00
dbItemToSyncItem(dbItem) {
2017-06-15 20:18:48 +02:00
if (!dbItem) return null;
return {
2017-06-18 22:19:13 +02:00
type: dbItem.type_ == BaseModel.ITEM_TYPE_FOLDER ? 'folder' : 'note',
2017-06-15 20:18:48 +02:00
path: Folder.systemPath(dbItem),
syncTime: dbItem.sync_time,
updatedTime: dbItem.updated_time,
dbItem: dbItem,
};
}
2017-06-15 01:14:15 +02:00
2017-06-15 20:18:48 +02:00
remoteItemToSyncItem(remoteItem) {
if (!remoteItem) return null;
return {
2017-06-18 22:19:13 +02:00
type: remoteItem.content.type_ == BaseModel.ITEM_TYPE_FOLDER ? 'folder' : 'note',
2017-06-15 20:18:48 +02:00
path: remoteItem.path,
syncTime: 0,
updatedTime: remoteItem.updatedTime,
remoteItem: remoteItem,
};
2017-06-15 01:14:15 +02:00
}
syncAction(localItem, remoteItem, deletedLocalPaths) {
let output = this.syncActions(localItem ? [localItem] : [], remoteItem ? [remoteItem] : [], deletedLocalPaths);
2017-06-15 20:18:48 +02:00
if (output.length > 1) throw new Error('Invalid number of actions returned');
return output.length ? output[0] : null;
2017-06-15 01:14:15 +02:00
}
2017-06-14 00:39:45 +02:00
// Assumption: it's not possible to, for example, have a directory one the dest
// and a file with the same name on the source. It's not possible because the
// file and directory names are UUID so should be unique.
2017-06-15 01:14:15 +02:00
// Each item must have these properties:
// - path
2017-06-15 20:18:48 +02:00
// - type
2017-06-15 01:14:15 +02:00
// - syncTime
// - updatedTime
2017-06-14 00:39:45 +02:00
syncActions(localItems, remoteItems, deletedLocalPaths) {
2017-06-13 22:58:17 +02:00
let output = [];
2017-06-14 00:39:45 +02:00
let donePaths = [];
2017-06-13 22:58:17 +02:00
2017-06-15 20:18:48 +02:00
// console.info('==================================================');
// console.info(localItems, remoteItems);
2017-06-13 22:58:17 +02:00
for (let i = 0; i < localItems.length; i++) {
2017-06-14 00:39:45 +02:00
let local = localItems[i];
let remote = this.itemByPath(remoteItems, local.path);
2017-06-13 22:58:17 +02:00
let action = {
2017-06-14 00:39:45 +02:00
local: local,
remote: remote,
2017-06-13 22:58:17 +02:00
};
2017-06-14 00:39:45 +02:00
if (!remote) {
2017-06-15 01:14:15 +02:00
if (local.syncTime) {
2017-06-14 00:39:45 +02:00
action.type = 'delete';
action.dest = 'local';
2017-06-18 01:53:19 +02:00
action.reason = 'Local has been synced to remote previously, but remote no longer exist, which means remote has been deleted';
2017-06-14 00:39:45 +02:00
} else {
action.type = 'create';
action.dest = 'remote';
2017-06-18 01:53:19 +02:00
action.reason = 'Local has never been synced to remote, and remote does not exists, which means remote must be created';
2017-06-14 00:39:45 +02:00
}
2017-06-13 22:58:17 +02:00
} else {
2017-06-16 00:12:00 +02:00
if (this.itemIsStrictlyOlderThan(local, local.syncTime)) continue;
2017-06-14 00:39:45 +02:00
2017-06-18 01:49:52 +02:00
if (this.itemIsStrictlyOlderThan(remote, local.updatedTime)) {
2017-06-13 22:58:17 +02:00
action.type = 'update';
2017-06-14 00:39:45 +02:00
action.dest = 'remote';
2017-06-18 22:19:13 +02:00
action.reason = sprintf('Remote (%s) was modified before updated time of local (%s).', moment.unix(remote.updatedTime).toISOString(), moment.unix(local.syncTime).toISOString(),);
} else if (this.itemIsStrictlyNewerThan(remote, local.syncTime) && this.itemIsStrictlyNewerThan(local, local.syncTime)) {
2017-06-14 00:39:45 +02:00
action.type = 'conflict';
2017-06-18 01:53:19 +02:00
action.reason = sprintf('Both remote (%s) and local (%s) were modified after the last sync (%s).',
2017-06-16 00:12:00 +02:00
moment.unix(remote.updatedTime).toISOString(),
moment.unix(local.updatedTime).toISOString(),
moment.unix(local.syncTime).toISOString()
);
2017-06-15 20:18:48 +02:00
if (local.type == 'folder') {
2017-06-14 00:39:45 +02:00
action.solution = [
{ type: 'update', dest: 'local' },
];
} else {
action.solution = [
{ type: 'copy-to-remote-conflict-dir', dest: 'local' },
{ type: 'copy-to-local-conflict-dir', dest: 'local' },
{ type: 'update', dest: 'local' },
];
}
2017-06-18 22:19:13 +02:00
} else if (this.itemIsStrictlyNewerThan(remote, local.syncTime) && local.updatedTime <= local.syncTime) {
action.type = 'update';
action.dest = 'local';
action.reason = sprintf('Remote (%s) was modified after update time of local (%s). And sync time (%s) is the same or more recent than local update time', moment.unix(remote.updatedTime).toISOString(), moment.unix(local.updatedTime).toISOString(), moment.unix(local.syncTime).toISOString());
2017-06-16 00:12:00 +02:00
} else {
continue; // Neither local nor remote item have been changed recently
2017-06-14 00:39:45 +02:00
}
}
donePaths.push(local.path);
output.push(action);
}
for (let i = 0; i < remoteItems.length; i++) {
let remote = remoteItems[i];
if (donePaths.indexOf(remote.path) >= 0) continue; // Already handled in the previous loop
let local = this.itemByPath(localItems, remote.path);
let action = {
local: local,
remote: remote,
};
if (!local) {
if (deletedLocalPaths.indexOf(remote.path) >= 0) {
action.type = 'delete';
action.dest = 'remote';
2017-06-13 22:58:17 +02:00
} else {
2017-06-14 00:39:45 +02:00
action.type = 'create';
action.dest = 'local';
2017-06-13 22:58:17 +02:00
}
2017-06-14 00:39:45 +02:00
} else {
2017-06-16 00:12:00 +02:00
if (this.itemIsStrictlyOlderThan(remote, local.syncTime)) continue; // Already have this version
2017-06-18 01:49:52 +02:00
2017-06-14 00:39:45 +02:00
// Note: no conflict is possible here since if the local item has been
// modified since the last sync, it's been processed in the previous loop.
2017-06-18 01:49:52 +02:00
// So throw an exception is this normally impossible condition happens anyway.
// It's handled at condition this.itemIsStrictlyNewerThan(remote, local.syncTime) in above loop
2017-06-18 22:19:13 +02:00
if (this.itemIsStrictlyNewerThan(remote, local.syncTime)) {
console.error('Remote cannot be newer than last sync time', remote, local);
throw new Error('Remote cannot be newer than last sync time');
}
2017-06-18 01:49:52 +02:00
if (this.itemIsStrictlyNewerThan(remote, local.updatedTime)) {
action.type = 'update';
action.dest = 'local';
2017-06-18 01:53:19 +02:00
action.reason = sprintf('Remote (%s) was modified after local (%s).', moment.unix(remote.updatedTime).toISOString(), moment.unix(local.updatedTime).toISOString(),);;
2017-06-18 01:49:52 +02:00
} else {
continue;
}
2017-06-13 22:58:17 +02:00
}
output.push(action);
}
2017-06-15 20:18:48 +02:00
// console.info('-----------------------------------------');
// console.info(output);
2017-06-13 22:58:17 +02:00
2017-06-15 20:18:48 +02:00
return output;
2017-06-03 18:20:17 +02:00
}
processState(state) {
Log.info('Sync: processing: ' + state);
this.state_ = state;
if (state == 'uploadChanges') {
2017-06-11 23:11:14 +02:00
return this.processState_uploadChanges();
2017-06-03 18:20:17 +02:00
} else if (state == 'downloadChanges') {
2017-06-15 20:18:48 +02:00
//return this.processState('idle');
return this.processState_downloadChanges();
2017-06-03 18:20:17 +02:00
} else if (state == 'idle') {
// Nothing
2017-06-15 20:18:48 +02:00
return Promise.resolve();
2017-06-03 18:20:17 +02:00
} else {
throw new Error('Invalid state: ' . state);
2017-05-18 21:58:01 +02:00
}
}
2017-06-15 01:14:15 +02:00
processSyncAction(action) {
2017-06-15 23:46:53 +02:00
//console.info('Sync action: ', action);
2017-06-15 20:18:48 +02:00
//console.info('Sync action: ' + JSON.stringify(action));
if (!action) return Promise.resolve();
2017-06-15 01:14:15 +02:00
2017-06-18 01:49:52 +02:00
console.info('Sync action: ' + action.type + ' ' + action.dest + ': ' + action.reason);
2017-06-16 00:12:00 +02:00
2017-06-15 01:14:15 +02:00
if (action.type == 'conflict') {
2017-06-16 00:12:00 +02:00
console.info(action);
2017-06-15 01:14:15 +02:00
} else {
2017-06-15 20:18:48 +02:00
let syncItem = action[action.dest == 'local' ? 'remote' : 'local'];
let path = syncItem.path;
2017-06-15 01:14:15 +02:00
if (action.type == 'create') {
if (action.dest == 'remote') {
2017-06-15 20:18:48 +02:00
let content = null;
2017-06-18 22:19:13 +02:00
let dbItem = syncItem.dbItem;
2017-06-15 20:18:48 +02:00
if (syncItem.type == 'folder') {
2017-06-18 22:19:13 +02:00
content = Folder.toFriendlyString(dbItem);
2017-06-15 20:18:48 +02:00
} else {
2017-06-18 22:19:13 +02:00
content = Note.toFriendlyString(dbItem);
2017-06-15 20:18:48 +02:00
}
return this.api().put(path, content).then(() => {
2017-06-18 22:19:13 +02:00
return this.api().setTimestamp(path, dbItem.updated_time);
2017-06-15 20:18:48 +02:00
});
2017-06-18 22:19:13 +02:00
// TODO: save sync_time
2017-06-15 20:18:48 +02:00
} else {
let dbItem = syncItem.remoteItem.content;
dbItem.sync_time = time.unix();
2017-06-18 22:19:13 +02:00
dbItem.updated_time = action.remote.updatedTime;
2017-06-15 20:18:48 +02:00
if (syncItem.type == 'folder') {
2017-06-18 01:49:52 +02:00
return Folder.save(dbItem, { isNew: true, autoTimestamp: false });
2017-06-15 01:14:15 +02:00
} else {
2017-06-18 01:49:52 +02:00
return Note.save(dbItem, { isNew: true, autoTimestamp: false });
2017-06-15 01:14:15 +02:00
}
2017-06-18 22:19:13 +02:00
// TODO: save sync_time
2017-06-15 01:14:15 +02:00
}
}
2017-06-15 20:18:48 +02:00
if (action.type == 'update') {
if (action.dest == 'remote') {
2017-06-18 22:19:13 +02:00
let dbItem = syncItem.dbItem;
let ItemClass = BaseItem.itemClass(dbItem);
let content = ItemClass.toFriendlyString(dbItem);
//console.info('PUT', content);
return this.api().put(path, content).then(() => {
return this.api().setTimestamp(path, dbItem.updated_time);
}).then(() => {
let toSave = { id: dbItem.id, sync_time: time.unix() };
return NoteFolderService.save(syncItem.type, dbItem, null, { autoTimestamp: false });
});
2017-06-15 20:18:48 +02:00
} else {
2017-06-18 22:19:13 +02:00
let dbItem = Object.assign({}, syncItem.remoteItem.content);
2017-06-15 20:18:48 +02:00
dbItem.sync_time = time.unix();
2017-06-18 01:49:52 +02:00
return NoteFolderService.save(syncItem.type, dbItem, action.local.dbItem, { autoTimestamp: false });
2017-06-15 20:18:48 +02:00
}
}
2017-06-15 01:14:15 +02:00
}
return Promise.resolve(); // TODO
}
2017-06-15 23:46:53 +02:00
async processLocalItem(dbItem) {
2017-06-15 20:18:48 +02:00
let localItem = this.dbItemToSyncItem(dbItem);
2017-06-15 23:46:53 +02:00
let remoteItem = await this.api().stat(localItem.path);
let action = this.syncAction(localItem, remoteItem, []);
await this.processSyncAction(action);
2017-06-15 01:14:15 +02:00
2017-06-18 22:19:13 +02:00
let toSave = Object.assign({}, dbItem);
toSave.sync_time = time.unix();
return NoteFolderService.save(localItem.type, toSave, dbItem, { autoTimestamp: false });
2017-06-15 20:18:48 +02:00
}
2017-06-14 21:59:46 +02:00
2017-06-15 23:46:53 +02:00
async processRemoteItem(remoteItem) {
let content = await this.api().get(remoteItem.path);
2017-06-18 01:49:52 +02:00
if (!content) throw new Error('Cannot get content for: ' + remoteItem.path);
2017-06-15 23:46:53 +02:00
remoteItem.content = Note.fromFriendlyString(content);
let remoteSyncItem = this.remoteItemToSyncItem(remoteItem);
2017-06-15 01:14:15 +02:00
2017-06-15 23:46:53 +02:00
let dbItem = await BaseItem.loadItemByPath(remoteItem.path);
let localSyncItem = this.dbItemToSyncItem(dbItem);
2017-06-15 01:14:15 +02:00
2017-06-15 23:46:53 +02:00
let action = this.syncAction(localSyncItem, remoteSyncItem, []);
return this.processSyncAction(action);
2017-06-15 20:18:48 +02:00
}
2017-06-15 23:46:53 +02:00
async processState_uploadChanges() {
while (true) {
let result = await NoteFolderService.itemsThatNeedSync(50);
2017-06-18 01:49:52 +02:00
console.info('Items that need sync: ' + result.items.length);
2017-06-15 23:46:53 +02:00
for (let i = 0; i < result.items.length; i++) {
let item = result.items[i];
await this.processLocalItem(item);
2017-06-15 20:18:48 +02:00
}
2017-06-15 23:46:53 +02:00
if (!result.hasMore) break;
}
2017-06-18 01:49:52 +02:00
//console.info('DOWNLOAD CHANGE DISABLED'); return Promise.resolve();
2017-06-15 23:46:53 +02:00
return this.processState('downloadChanges');
}
async processState_downloadChanges() {
let items = await this.api().list();
for (let i = 0; i < items.length; i++) {
await this.processRemoteItem(items[i]);
}
2017-06-16 00:12:00 +02:00
return this.processState('idle');
2017-06-15 20:18:48 +02:00
}
2017-06-15 01:14:15 +02:00
2017-06-15 20:18:48 +02:00
start() {
Log.info('Sync: start');
2017-06-15 01:14:15 +02:00
2017-06-15 20:18:48 +02:00
if (this.state() != 'idle') {
return Promise.reject('Cannot start synchronizer because synchronization already in progress. State: ' + this.state());
}
this.state_ = 'started';
2017-06-14 21:59:46 +02:00
2017-06-11 23:11:14 +02:00
// if (!this.api().session()) {
// Log.info("Sync: cannot start synchronizer because user is not logged in.");
// return;
// }
2017-05-18 21:58:01 +02:00
2017-06-18 01:49:52 +02:00
return this.processState('uploadChanges').catch((error) => {
console.info('Synchronizer error:', error);
throw error;
});
2017-05-18 21:58:01 +02:00
}
2017-06-15 01:14:15 +02:00
2017-05-18 21:58:01 +02:00
}
export { Synchronizer };