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 } ;