From 9060ed489c08b47b037678d4ff9e63ca2096e07f Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Thu, 29 Jun 2017 18:03:16 +0000 Subject: [PATCH] Fixed onedrive sync issue --- CliClient/app/import-enex.js | 15 +- CliClient/app/main.js | 172 ++++++++++------- CliClient/package.json | 2 +- CliClient/run.sh | 4 +- CliClient/tests/synchronizer.js | 3 +- lib/file-api-driver-local.js | 8 +- lib/file-api-driver-memory.js | 8 +- lib/file-api-driver-onedrive.js | 23 ++- lib/file-api.js | 14 +- lib/onedrive-api.js | 42 +++- lib/synchronizer.js | 328 ++++++++++++++++---------------- 11 files changed, 358 insertions(+), 261 deletions(-) diff --git a/CliClient/app/import-enex.js b/CliClient/app/import-enex.js index bf9089ebe..30c113165 100644 --- a/CliClient/app/import-enex.js +++ b/CliClient/app/import-enex.js @@ -50,19 +50,8 @@ function createNoteId(note) { } async function fuzzyMatch(note) { - let notes = await Note.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0 AND created_time = ?', [note.created_time]); - if (!notes.length) return null; - if (notes.length === 1) return notes[0]; - - for (let i = 0; i < notes.length; i++) { - if (notes[i].title == note.title && note.title.trim() != '') return notes[i]; - } - - for (let i = 0; i < notes.length; i++) { - if (notes[i].body == note.body && note.body.trim() != '') return notes[i]; - } - - return null; + let notes = await Note.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0 AND created_time = ? AND title = ?', [note.created_time, note.title]); + return notes.length !== 1 ? null : notes[0]; } async function saveNoteResources(note) { diff --git a/CliClient/app/main.js b/CliClient/app/main.js index 9037abe46..c43cf197e 100644 --- a/CliClient/app/main.js +++ b/CliClient/app/main.js @@ -42,21 +42,6 @@ let logger = new Logger(); let dbLogger = new Logger(); let syncLogger = new Logger(); -// commands.push({ -// usage: 'root', -// options: [ -// ['--profile ', 'Sets the profile path directory.'], -// ], -// action: function(args, end) { -// if (args.profile) { -// initArgs.profileDir = args.profile; -// args.splice(0, 2); -// } - -// end(args); -// }, -// }); - commands.push({ usage: 'version', description: 'Displays version information', @@ -433,7 +418,7 @@ function commandByName(name) { for (let i = 0; i < commands.length; i++) { let c = commands[i]; let n = c.usage.split(' '); - n = n[0]; + n = n[0].trim(); if (n == name) return c; if (c.aliases && c.aliases.indexOf(name) >= 0) return c; } @@ -453,6 +438,58 @@ function execCommand(name, args) { }); } +// async function execCommand(args) { +// var parseArgs = require('minimist'); + +// let results = parseArgs(args); +// //var results = vorpal.parse(args, { use: 'minimist' }); +// if (!results['_'].length) throw new Error(_('Invalid command: %s', args)); + +// console.info(results); + +// let commandName = results['_'].splice(0, 1); +// let cmd = commandByName(commandName); +// if (!cmd) throw new Error(_('Unknown command: %s', args)); + + +// let usage = cmd.usage.split(' '); +// let commandArgs = []; +// usage.splice(0, 1); +// for (let i = 0; i < usage.length; i++) { +// let u = usage[i].trim(); +// if (u == '') continue; + +// let required = false; + +// if (u.length >= 3 && u[0] == '<' && u[u.length - 1] == '>') { +// required = true; +// u = u.substr(1, u.length - 2); +// } + +// if (u.length >= 3 && u[0] == '[' && u[u.length - 1] == ']') { +// u = u.substr(1, u.length - 2); +// } + +// if (required && !results['_'].length) throw new Error(_('Missing argument: %s', args)); + +// if (!results['_'].length) break; + +// console.info(u); + +// commandArgs[u] = results['_'].splice(0, 1); +// } + +// console.info(commandArgs); + + +// // usage: 'import-enex [notebook]', +// // description: _('Imports en Evernote notebook file (.enex file).'), +// // options: [ +// // ['--fuzzy-matching', 'For debugging purposes. Do not use.'], +// // ], + +// } + async function synchronizer(syncTarget) { if (synchronizers_[syncTarget]) return synchronizers_[syncTarget]; @@ -580,53 +617,49 @@ function cmdPromptConfirm(commandInstance, message) { }); } -// Handles the initial arguments passed to main script and -// route them to the "root" command. -function handleStartArgs(argv) { - return new Promise((resolve, reject) => { - while (true) { +// Handles the initial flags passed to main script and +// returns the remaining args. +async function handleStartFlags(argv) { + argv = argv.slice(0); + argv.splice(0, 2); // First arguments are the node executable, and the node JS file + + while (argv.length) { + let arg = argv[0]; + let nextArg = argv.length >= 2 ? argv[1] : null; + + if (arg == '--profile') { + if (!nextArg) { + throw new Error(_('Usage: --profile ')); + } + initArgs.profileDir = nextArg; argv.splice(0, 2); - - if (argv[0] == '--profile') { - argv.splice(0, 1); - if (!argv.length) throw new Error(_('Profile path is missing')); - initArgs.profileDir = argv[0]; - argv.splice(0, 1); - } else if (argv[0][0] === '-') { - throw new Error(_('Unknown flag: "%s"', argv[0])); - } - - if (!argv.length || argv[0][0] != '-') { - resolve(argv); - } - - return; - - // if (argv && argv.length >= 3 && argv[2][0] == '-') { - // const startParams = vorpal.parse(argv, { use: 'minimist' }); - // const cmd = commandByName('root'); - // cmd.action(startParams, (newArgs) => { - // console.info(newArgs); - // resolve(); - // }); - // } else { - // console.info(argv); - // resolve(); - // } - + continue; } - // if (argv && argv.length >= 3 && argv[2][0] == '-') { - // const startParams = vorpal.parse(argv, { use: 'minimist' }); - // const cmd = commandByName('root'); - // cmd.action(startParams, (newArgs) => { - // console.info(newArgs); - // resolve(); - // }); - // } else { - // console.info(argv); - // resolve(); - // } - }); + + if (arg.length && arg[0] == '-') { + throw new Error(_('Unknown flag: %s', arg)); + } else { + break; + } + } + + return argv; +} + +function escapeShellArg(arg) { + if (arg.indexOf('"') >= 0 && arg.indexOf("'") >= 0) throw new Error(_('Command line argument "%s" contains both quotes and double-quotes - aborting.', arg)); // Hopeless case + let quote = '"'; + if (arg.indexOf('"') >= 0) quote = "'"; + if (arg.indexOf(' ') >= 0 || arg.indexOf("\t") >= 0) return quote + arg + quote; + return arg; +} + +function shellArgsToString(args) { + let output = []; + for (let i = 0; i < args.length; i++) { + output.push(escapeShellArg(args[i])); + } + return output.join(' '); } process.stdin.on('keypress', (_, key) => { @@ -645,7 +678,6 @@ const vorpal = require('vorpal')(); async function main() { for (let commandIndex = 0; commandIndex < commands.length; commandIndex++) { let c = commands[commandIndex]; - if (c.usage == 'root') continue; let o = vorpal.command(c.usage, c.description); if (c.options) { for (let i = 0; i < c.options.length; i++) { @@ -669,7 +701,8 @@ async function main() { vorpal.history('net.cozic.joplin'); // Enables persistent history - await handleStartArgs(process.argv); + let argv = process.argv; + argv = await handleStartFlags(argv); const profileDir = initArgs.profileDir ? initArgs.profileDir : os.homedir() + '/.config/' + Setting.value('appName'); const resourceDir = profileDir + '/resources'; @@ -704,10 +737,19 @@ async function main() { if (!activeFolder) activeFolder = await Folder.defaultFolder(); if (!activeFolder) activeFolder = await Folder.createDefaultFolder(); if (!activeFolder) throw new Error(_('No default notebook is defined and could not create a new one. The database might be corrupted, please delete it and try again.')); + Setting.setValue('activeFolderId', activeFolder.id); - if (activeFolder) await execCommand('cd', { 'notebook': activeFolder.title }); // Use execCommand() so that no history entry is created - + await execCommand('cd', { 'notebook': activeFolder.title }); // Use execCommand() so that no history entry is created vorpal.delimiter(promptString()).show(); + + // If we still have arguments, pass it to Vorpal and exit + if (argv.length) { + let cmd = shellArgsToString(argv); + vorpal.log(_('Executing: %s', cmd)); + await vorpal.exec(cmd); + await vorpal.exec('exit'); + return; + } } main().catch((error) => { diff --git a/CliClient/package.json b/CliClient/package.json index 2fefa5e54..9914a382c 100644 --- a/CliClient/package.json +++ b/CliClient/package.json @@ -1,6 +1,6 @@ { "name": "joplin-cli", - "version": "0.8.11", + "version": "0.8.14", "bin": { "joplin": "./main.sh" }, diff --git a/CliClient/run.sh b/CliClient/run.sh index 009d13a80..20d8ba132 100755 --- a/CliClient/run.sh +++ b/CliClient/run.sh @@ -1,4 +1,6 @@ #!/bin/bash set -e CLIENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -bash $CLIENT_DIR/build.sh && NODE_PATH="$CLIENT_DIR/build/" node build/main.js --profile ~/Temp/TestNotes import-enex --fuzzy-matching /home/laurent/Downloads/desktop/afaire.enex afaire \ No newline at end of file +bash $CLIENT_DIR/build.sh && NODE_PATH="$CLIENT_DIR/build/" node build/main.js --profile ~/Temp/TestNotes +#bash $CLIENT_DIR/build.sh && NODE_PATH="$CLIENT_DIR/build/" node build/main.js --profile ~/Temp/TestNotes import-enex --fuzzy-matching /home/laurent/Desktop/afaire.enex afaire +#bash $CLIENT_DIR/build.sh && NODE_PATH="$CLIENT_DIR/build/" node build/main.js --profile ~/Temp/TestNotes import-enex --fuzzy-matching /home/laurent/Desktop/Laurent.enex laurent \ No newline at end of file diff --git a/CliClient/tests/synchronizer.js b/CliClient/tests/synchronizer.js index c9888baf6..032093848 100644 --- a/CliClient/tests/synchronizer.js +++ b/CliClient/tests/synchronizer.js @@ -20,6 +20,7 @@ async function allItems() { async function localItemsSameAsRemote(locals, expect) { try { let files = await fileApi().list(); + files = files.items; expect(locals.length).toBe(files.length); for (let i = 0; i < locals.length; i++) { @@ -116,7 +117,6 @@ describe('Synchronizer', function() { await synchronizer().start(); let all = await allItems(); - let files = await fileApi().list(); await localItemsSameAsRemote(all, expect); @@ -227,6 +227,7 @@ describe('Synchronizer', function() { await synchronizer().start(); let files = await fileApi().list(); + files = files.items; expect(files.length).toBe(1); expect(files[0].path).toBe(Folder.systemPath(folder1)); diff --git a/lib/file-api-driver-local.js b/lib/file-api-driver-local.js index 4c6c035ea..74d7099a5 100644 --- a/lib/file-api-driver-local.js +++ b/lib/file-api-driver-local.js @@ -53,7 +53,7 @@ class FileApiDriverLocal { }); } - list(path) { + list(path, options) { return new Promise((resolve, reject) => { fs.readdir(path, (error, items) => { if (error) { @@ -75,7 +75,11 @@ class FileApiDriverLocal { return promiseChain(chain).then((results) => { if (!results) results = []; - resolve(results); + resolve({ + items: results + hasMore: false, + context: null, + }); }).catch((error) => { reject(error); }); diff --git a/lib/file-api-driver-memory.js b/lib/file-api-driver-memory.js index 6c167b410..47694cd7a 100644 --- a/lib/file-api-driver-memory.js +++ b/lib/file-api-driver-memory.js @@ -41,7 +41,7 @@ class FileApiDriverMemory { return Promise.resolve(); } - list(path) { + list(path, options) { let output = []; for (let i = 0; i < this.items_.length; i++) { @@ -57,7 +57,11 @@ class FileApiDriverMemory { } } - return Promise.resolve(output); + return Promise.resolve({ + items: output, + hasMore: false, + context: null, + }); } get(path) { diff --git a/lib/file-api-driver-onedrive.js b/lib/file-api-driver-onedrive.js index 8a8575a7e..5ea93c705 100644 --- a/lib/file-api-driver-onedrive.js +++ b/lib/file-api-driver-onedrive.js @@ -45,7 +45,7 @@ class FileApiDriverOneDrive { try { item = await this.api_.execJson('GET', this.makePath_(path), this.itemFilter_()); } catch (error) { - if (error.error.code == 'itemNotFound') return null; + if (error.code == 'itemNotFound') return null; throw error; } return item; @@ -67,9 +67,22 @@ class FileApiDriverOneDrive { return this.makeItem_(item); } - async list(path) { - let items = await this.api_.execJson('GET', this.makePath_(path) + ':/children', this.itemFilter_()); - return this.makeItems_(items.value); + async list(path, options = null) { + let query = this.itemFilter_(); + let url = this.makePath_(path) + ':/children'; + + if (options.context) { + query = null; + url = options.context; + } + + let r = await this.api_.execJson('GET', url, query); + + return { + hasMore: !!r['@odata.nextLink'], + items: this.makeItems_(r.value), + context: r["@odata.nextLink"], + } } async get(path) { @@ -77,7 +90,7 @@ class FileApiDriverOneDrive { try { content = await this.api_.execText('GET', this.makePath_(path) + ':/content'); } catch (error) { - if (error.error.code == 'itemNotFound') return null; + if (error.code == 'itemNotFound') return null; throw error; } return content; diff --git a/lib/file-api.js b/lib/file-api.js index b62446b8a..89583c083 100644 --- a/lib/file-api.js +++ b/lib/file-api.js @@ -27,17 +27,19 @@ class FileApi { list(path = '', options = null) { if (!options) options = {}; if (!('includeHidden' in options)) options.includeHidden = false; + if (!('context' in options)) options.context = null; - this.logger().debug('list'); - return this.driver_.list(this.baseDir_).then((items) => { + this.logger().debug('list ' + this.baseDir_); + + return this.driver_.list(this.baseDir_, options).then((result) => { if (!options.includeHidden) { let temp = []; - for (let i = 0; i < items.length; i++) { - if (!isHidden(items[i].path)) temp.push(items[i]); + for (let i = 0; i < result.items.length; i++) { + if (!isHidden(result.items[i].path)) temp.push(result.items[i]); } - items = temp; + result.items = temp; } - return items; + return result; }); } diff --git a/lib/onedrive-api.js b/lib/onedrive-api.js index 17e211dc8..4ca1cee46 100644 --- a/lib/onedrive-api.js +++ b/lib/onedrive-api.js @@ -67,6 +67,20 @@ class OneDriveApi { return 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?' + stringify(query); } + oneDriveErrorResponseToError(errorResponse) { + if (!errorResponse) return new Error('Undefined error'); + + if (errorResponse.error) { + let e = errorResponse.error; + let output = new Error(e.message); + if (e.code) output.code = e.code; + if (e.innerError) output.innerError = e.innerError; + return output; + } else { + return new Error(JSON.stringify(errorResponse)); + } + } + async exec(method, path, query = null, data = null, options = null) { method = method.toUpperCase(); @@ -82,26 +96,42 @@ class OneDriveApi { if (data) data = JSON.stringify(data); } - let url = 'https://graph.microsoft.com/v1.0' + path; + let url = path; - if (query) url += '?' + stringify(query); + // In general, `path` contains a path relative to the base URL, but in some + // cases the full URL is provided (for example, when it's a URL that was + // retrieved from the API). + if (url.indexOf('https://') !== 0) url = 'https://graph.microsoft.com/v1.0' + path; + + if (query) { + url += url.indexOf('?') < 0 ? '?' : '&'; + url += stringify(query); + } if (data) options.body = data; - // console.info(method + ' ' + url); - // console.info(data); + // Rare error (one Google hit) - maybe repeat the request when it happens? + + // { error: + // { code: 'generalException', + // message: 'An error occurred in the data store.', + // innerError: + // { 'request-id': 'b4310552-c18a-45b1-bde1-68e2c2345eef', + // date: '2017-06-29T00:15:50' } } } for (let i = 0; i < 5; i++) { options.headers['Authorization'] = 'bearer ' + this.token(); let response = await fetch(url, options); if (!response.ok) { - let error = await response.json(); + let errorResponse = await response.json(); + let error = this.oneDriveErrorResponseToError(errorResponse); - if (error && error.error && error.error.code == 'InvalidAuthenticationToken') { + if (error.code == 'InvalidAuthenticationToken') { await this.refreshAccessToken(); continue; } else { + error.request = method + ' ' + url + ' ' + JSON.stringify(query) + ' ' + JSON.stringify(data) + ' ' + JSON.stringify(options); throw error; } } diff --git a/lib/synchronizer.js b/lib/synchronizer.js index f134badd9..e4761dd7b 100644 --- a/lib/synchronizer.js +++ b/lib/synchronizer.js @@ -104,194 +104,204 @@ class Synchronizer { noteConflict: 0, }; - await this.createWorkDir(); + try { + await this.createWorkDir(); - let donePaths = []; - while (true) { - let result = await BaseItem.itemsThatNeedSync(); - let locals = result.items; + let donePaths = []; + while (true) { + let result = await BaseItem.itemsThatNeedSync(); + let locals = result.items; - for (let i = 0; i < locals.length; i++) { - let local = locals[i]; - let ItemClass = BaseItem.itemClass(local); - let path = BaseItem.systemPath(local); + for (let i = 0; i < locals.length; i++) { + let local = locals[i]; + let ItemClass = BaseItem.itemClass(local); + let path = BaseItem.systemPath(local); - // Safety check to avoid infinite loops: - if (donePaths.indexOf(path) > 0) throw new Error(sprintf('Processing a path that has already been done: %s. sync_time was not updated?', path)); + // Safety check to avoid infinite loops: + if (donePaths.indexOf(path) > 0) throw new Error(sprintf('Processing a path that has already been done: %s. sync_time was not updated?', path)); - let remote = await this.api().stat(path); - let content = ItemClass.serialize(local); - let action = null; - let updateSyncTimeOnly = true; - let reason = ''; + let remote = await this.api().stat(path); + let content = ItemClass.serialize(local); + let action = null; + let updateSyncTimeOnly = true; + let reason = ''; - if (!remote) { - if (!local.sync_time) { - action = 'createRemote'; - reason = 'remote does not exist, and local is new and has never been synced'; + if (!remote) { + if (!local.sync_time) { + action = 'createRemote'; + reason = 'remote does not exist, and local is new and has never been synced'; + } else { + // Note or folder was modified after having been deleted remotely + action = local.type_ == BaseModel.MODEL_TYPE_NOTE ? 'noteConflict' : 'folderConflict'; + reason = 'remote has been deleted, but local has changes'; + } } else { - // Note or folder was modified after having been deleted remotely - action = local.type_ == BaseModel.MODEL_TYPE_NOTE ? 'noteConflict' : 'folderConflict'; - reason = 'remote has been deleted, but local has changes'; + if (remote.updated_time > local.sync_time) { + // Since, in this loop, we are only dealing with notes that require sync, if the + // remote has been modified after the sync time, it means both notes have been + // modified and so there's a conflict. + action = local.type_ == BaseModel.MODEL_TYPE_NOTE ? 'noteConflict' : 'folderConflict'; + reason = 'both remote and local have changes'; + } else { + action = 'updateRemote'; + reason = 'local has changes'; + } } - } else { - if (remote.updated_time > local.sync_time) { - // Since, in this loop, we are only dealing with notes that require sync, if the - // remote has been modified after the sync time, it means both notes have been - // modified and so there's a conflict. - action = local.type_ == BaseModel.MODEL_TYPE_NOTE ? 'noteConflict' : 'folderConflict'; - reason = 'both remote and local have changes'; + + this.logSyncOperation(action, local, remote, reason); + + if (action == 'createRemote' || action == 'updateRemote') { + + // Make the operation atomic by doing the work on a copy of the file + // and then copying it back to the original location. + let tempPath = this.syncDirName_ + '/' + path + '_' + time.unixMs(); + + await this.api().put(tempPath, content); + await this.api().setTimestamp(tempPath, local.updated_time); + await this.api().move(tempPath, path); + + await ItemClass.save({ id: local.id, sync_time: time.unixMs(), type_: local.type_ }, { autoTimestamp: false }); + + } else if (action == 'folderConflict') { + + if (remote) { + let remoteContent = await this.api().get(path); + local = BaseItem.unserialize(remoteContent); + + local.sync_time = time.unixMs(); + await ItemClass.save(local, { autoTimestamp: false }); + } else { + await ItemClass.delete(local.id); + } + + } else if (action == 'noteConflict') { + + // - Create a duplicate of local note into Conflicts folder (to preserve the user's changes) + // - Overwrite local note with remote note + let conflictedNote = Object.assign({}, local); + delete conflictedNote.id; + conflictedNote.is_conflict = 1; + await Note.save(conflictedNote, { autoTimestamp: false }); + + if (remote) { + let remoteContent = await this.api().get(path); + local = BaseItem.unserialize(remoteContent); + + local.sync_time = time.unixMs(); + await ItemClass.save(local, { autoTimestamp: false }); + } + + } + + report[action]++; + + donePaths.push(path); + } + + if (!result.hasMore) break; + } + + // ------------------------------------------------------------------------ + // Delete the remote items that have been deleted locally. + // ------------------------------------------------------------------------ + + let deletedItems = await BaseModel.deletedItems(); + for (let i = 0; i < deletedItems.length; i++) { + let item = deletedItems[i]; + let path = BaseItem.systemPath(item.item_id) + this.logSyncOperation('deleteRemote', null, { id: item.item_id }, 'local has been deleted'); + await this.api().delete(path); + await BaseModel.remoteDeletedItem(item.item_id); + + report['deleteRemote']++; + } + + // ------------------------------------------------------------------------ + // Loop through all the remote items, find those that + // have been updated, and apply the changes to local. + // ------------------------------------------------------------------------ + + // At this point all the local items that have changed have been pushed to remote + // or handled as conflicts, so no conflict is possible after this. + + let remoteIds = []; + let context = null; + + while (true) { + let listResult = await this.api().list('', { context: context }); + let remotes = listResult.items; + for (let i = 0; i < remotes.length; i++) { + let remote = remotes[i]; + let path = remote.path; + + remoteIds.push(BaseItem.pathToId(path)); + if (donePaths.indexOf(path) > 0) continue; + + let action = null; + let reason = ''; + let local = await BaseItem.loadItemByPath(path); + if (!local) { + action = 'createLocal'; + reason = 'remote exists but local does not'; } else { - action = 'updateRemote'; - reason = 'local has changes'; + if (remote.updated_time > local.updated_time) { + action = 'updateLocal'; + reason = sprintf('remote is more recent than local'); // , time.unixMsToIso(remote.updated_time), time.unixMsToIso(local.updated_time) + } } - } - this.logSyncOperation(action, local, remote, reason); + if (!action) continue; - if (action == 'createRemote' || action == 'updateRemote') { + if (action == 'createLocal' || action == 'updateLocal') { + let content = await this.api().get(path); + if (content === null) { + this.logger().warn('Remote has been deleted between now and the list() call? In that case it will be handled during the next sync: ' + path); + continue; + } + content = BaseItem.unserialize(content); + let ItemClass = BaseItem.itemClass(content); - // Make the operation atomic by doing the work on a copy of the file - // and then copying it back to the original location. - let tempPath = this.syncDirName_ + '/' + path + '_' + time.unixMs(); - - await this.api().put(tempPath, content); - await this.api().setTimestamp(tempPath, local.updated_time); - await this.api().move(tempPath, path); - - await ItemClass.save({ id: local.id, sync_time: time.unixMs(), type_: local.type_ }, { autoTimestamp: false }); + let newContent = Object.assign({}, content); + newContent.sync_time = time.unixMs(); + let options = { autoTimestamp: false }; + if (action == 'createLocal') options.isNew = true; + await ItemClass.save(newContent, options); - } else if (action == 'folderConflict') { - - if (remote) { - let remoteContent = await this.api().get(path); - local = BaseItem.unserialize(remoteContent); - - local.sync_time = time.unixMs(); - await ItemClass.save(local, { autoTimestamp: false }); + this.logSyncOperation(action, local, content, reason); } else { - await ItemClass.delete(local.id); - } - - } else if (action == 'noteConflict') { - - // - Create a duplicate of local note into Conflicts folder (to preserve the user's changes) - // - Overwrite local note with remote note - let conflictedNote = Object.assign({}, local); - delete conflictedNote.id; - conflictedNote.is_conflict = 1; - await Note.save(conflictedNote, { autoTimestamp: false }); - - if (remote) { - let remoteContent = await this.api().get(path); - local = BaseItem.unserialize(remoteContent); - - local.sync_time = time.unixMs(); - await ItemClass.save(local, { autoTimestamp: false }); + this.logSyncOperation(action, local, remote, reason); } + report[action]++; } - report[action]++; - - donePaths.push(path); + if (!listResult.hasMore) break; + context = listResult.context; } - if (!result.hasMore) break; - } + // ------------------------------------------------------------------------ + // Search, among the local IDs, those that don't exist remotely, which + // means the item has been deleted. + // ------------------------------------------------------------------------ - // ------------------------------------------------------------------------ - // Delete the remote items that have been deleted locally. - // ------------------------------------------------------------------------ - - let deletedItems = await BaseModel.deletedItems(); - for (let i = 0; i < deletedItems.length; i++) { - let item = deletedItems[i]; - let path = BaseItem.systemPath(item.item_id) - this.logSyncOperation('deleteRemote', null, { id: item.item_id }, 'local has been deleted'); - await this.api().delete(path); - await BaseModel.remoteDeletedItem(item.item_id); - - report['deleteRemote']++; - } - - // ------------------------------------------------------------------------ - // Loop through all the remote items, find those that - // have been updated, and apply the changes to local. - // ------------------------------------------------------------------------ - - // At this point all the local items that have changed have been pushed to remote - // or handled as conflicts, so no conflict is possible after this. - - let remoteIds = []; - let remotes = await this.api().list(); - - for (let i = 0; i < remotes.length; i++) { - let remote = remotes[i]; - let path = remote.path; - - remoteIds.push(BaseItem.pathToId(path)); - if (donePaths.indexOf(path) > 0) continue; - - let action = null; - let reason = ''; - let local = await BaseItem.loadItemByPath(path); - if (!local) { - action = 'createLocal'; - reason = 'remote exists but local does not'; - } else { - if (remote.updated_time > local.updated_time) { - action = 'updateLocal'; - reason = sprintf('remote is more recent than local'); // , time.unixMsToIso(remote.updated_time), time.unixMsToIso(local.updated_time) + let noteIds = await Folder.syncedNoteIds(); + for (let i = 0; i < noteIds.length; i++) { + let noteId = noteIds[i]; + if (remoteIds.indexOf(noteId) < 0) { + this.logSyncOperation('deleteLocal', { id: noteId }, null, 'remote has been deleted'); + await Note.delete(noteId, { trackDeleted: false }); + report['deleteLocal']++; } } - - if (!action) continue; - - if (action == 'createLocal' || action == 'updateLocal') { - let content = await this.api().get(path); - if (content === null) { - this.logger().warn('Remote has been deleted between now and the list() call? In that case it will be handled during the next sync: ' + path); - continue; - } - content = BaseItem.unserialize(content); - let ItemClass = BaseItem.itemClass(content); - - let newContent = Object.assign({}, content); - newContent.sync_time = time.unixMs(); - let options = { autoTimestamp: false }; - if (action == 'createLocal') options.isNew = true; - await ItemClass.save(newContent, options); - - this.logSyncOperation(action, local, content, reason); - } else { - this.logSyncOperation(action, local, remote, reason); - } - - report[action]++; - } - - // ------------------------------------------------------------------------ - // Search, among the local IDs, those that don't exist remotely, which - // means the item has been deleted. - // ------------------------------------------------------------------------ - - let noteIds = await Folder.syncedNoteIds(); - for (let i = 0; i < noteIds.length; i++) { - let noteId = noteIds[i]; - if (remoteIds.indexOf(noteId) < 0) { - this.logSyncOperation('deleteLocal', { id: noteId }, null, 'remote has been deleted'); - await Note.delete(noteId, { trackDeleted: false }); - report['deleteLocal']++; - } + } catch (error) { + this.logger().error(error); + throw error; } this.logger().info('Synchronization complete [' + synchronizationId + ']:'); await this.logSyncSummary(report); this.state_ = 'idle'; - - return Promise.resolve(); } }