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

563 lines
17 KiB
JavaScript
Raw Normal View History

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';
import { BaseModel } from 'src/base-model.js';
2017-05-19 21:12:09 +02:00
import { promiseChain } from 'src/promise-chain.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
if (item.isDir) return Promise.resolve();
return this.conflictDir().then((conflictDirPath) => {
2017-06-12 23:56:27 +02:00
let p = path.basename(item.path).split('.');
2017-06-11 23:11:14 +02:00
let pos = item.isDir ? p.length - 1 : p.length - 2;
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
// isNewerThan(date1, date2) {
// return date1 > date2;
// }
itemByPath(items, path) {
for (let i = 0; i < items.length; i++) {
if (items[i].path == path) return items[i];
}
return null;
}
syncAction(actionType, where, item, isConflict) {
return {
type: actionType,
where: where,
item: item,
};
}
2017-06-14 00:39:45 +02:00
itemIsSameDate(item, date) {
return Math.abs(item.updatedTime - date) <= 1;
}
2017-06-13 22:58:17 +02:00
itemIsNewerThan(item, date) {
2017-06-14 00:39:45 +02:00
if (this.itemIsSameDate(item, date)) return false;
2017-06-13 22:58:17 +02:00
return item.updatedTime > date;
}
itemIsOlderThan(item, date) {
2017-06-14 00:39:45 +02:00
if (this.itemIsSameDate(item, date)) return false;
return item.updatedTime < date;
2017-06-13 22:58:17 +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.
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
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) {
if (local.lastSyncTime) {
// The item has been synced previously and now is no longer in the dest
// which means it has been deleted.
action.type = 'delete';
action.dest = 'local';
} else {
// The item has never been synced and is not present in the dest
// which means it is new
action.type = 'create';
action.dest = 'remote';
}
2017-06-13 22:58:17 +02:00
} else {
2017-06-14 00:39:45 +02:00
if (this.itemIsOlderThan(local, local.lastSyncTime)) continue;
if (this.itemIsOlderThan(remote, local.lastSyncTime)) {
2017-06-13 22:58:17 +02:00
action.type = 'update';
2017-06-14 00:39:45 +02:00
action.dest = 'remote';
} else {
action.type = 'conflict';
if (local.isDir) {
// For folders, currently we don't completely handle conflicts, we just
// we just update the local dir (.folder metadata file) with the remote
// version. It means the local version is lost but shouldn't be a big deal
// and should be rare (at worst, the folder name needs to renamed).
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' },
];
}
}
}
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 {
if (this.itemIsOlderThan(remote, local.lastSyncTime)) continue; // Already have this version
// 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.
action.type = 'update';
action.dest = 'local';
2017-06-13 22:58:17 +02:00
}
output.push(action);
}
return output;
}
2017-06-03 18:20:17 +02:00
processState_uploadChanges() {
2017-06-11 23:11:14 +02:00
let remoteFiles = [];
let processedChangeIds = [];
return this.api().list('', true).then((items) => {
remoteFiles = items;
return Change.all();
}).then((changes) => {
2017-06-03 18:20:17 +02:00
let mergedChanges = Change.mergeChanges(changes);
let chain = [];
2017-06-12 23:56:27 +02:00
const lastSyncTime = Setting.value('sync.lastUpdateTime');
2017-06-03 18:20:17 +02:00
for (let i = 0; i < mergedChanges.length; i++) {
let c = mergedChanges[i];
chain.push(() => {
let p = null;
2017-05-20 00:16:50 +02:00
let ItemClass = null;
2017-06-03 18:20:17 +02:00
if (c.item_type == BaseModel.ITEM_TYPE_FOLDER) {
2017-05-20 00:16:50 +02:00
ItemClass = Folder;
2017-06-03 18:20:17 +02:00
} else if (c.item_type == BaseModel.ITEM_TYPE_NOTE) {
2017-05-20 00:16:50 +02:00
ItemClass = Note;
}
2017-05-19 21:12:09 +02:00
2017-06-03 18:20:17 +02:00
if (c.type == Change.TYPE_NOOP) {
p = Promise.resolve();
} else if (c.type == Change.TYPE_CREATE) {
2017-06-12 23:56:27 +02:00
p = this.loadParentAndItem(c).then((result) => {
let item = result.item;
let parent = result.parent;
if (!item) return; // Change refers to an object that doesn't exist (has probably been deleted directly in the database)
let path = ItemClass.systemPath(parent, item);
let remoteFile = this.remoteFileByPath(remoteFiles, path);
let p = null;
if (remoteFile) {
p = this.moveConflict(remoteFile);
} else {
p = Promise.resolve();
}
return p.then(() => {
if (c.item_type == BaseModel.ITEM_TYPE_FOLDER) {
return this.api().mkdir(path).then(() => {
return this.api().put(Folder.systemMetadataPath(parent, item), Folder.toFriendlyString(item));
}).then(() => {
2017-06-13 22:12:08 +02:00
return this.api().setTimestamp(Folder.systemMetadataPath(parent, item), item.updated_time);
2017-06-12 23:56:27 +02:00
});
} else {
return this.api().put(path, Note.toFriendlyString(item)).then(() => {
2017-06-13 22:12:08 +02:00
return this.api().setTimestamp(path, item.updated_time);
2017-06-12 23:56:27 +02:00
});
}
});
});
} else if (c.type == Change.TYPE_UPDATE) {
2017-06-11 23:11:14 +02:00
p = this.loadParentAndItem(c).then((result) => {
if (!result.item) return; // Change refers to an object that doesn't exist (has probably been deleted directly in the database)
let path = ItemClass.systemPath(result.parent, result.item);
2017-06-12 23:56:27 +02:00
let remoteFile = this.remoteFileByPath(remoteFiles, path);
2017-06-11 23:11:14 +02:00
let p = null;
2017-06-12 23:56:27 +02:00
if (remoteFile && remoteFile.updatedTime > lastSyncTime) {
console.info('CONFLICT:', lastSyncTime, remoteFile);
//console.info(moment.unix(remoteFile.updatedTime), moment.unix(result.item.updated_time));
2017-06-11 23:11:14 +02:00
p = this.moveConflict(remoteFile);
} else {
p = Promise.resolve();
}
2017-06-12 23:56:27 +02:00
console.info('Uploading change:', JSON.stringify(result.item));
2017-06-11 23:11:14 +02:00
return p.then(() => {
if (c.item_type == BaseModel.ITEM_TYPE_FOLDER) {
2017-06-12 23:56:27 +02:00
return this.api().put(Folder.systemMetadataPath(result.parent, result.item), Folder.toFriendlyString(result.item));
2017-06-11 23:11:14 +02:00
} else {
return this.api().put(path, Note.toFriendlyString(result.item));
}
});
2017-05-20 00:16:50 +02:00
});
}
2017-05-19 21:32:49 +02:00
2017-06-11 23:11:14 +02:00
// TODO: handle DELETE
2017-06-03 18:20:17 +02:00
return p.then(() => {
processedChangeIds = processedChangeIds.concat(c.ids);
}).catch((error) => {
2017-06-11 23:11:14 +02:00
Log.warn('Failed applying changes', c.ids, error);
2017-06-03 18:20:17 +02:00
// This is fine - trying to apply changes to an object that has been deleted
2017-06-11 23:11:14 +02:00
// if (error.type == 'NotFoundException') {
// processedChangeIds = processedChangeIds.concat(c.ids);
// } else {
// throw error;
// }
2017-06-03 18:20:17 +02:00
});
});
}
2017-06-11 23:11:14 +02:00
return promiseChain(chain);
2017-06-12 23:56:27 +02:00
// }).then(() => {
// console.info(remoteFiles);
// for (let i = 0; i < remoteFiles.length; i++) {
// const remoteFile = remoteFiles[i];
// }
2017-06-11 23:11:14 +02:00
}).catch((error) => {
2017-06-12 23:56:27 +02:00
Log.error('Synchronization was interrupted due to an error:', error);
2017-06-11 23:11:14 +02:00
}).then(() => {
2017-06-12 23:56:27 +02:00
//Log.info('IDs to delete: ', processedChangeIds);
//return Change.deleteMultiple(processedChangeIds);
2017-06-03 18:20:17 +02:00
}).then(() => {
this.processState('downloadChanges');
});
2017-06-11 23:11:14 +02:00
// }).then(() => {
// return 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 = this.loadParentAndItem(c).then((result) => {
// // let options = {
// // contents: Note.toFriendlyString(result.item),
// // path: Note.systemPath(result.parent, result.item),
// // mode: 'overwrite',
// // // client_modified:
// // };
// // return this.api().filesUpload(options).then((result) => {
// // console.info('DROPBOX', result);
// // });
// });
// // p = ItemClass.load(c.item_id).then((item) => {
// // console.info(item);
// // let options = {
// // contents: Note.toFriendlyString(item),
// // path: Note.systemPath(item),
// // mode: 'overwrite',
// // // client_modified:
// // };
// // // console.info(options);
// // //let content = Note.toFriendlyString(item);
// // //console.info(content);
// // //console.info('SYNC', 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);
// }).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;
// }
// });
// });
// }
// 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');
// });
// });
2017-06-03 18:20:17 +02:00
}
2017-05-20 00:16:50 +02:00
2017-06-03 18:20:17 +02:00
processState_downloadChanges() {
2017-06-12 23:56:27 +02:00
// return this.api().list('', true).then((items) => {
// remoteFiles = items;
// return Change.all();
2017-05-19 21:12:09 +02:00
2017-06-12 23:56:27 +02:00
// let maxRevId = null;
// let hasMore = false;
// this.api().get('synchronizer', { rev_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 });
// });
// });
// }
// 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);
// });
// let maxRevId = null;
// let hasMore = false;
// this.api().get('synchronizer', { rev_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 });
// });
// });
// }
// 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);
// });
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-11 23:11:14 +02:00
return this.processState('idle');
//this.processState_downloadChanges();
2017-06-03 18:20:17 +02:00
} else if (state == 'idle') {
// Nothing
} else {
throw new Error('Invalid state: ' . state);
2017-05-18 21:58:01 +02:00
}
}
start() {
2017-05-19 21:12:09 +02:00
Log.info('Sync: start');
2017-05-18 21:58:01 +02:00
if (this.state() != 'idle') {
Log.info("Sync: cannot start synchronizer because synchronization already in progress. State: " + this.state());
return;
}
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-11 23:11:14 +02:00
return this.processState('uploadChanges');
2017-05-18 21:58:01 +02:00
}
}
export { Synchronizer };