Add a first version of synchronisation
authorymh <ymh.work@gmail.com>
Fri, 28 Jul 2017 19:40:35 +0200
changeset 129 d48946d164c6
parent 128 34a75bd8d0b9
child 130 78246db1cbac
Add a first version of synchronisation Remove redux-offline dependency make the redux state fully immutable TODO: better error management TODO: make syncronization work automatically
client/package.json
client/src/actions/authActions.js
client/src/actions/networkActions.js
client/src/actions/notesActions.js
client/src/actions/sessionsActions.js
client/src/actions/syncActions.js
client/src/api/APIClient.js
client/src/api/WebAnnotationSerializer.js
client/src/components/Login.js
client/src/components/Navbar.js
client/src/components/Register.js
client/src/components/Session.js
client/src/components/SessionForm.js
client/src/components/SessionList.js
client/src/components/Settings.js
client/src/constants/actionTypes.js
client/src/constants/index.js
client/src/misc/AuthenticatedRoute.js
client/src/reducers/authReducer.js
client/src/reducers/index.js
client/src/reducers/miscReducer.js
client/src/reducers/notesReducer.js
client/src/reducers/sessionsReducer.js
client/src/reducers/syncReducer.js
client/src/sagas/BaseSyncronizer.js
client/src/sagas/NoteSyncronizer.js
client/src/sagas/SessionSyncronizer.js
client/src/sagas/networkSaga.js
client/src/sagas/selectors.js
client/src/sagas/syncSaga.js
client/src/sagas/utils.js
client/src/store/configureStore.js
client/src/store/noteRecord.js
client/src/store/sessionRecord.js
client/yarn.lock
src/irinotes/settings.py
src/notes/api/views/sync.py
src/notes/tests/api/sync.py
src/requirements/base.txt
src/requirements/base.txt.in
src/setup.py
--- a/client/package.json	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/package.json	Fri Jul 28 19:40:35 2017 +0200
@@ -9,6 +9,7 @@
     "localforage": "^1.5.0",
     "lodash": "^4.17.4",
     "moment": "^2.18.1",
+    "qs": "^6.5.0",
     "react": "^15.5.4",
     "react-bootstrap": "^0.31.0",
     "react-dom": "^15.5.4",
@@ -18,7 +19,8 @@
     "react-router-redux": "next",
     "redux": "^3.6.0",
     "redux-immutable": "^4.0.0",
-    "redux-offline": "^2.0.0",
+    "redux-persist": "^4.8.2",
+    "redux-persist-immutable": "^4.3.0",
     "redux-persist-transform-immutable": "^4.3.0",
     "redux-saga": "^0.15.3",
     "slate": "^0.20.1",
--- a/client/src/actions/authActions.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/actions/authActions.js	Fri Jul 28 19:40:35 2017 +0200
@@ -36,6 +36,6 @@
 }
 
 
-export const purgeOutbox = () => {
-  return { type: types.OFFLINE_PURGE_OUTBOX }
+export const resetAll = () => {
+  return { type: types.SYNC_RESET_ALL }
 }
--- a/client/src/actions/networkActions.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/actions/networkActions.js	Fri Jul 28 19:40:35 2017 +0200
@@ -7,9 +7,15 @@
   };
 }
 
-export const offlineConfigInitialized = (additionalContext) => {
+export const setOnlineStatus = (status) => {
   return {
-    type: types.OFFLINE_CONFIG_INITIALIZED,
-    additionalContext
+    type: status?types.STATUS_ONLINE:types.STATUS_OFFLINE
   }
 }
+
+
+export const forceSync = () => {
+  return {
+    type: types.SYNC_DO_SYNC
+  }
+}
--- a/client/src/actions/notesActions.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/actions/notesActions.js	Fri Jul 28 19:40:35 2017 +0200
@@ -1,7 +1,8 @@
 import uuidV1 from 'uuid/v1';
 
 import * as types from '../constants/actionTypes';
-import WebAnnotationSerializer from '../api/WebAnnotationSerializer';
+// import WebAnnotationSerializer from '../api/WebAnnotationSerializer';
+import { ActionEnum } from '../constants';
 
 export const addNote = (session, data) => {
   const noteId = uuidV1();
@@ -15,34 +16,12 @@
     finishedAt: data.finishedAt,
     categories: data.categories,
     marginComment: data.marginComment,
+    action: ActionEnum.CREATED
   };
 
-  const noteSrvr = {
-    ext_id: noteId,
-    session: session._id,
-    raw: JSON.stringify(data.raw),
-    plain: data.plain,
-    html: data.html,
-    tc_start: data.startedAt,
-    tc_end: data.finishedAt,
-    categorization: WebAnnotationSerializer.serialize(note),
-    margin_note: data.marginComment,
-  }
-
   return {
     type: types.ADD_NOTE,
     note,
-    meta: {
-      offline: {
-        effect: {
-          url: `/api/notes/sessions/${session.get('_id')}/notes/`,
-          method: 'POST',
-          data: noteSrvr
-        },
-        commit: { type: types.NOOP },
-        rollback: { type: types.NOOP }
-      }
-    }
   };
 }
 
@@ -50,42 +29,27 @@
   return {
     type: types.DELETE_NOTE,
     note,
-    meta: {
-      offline: {
-        effect: {
-          url: `/api/notes/sessions/${note.get('session')}/notes/${note.get('_id')}/`,
-          method: 'DELETE'
-        },
-        commit: { type: types.NOOP },
-        rollback: { type: types.NOOP }
-      }
-    }
   };
 }
 
 export const updateNote = (note, data) => {
-  const noteSrvr = {
-    raw: JSON.stringify(data.raw),
-    plain: data.plain,
-    html: data.html,
-    categorization: WebAnnotationSerializer.serialize(note),
-    margin_note: data.marginComment,
-  }
 
   return {
     type: types.UPDATE_NOTE,
     note,
     data,
-    meta: {
-      offline: {
-        effect: {
-          url: `/api/notes/sessions/${note.get('session')}/notes/${note.get('_id')}/`,
-          method: 'PUT',
-          data: noteSrvr
-        },
-        commit: { type: types.NOOP },
-        rollback: { type: types.NOOP }
-      }
-    }
   };
 }
+
+export const doDeleteNote = (noteId) => {
+  return { type: types.DO_DELETE_NOTE, noteId };
+}
+
+export const loadNote = (note) => {
+  return { type: types.LOAD_NOTE, note };
+}
+
+export const resetActionNote = (note) => {
+  return { type: types.RESET_ACTION_NOTE, note };
+}
+
--- a/client/src/actions/sessionsActions.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/actions/sessionsActions.js	Fri Jul 28 19:40:35 2017 +0200
@@ -1,6 +1,8 @@
 import { now } from '../utils';
+import { ActionEnum } from '../constants'
 import * as types from '../constants/actionTypes';
 
+
 export const createSession = (sessionId) => {
 
   const newSession = {
@@ -9,24 +11,12 @@
       date: now(),
       title: '',
       description: '',
-      deleted: false,
-      modified: true
+      action: ActionEnum.CREATED
   };
 
   return {
     type: types.CREATE_SESSION,
     session: newSession,
-    meta: {
-      offline: {
-        effect: {
-          url: `/api/notes/sessions/`,
-          method: 'POST',
-          data: newSession
-        },
-        commit: { type: types.NOOP },
-        rollback: { type: types.NOOP }
-      }
-    }
   };
 }
 
@@ -35,17 +25,6 @@
     type: types.UPDATE_SESSION,
     session: session,
     values: values,
-    meta: {
-      offline: {
-        effect: {
-          url: `/api/notes/sessions/${session.get('_id')}/`,
-          method: 'PUT',
-          data: values
-        },
-        commit: { type: types.NOOP },
-        rollback: { type: types.NOOP }
-      }
-    }
   };
 }
 
@@ -53,25 +32,27 @@
   return {
     type: types.DELETE_SESSION,
     session: session,
-    meta: {
-      offline: {
-        effect: {
-          url: `/api/notes/sessions/${session.get('_id')}/`,
-          method: 'DELETE',
-        },
-        commit: { type: types.NOOP },
-        rollback: { type: types.NOOP }
-      }
-    }
   };
 }
 
+export const doDeleteSession = (sessionId) => {
+  return { type: types.DO_DELETE_SESSION, sessionId };
+}
+
 export const loadSessions = () => {
   return {
     type: types.LOAD_SESSIONS
   }
 }
 
+export const loadSession = (session) => {
+  return { type: types.LOAD_SESSION, session };
+}
+
+export const resetActionSession = (session) => {
+  return { type: types.RESET_ACTION_SESSION, session };
+}
+
 export const createGroupAndUpdateSession = (session, name) => {
   const group = {
     name
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/actions/syncActions.js	Fri Jul 28 19:40:35 2017 +0200
@@ -0,0 +1,14 @@
+import * as types from '../constants/actionTypes';
+
+export const updateLastSync = (timestamp) => {
+  return {
+    type: types.SYNC_SET_LAST_SYNC,
+    value: timestamp
+  }
+}
+
+export const endSynchronize = () => {
+  return {
+    type: types.SYNC_END_SYNC
+  }
+}
--- a/client/src/api/APIClient.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/api/APIClient.js	Fri Jul 28 19:40:35 2017 +0200
@@ -1,3 +1,5 @@
+import qs from 'qs';
+
 class APIClient {
   constructor(baseURL) {
     this.baseURL = baseURL;
@@ -22,17 +24,28 @@
       headers: headers,
     };
 
+    let queryparams = '';
     if (data) {
-      options.body = JSON.stringify(data);
+      if(method !== 'HEAD' && method !== 'GET' && data) {
+        options.body = JSON.stringify(data);
+      }
+      else {
+        queryparams = "?"+qs.stringify(data);
+      }
     }
 
     // TODO : use URL-module to build URL
-    return new Request(this.baseURL + uri, options);
+    return new Request(this.baseURL + uri + queryparams, options);
   }
 
   getToken = () => {
     const state = this.store.getState();
-    return state['token'];
+    return state.getIn(['authStatus', 'token']);
+  }
+
+  getClientId = () => {
+    const state = this.store.getState();
+    return state.getIn(['authStatus', 'clientId']);
   }
 
   hasToken = () => {
@@ -41,17 +54,32 @@
     return token !== null && token !== '';
   }
 
-  createAuthorizedRequest = (method, uri, data) => {
+  hasClientId = () => {
+    const clientId = this.getClientId();
+    return clientId !== null && clientId !== '';
+  }
 
-    var headers = new Headers(),
-        token = this.getToken() || '';
+  createAuthorizedHeader = (headers) => {
+    const token = this.getToken() || '';
     headers.append("Authorization", "JWT " + token);
+    return headers;
+  }
 
-    return this.createRequest(method, uri, data, headers);
+  createClientIdHeader = (headers) => {
+    const clientId = this.getClientId() || '';
+    headers.append("Auditlog-Client", clientId);
+    return headers;
   }
 
   request = (method, uri, data) => {
-    var req = this.hasToken() ? this.createAuthorizedRequest(method, uri, data) : this.createRequest(method, uri, data);
+    var headers = new Headers();
+    if(this.hasToken()) {
+      headers = this.createAuthorizedHeader(headers);
+    }
+    if(this.hasClientId()) {
+      headers = this.createClientIdHeader(headers);
+    }
+    var req = this.createRequest(method, uri, data, headers);
     return this.fetch(req, { credentials: 'include' });
   }
 
@@ -67,6 +95,10 @@
     return this.request('PUT', uri, data);
   }
 
+  delete = (uri, data) => {
+    return this.request('DELETE', uri, data);
+  }
+
   fetch = (req) => {
     return new Promise((resolve, reject) => {
       fetch(req)
@@ -87,7 +119,11 @@
             return resJsonPromise.then(data => resolve(data));
 
           } else {
-            return response.json().then(data => reject(data));
+            let errorResp = {
+              status: response.status,
+              statusText: response.statusText
+            }
+            return response.json().then(data => reject({...errorResp,data }));
           }
         })
         .catch((error) => {
--- a/client/src/api/WebAnnotationSerializer.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/api/WebAnnotationSerializer.js	Fri Jul 28 19:40:35 2017 +0200
@@ -2,14 +2,14 @@
 
   static serialize = (note) => {
 
-    const categories = note.categories;
+    const categories = note.get('categories');
 
     const baseAnnotation = {
       '@context': "http://www.w3.org/ns/anno.jsonld",
       "type":     "Annotation",
     }
 
-    const source = "/session/" + note.session + "/notes/" + note._id;
+    const source = "/session/" + note.get('session') + "/notes/" + note.get('_id');
 
     return categories.map((category, index) => {
 
--- a/client/src/components/Login.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/components/Login.js	Fri Jul 28 19:40:35 2017 +0200
@@ -90,7 +90,7 @@
 
 function mapStateToProps(state, props) {
   return {
-    login: state['login']
+    login: state.get('login')
   };
 }
 
--- a/client/src/components/Navbar.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/components/Navbar.js	Fri Jul 28 19:40:35 2017 +0200
@@ -6,6 +6,8 @@
 // import logo from './logo.svg';
 import { Navbar, Nav, NavItem, NavDropdown, MenuItem, Modal, Button } from 'react-bootstrap';
 import * as authActions from '../actions/authActions';
+import { forceSync } from '../actions/networkActions';
+import { ActionEnum } from '../constants';
 
 const LoginNav = ({isAuthenticated, currentUser, history, authActions, onLogout}) => {
 
@@ -32,10 +34,19 @@
   );
 }
 
-const Online = ({ offline }) => {
+const Online = ({ online }) => {
   return (
     <NavItem>
-      <span className="material-icons" style={{ color: offline.online ? '#2ECC71' : '#E74C3C' }}>signal_wifi_4_bar</span>
+      <span className="material-icons" style={{ color: online ? '#2ECC71' : '#E74C3C' }}>signal_wifi_4_bar</span>
+    </NavItem>
+  )
+}
+
+const SyncButton = ({ onSyncClick, isSynchronizing }) => {
+  return (
+    <NavItem onClick={onSyncClick}>
+      Sync
+      {isSynchronizing && <span className="material-icons">&#xE627;</span>}
     </NavItem>
   )
 }
@@ -55,14 +66,14 @@
     this.props.history.push('/');
   }
 
-  isOutboxEmpty = () => {
-    return this.props.offline.outbox.length === 0;
+  isSynchronized = () => {
+    return this.props.isSynchronized;
   }
 
   onClickLogout = (e) => {
     e.preventDefault();
-    const isOutboxEmpty = this.isOutboxEmpty();
-    if (isOutboxEmpty) {
+    const isSynchronized = this.isSynchronized();
+    if (isSynchronized) {
       this.logout();
     } else {
       this.setState({ showModal: true })
@@ -70,9 +81,9 @@
   }
 
   confirmLogout = () => {
-    const isOutboxEmpty = this.isOutboxEmpty();
-    if (!isOutboxEmpty) {
-      this.props.authActions.purgeOutbox();
+    const isSynchronized = this.isSynchronized();
+    if (!isSynchronized) {
+      this.props.authActions.resetAll();
     }
     this.logout();
     this.closeModal();
@@ -88,6 +99,11 @@
     this.props.history.push('/sessions');
   }
 
+  onSyncClick = (e) => {
+    e.preventDefault();
+    this.props.networkActions.forceSync();
+  }
+
   render() {
     return (
       <Navbar fluid inverse fixedTop>
@@ -102,6 +118,7 @@
             <NavItem onClick={this.onClickSessions} href="/sessions">Sessions</NavItem>
           </Nav>
           <Nav pullRight>
+            <SyncButton onSyncClick={this.onSyncClick} isSynchronizing={this.props.isSynchronizing}/>
             <Online {...this.props} />
             <LoginNav {...this.props} onLogout={this.onClickLogout} />
           </Nav>
@@ -130,15 +147,19 @@
 
 function mapStateToProps(state, props) {
   return {
-    isAuthenticated: state['isAuthenticated'],
-    currentUser: state['currentUser'],
-    offline: state['offline']
+    isAuthenticated: state.getIn(['authStatus', 'isAuthenticated']),
+    currentUser: state.getIn(['authStatus', 'currentUser']),
+    online: state.getIn(['status', 'online']),
+    isSynchronizing: state.getIn(['status', 'isSynchronizing']),
+    isSynchronized: state.get('notes').every((n) => n.get('action')===ActionEnum.NONE) &&
+      state.get('sessions').every((n) => n.get('action')===ActionEnum.NONE)
   };
 }
 
 function mapDispatchToProps(dispatch) {
   return {
     authActions: bindActionCreators(authActions, dispatch),
+    networkActions: bindActionCreators({ forceSync }, dispatch)
   }
 }
 
--- a/client/src/components/Register.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/components/Register.js	Fri Jul 28 19:40:35 2017 +0200
@@ -88,7 +88,7 @@
 
 function mapStateToProps(state, props) {
   return {
-    register: state['register']
+    register: state.get('register')
   };
 }
 
--- a/client/src/components/Session.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/components/Session.js	Fri Jul 28 19:40:35 2017 +0200
@@ -11,6 +11,7 @@
 import * as sessionsActions from '../actions/sessionsActions';
 import * as notesActions from '../actions/notesActions';
 import * as userActions from '../actions/userActions';
+import { ActionEnum } from '../constants';
 
 class Session extends Component {
   render() {
@@ -55,13 +56,13 @@
 
   const sessionId = props.match.params.id;
 
-  const sessions = state['sessions'];
-  const notes = state['notes'];
-  const autoSubmit = state['autoSubmit'];
+  const sessions = state.get('sessions');
+  const notes = state.get('notes');
+  const autoSubmit = state.get('autoSubmit');
 
   const currentSession = sessions.find(session => session._id === sessionId);
   const currentNotes = notes.filter(note => {
-        return (note.session === sessionId && !note.deleted);
+        return (note.get('session') === sessionId && note.get('action') !== ActionEnum.DELETED);
     }).sortBy( n => n.get('startedAt') );
 
   return {
--- a/client/src/components/SessionForm.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/components/SessionForm.js	Fri Jul 28 19:40:35 2017 +0200
@@ -155,8 +155,8 @@
 
   return {
     currentSession: props.session,
-    createGroup: state.createGroup,
-    groups: state.groups,
+    createGroup: state.get('createGroup'),
+    groups: state.get('groups'),
     group
   };
 }
--- a/client/src/components/SessionList.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/components/SessionList.js	Fri Jul 28 19:40:35 2017 +0200
@@ -7,6 +7,7 @@
 import Navbar from './Navbar';
 import * as sessionsActions from '../actions/sessionsActions';
 import uuidV1 from 'uuid/v1';
+import { ActionEnum } from '../constants';
 
 class SessionList extends Component {
 
@@ -85,7 +86,7 @@
 
 function mapStateToProps(state, props) {
   return {
-    sessions: state['sessions'].filter(session => !session.deleted)
+    sessions: state.get('sessions').filter(session => session.get('action') !== ActionEnum.DELETED)
   };
 }
 
--- a/client/src/components/Settings.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/components/Settings.js	Fri Jul 28 19:40:35 2017 +0200
@@ -58,7 +58,7 @@
 
 function mapStateToProps(state, props) {
   return {
-    currentUser: state['currentUser'],
+    currentUser: state.getIn(['authStatus', 'currentUser']),
   };
 }
 
--- a/client/src/constants/actionTypes.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/constants/actionTypes.js	Fri Jul 28 19:40:35 2017 +0200
@@ -4,12 +4,16 @@
 export const DELETE_NOTE = 'DELETE_NOTE';
 export const DO_DELETE_NOTE = 'DO_DELETE_NOTE';
 export const UPDATE_NOTE = 'UPDATE_NOTE';
+export const RESET_ACTION_NOTE = 'RESET_ACTION_NOTE';
+export const LOAD_NOTE = 'LOAD_NOTE';
 
 export const CREATE_SESSION = 'CREATE_SESSION';
 export const UPDATE_SESSION = 'UPDATE_SESSION';
 export const DELETE_SESSION = 'DELETE_SESSION';
 export const DO_DELETE_SESSION = 'DO_DELETE_SESSION';
 export const LOAD_SESSIONS = 'LOAD_SESSIONS';
+export const LOAD_SESSION = 'LOAD_SESSION';
+export const RESET_ACTION_SESSION = 'RESET_ACTION_SESSION';
 
 export const AUTH_LOGIN_SUBMIT = 'AUTH_LOGIN_SUBMIT';
 export const AUTH_LOGIN_REQUEST = 'AUTH_LOGIN_REQUEST';
@@ -19,11 +23,12 @@
 export const AUTH_REGISTER_REQUEST = 'AUTH_REGISTER_REQUEST';
 export const AUTH_REGISTER_ERROR = 'AUTH_REGISTER_ERROR';
 
-export const OFFLINE_PURGE_OUTBOX = 'OFFLINE_PURGE_OUTBOX';
-
 // Used both by login & register
 export const AUTH_LOGIN_SUCCESS = 'AUTH_LOGIN_SUCCESS';
 
+export const STATUS_ONLINE = 'STATUS_ONLINE';
+export const STATUS_OFFLINE = 'STATUS_OFFLINE';
+
 export const AUTH_STORE_TOKEN_ASYNC = 'AUTH_STORE_TOKEN_ASYNC';
 export const AUTH_STORE_TOKEN = 'AUTH_STORE_TOKEN';
 export const AUTH_LOGOUT = 'AUTH_LOGOUT';
@@ -43,4 +48,8 @@
 export const USER_TOGGLE_AUTO_SUBMIT = 'USER_TOGGLE_AUTO_SUBMIT';
 
 export const DATA_FETCH_SUCCESS = 'DATA_FETCH_SUCCESS';
-export const OFFLINE_CONFIG_INITIALIZED ='OFFLINE_CONFIG_INITIALIZED';
+
+export const SYNC_DO_SYNC = 'SYNC_DO_SYNC';
+export const SYNC_END_SYNC = 'SYNC_END_SYNC';
+export const SYNC_SET_LAST_SYNC = 'SYNC_SET_LAST_SYNC';
+export const SYNC_RESET_ALL = 'SYNC_RESET_ALL';
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/constants/index.js	Fri Jul 28 19:40:35 2017 +0200
@@ -0,0 +1,6 @@
+export const ActionEnum = {
+  NONE: 0,
+  CREATED: 1,
+  UPDATED: 2,
+  DELETED: 3
+}
--- a/client/src/misc/AuthenticatedRoute.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/misc/AuthenticatedRoute.js	Fri Jul 28 19:40:35 2017 +0200
@@ -8,7 +8,7 @@
 
   const { store } = props;
   const state = store.getState();
-  const isAuthenticated = state.isAuthenticated;
+  const isAuthenticated = state.getIn(['authStatus', 'isAuthenticated']);
 
   if (isAuthenticated) {
     return <Route { ...props } component={ component } />;
--- a/client/src/reducers/authReducer.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/reducers/authReducer.js	Fri Jul 28 19:40:35 2017 +0200
@@ -2,6 +2,7 @@
 import * as types from '../constants/actionTypes';
 import UserRecord from '../store/userRecord';
 import asyncRequest from '../constants/asyncRequest';
+import uuidV4 from 'uuid/v4';
 
 export const isAuthenticated = (state = false, action) => {
   switch (action.type) {
@@ -44,6 +45,19 @@
   }
 }
 
+export const clientId = (state = null, action) => {
+  switch (action.type) {
+    case types.AUTH_DEAUTHENTICATE:
+    case types.AUTH_LOGOUT:
+      return null;
+    case types.AUTH_LOGIN_SUCCESS:
+      return uuidV4();
+    default:
+      return state;
+  }
+
+}
+
 export const login = (state = asyncRequest, action) => {
   switch (action.type) {
     case types.AUTH_LOGIN_REQUEST:
--- a/client/src/reducers/index.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/reducers/index.js	Fri Jul 28 19:40:35 2017 +0200
@@ -1,27 +1,34 @@
-//import { combineReducers } from 'redux-immutable';
-import { combineReducers } from 'redux';
+import { combineReducers } from 'redux-immutable';
 import { routerReducer } from 'react-router-redux';
 
 import notes from './notesReducer';
 import { sessions } from './sessionsReducer';
-import { isAuthenticated, currentUser, login, register, token, groups, createGroup } from './authReducer';
-import { autoSubmit, outbox } from './miscReducer';
+import { isAuthenticated, currentUser, login, register, token, groups, createGroup, clientId } from './authReducer';
+import { autoSubmit, online } from './miscReducer';
+import { isSynchronizing, lastSync } from './syncReducer';
+
 
 const rootReducer = combineReducers({
   sessions,
   notes,
-  isAuthenticated,
-  currentUser,
   login,
   register,
-  token,
+  authStatus: combineReducers({
+    token,
+    currentUser,
+    isAuthenticated,
+    clientId,
+    lastSync
+  }),
+  status: combineReducers({
+    isSynchronizing,
+    online
+  }),
   router: routerReducer,
   autoSubmit,
   groups,
   createGroup,
-  offline: combineReducers({
-    outbox
-  })
 });
 
+
 export default rootReducer;
--- a/client/src/reducers/miscReducer.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/reducers/miscReducer.js	Fri Jul 28 19:40:35 2017 +0200
@@ -9,14 +9,14 @@
   }
 }
 
-export const outbox = (state = [], action) => {
+export const online = (state = false, action) => {
   switch (action.type) {
-    case types.OFFLINE_PURGE_OUTBOX:
-      // FIXME Does not work
-      // Need to find a way to purge outbox
-      // @see https://github.com/jevakallio/redux-offline/issues/68
-      return state;
+    case types.STATUS_ONLINE:
+      return true;
+    case types.STATUS_OFFLINE:
+      return false;
     default:
       return state;
   }
+
 }
--- a/client/src/reducers/notesReducer.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/reducers/notesReducer.js	Fri Jul 28 19:40:35 2017 +0200
@@ -1,6 +1,7 @@
 import Immutable from 'immutable';
 import * as types from '../constants/actionTypes';
 import NoteRecord from '../store/noteRecord';
+import { ActionEnum } from '../constants';
 
 const findNoteIndex = (notes, id) => {
   return notes.findIndex((note) => note.get('_id') === id);
@@ -18,28 +19,68 @@
     case types.DELETE_NOTE: {
       const noteIndex = findNoteIndex(state, action.note.get('_id'));
       const note = findNote(state, action.note.get('_id'));
-      return state.set(noteIndex, note.merge({deleted:true, modified:true}));
+      return state.set(noteIndex, note.merge({action: ActionEnum.DELETED}));
     }
     case types.DO_DELETE_NOTE: {
-      const noteIndex = state.findIndex((note) => note.get('_id') === action.note.get('_id'));
+      const noteIndex = state.findIndex((note) => note.get('_id') === action.noteId);
       return state.delete(noteIndex);
     }
     case types.UPDATE_NOTE: {
       const index = findNoteIndex(state, action.note.get('_id'));
       const note = findNote(state, action.note.get('_id'));
-      let newNote = note.merge(action.data, {modified:true});
+      let newAction;
+      switch (note.get('action')) {
+        case ActionEnum.CREATED:
+          newAction = ActionEnum.CREATED;
+          break;
+        case ActionEnum.DELETED: // should not happen, but...
+          newAction = ActionEnum.DELETED;
+          break;
+        default:
+          newAction = ActionEnum.UPDATED;
+      }
+
+      let newNote = note.merge(action.data, {action: newAction});
       return state.set(index, newNote);
     }
     case types.DELETE_SESSION: {
       const sessionId = action.session.get('_id');
       return state.map((note) => {
         if(sessionId === note.session) {
-          return note.merge({deleted:true, modified:true});
+          return note.merge({action: ActionEnum.DELETED});
         } else {
           return note;
         }
       })
     }
+    case types.DO_DELETE_SESSION: {
+      return state.filter((note) => action.sessionId !== note.session)
+    }
+    case types.RESET_ACTION_NOTE: {
+      const noteId = action.note.get('_id');
+      const index = state.findIndex((note) => note.get('_id') === noteId);
+      const note = state.get(index);
+      return state.set(index, note.merge({action: ActionEnum.NONE}));
+    }
+    case types.SYNC_RESET_ALL: {
+      return state.map((note) => {
+        if(note.action !== ActionEnum.NONE) {
+          return note.merge({action: ActionEnum.None});
+        } else {
+          return note;
+        }
+      });
+    }
+    case types.LOAD_NOTE: {
+      const noteRec = action.note;
+      const noteId = noteRec.get('_id');
+      const index = state.findIndex((note) => note.get('_id') === noteId);
+      if(index >= 0) {
+        return state.set(index, noteRec);
+      } else {
+        return state.push(noteRec);
+      }
+    }
     default:
       return state;
   }
--- a/client/src/reducers/sessionsReducer.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/reducers/sessionsReducer.js	Fri Jul 28 19:40:35 2017 +0200
@@ -1,6 +1,7 @@
 import Immutable from 'immutable';
 import * as types from '../constants/actionTypes';
 import SessionRecord from '../store/sessionRecord';
+import { ActionEnum } from '../constants';
 
 export const sessions = (state = Immutable.List([]), action) => {
 
@@ -15,11 +16,23 @@
       if (sessionIndex === -1) {
         return state;
       }
-      const updatedSession = sessionToUpdate.merge(action.values, {modified: true});
+      let newAction;
+      switch (sessionToUpdate.get('action')) {
+        case ActionEnum.CREATED:
+          newAction = ActionEnum.CREATED;
+          break;
+        case ActionEnum.DELETED: // should not happen, but...
+          newAction = ActionEnum.DELETED;
+          break;
+        default:
+          newAction = ActionEnum.UPDATED;
+      }
+
+      const updatedSession = sessionToUpdate.merge(action.values, {action: newAction});
       return state.set(sessionIndex, updatedSession);
     }
     case types.DO_DELETE_SESSION: {
-      return state.filter((note) => action.session.get('_id') !== note.session)
+      return state.filter((session) => action.sessionId !== session._id)
     }
     case types.DELETE_SESSION: {
       const sessionIndex = state.indexOf(action.session);
@@ -27,11 +40,41 @@
         return state;
       }
       const deletedSession = state.get(sessionIndex);
-      return state.set(sessionIndex, deletedSession.merge({deleted:true, modified:true}));
+      if(deletedSession.get('action') === ActionEnum.CREATED) {
+        // The session was previously created, we can delete it
+        return state.delete(sessionIndex);
+      } else {
+        return state.set(sessionIndex, deletedSession.merge({action: ActionEnum.DELETED}));
+      }
     }
     case types.LOAD_SESSIONS: {
       return action.sessions;
     }
+    case types.LOAD_SESSION: {
+      const sessionRec = action.session;
+      const sessionId = sessionRec.get('_id');
+      const index = state.findIndex((session) => session.get('_id') === sessionId);
+      if(index >= 0) {
+        return state.set(index, sessionRec);
+      } else {
+        return state.push(sessionRec);
+      }
+    }
+    case types.SYNC_RESET_ALL: {
+      return state.map((session) => {
+        if(session.action !== ActionEnum.NONE) {
+          return session.merge({action: ActionEnum.None});
+        } else {
+          return session;
+        }
+      });
+    }
+    case types.RESET_ACTION_SESSION: {
+      const sessionId = action.session.get('_id');
+      const index = state.findIndex((session) => session.get('_id') === sessionId);
+      const session = state.get(index);
+      return state.set(index, session.merge({action: ActionEnum.NONE}));
+    }
     default:
       return state;
   }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/reducers/syncReducer.js	Fri Jul 28 19:40:35 2017 +0200
@@ -0,0 +1,23 @@
+import * as types from '../constants/actionTypes';
+
+export const lastSync = (state = 0, action) => {
+  switch (action.type) {
+    case types.SYNC_SET_LAST_SYNC:
+      return action.value;
+    case types.AUTH_LOGOUT:
+      return 0;
+    default:
+      return state;
+  }
+}
+
+export const isSynchronizing = (state = false, action) => {
+  switch (action.type) {
+    case types.SYNC_DO_SYNC:
+      return true;
+    case types.SYNC_END_SYNC:
+      return false;
+    default:
+      return state;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/sagas/BaseSyncronizer.js	Fri Jul 28 19:40:35 2017 +0200
@@ -0,0 +1,140 @@
+import { put } from 'redux-saga/effects'
+import Immutable from 'immutable';
+
+export const SyncMixin = Base => class extends Base {
+
+  constructor(syncEntries, client) {
+    super();
+    this.syncEntries = syncEntries;
+    this.client = client;
+    this.localDiffs = null;
+  }
+
+  // abstract methods
+
+  // local diffs (immutable)
+  // getLocalDiffs()
+
+  // remote urls
+  // getRemoteLoadUrl()
+  // getRemoteDeleteUrl(localObjInst);
+  // getRemoteCreateUrl(localObjInst)
+  // getRemoteUpdateUrl(localObjInst)
+
+  // build remote json message
+  // getRemoteData(localObjInst)
+  // getLocalRecord(remoteObj)
+
+  // actions
+  // doDeleteLocalObj(localObjId);
+  // resetLocalObj(localObjInst)
+  // loadObj(objRecord)
+
+
+  * loadFromRemote() {
+
+    const objIds = this.syncEntries
+      .filter((syncEntry) => syncEntry.action !== 2)
+      .map((syncEntry) => syncEntry.ext_id);
+
+    if(objIds.length === 0) {
+      return ;
+    }
+
+    //TODO: manage pagination
+    const remoteObjs = yield this.client.get(this.getRemoteLoadUrl(), { ext_id__in: objIds.join(',') })
+
+    for (var remoteObj of remoteObjs.results) {
+
+      if(this.localDiffs.get('deleted').has(remoteObj.ext_id)) {
+        // The session has been deleted locally, we will delete it later
+        continue;
+      }
+
+      if(this.localDiffs.get('created').has(remoteObj.ext_id)) {
+        // The session has been modified both locally and remotely
+        // the server wins, it will be loaded locally, we must remove it from the list of locally changed sessions
+        const newCreatedMap = this.localDiffs.get('created').delete(remoteObj.ext_id);
+        this.localDiffs = this.localDiffs.set('created', newCreatedMap);
+      }
+
+      if(this.localDiffs.get('updated').has(remoteObj.ext_id)) {
+        // The session has been modified both locally and remotely
+        // the server wins, it will be loaded locally, we must remove it from the list of locally changed sessions
+        const newModifiedMap = this.localDiffs.get('updated').delete(remoteObj.ext_id);
+        this.localDiffs = this.localDiffs.set('updated', newModifiedMap);
+      }
+
+      let objRecord = this.getLocalRecord(remoteObj);
+      yield put(this.loadObj(objRecord));
+    }
+  }
+
+  * deleteFromRemote() {
+
+    const objToDelete = this.syncEntries
+      .filter((syncObj) => syncObj.action === 2)
+      .map((syncObj) => syncObj.ext_id);
+
+    let deleteObjs = this.localDiffs.get('deleted');
+    let updatedObjs = this.localDiffs.get('updated');
+    let createdObjs = this.localDiffs.get('created');
+    for (var objId of objToDelete) {
+      if(deleteObjs.has(objId)) {
+        // we remove it from the list of sessions to delete
+        deleteObjs = deleteObjs.delete(objId);
+      }
+      if(updatedObjs.has(objId)) {
+        updatedObjs = updatedObjs.delete(objId);
+      }
+      if(createdObjs.has(objId)) {
+        createdObjs = createdObjs.delete(objId);
+      }
+      yield put(this.doDeleteLocalObj(objId));
+    }
+    this.localDiffs = Immutable.Map({created: createdObjs, updated: updatedObjs, deleted: deleteObjs});
+  }
+
+  * syncObjects() {
+
+    this.localDiffs = yield this.getLocalDiffs();
+
+    yield this.loadFromRemote();
+    yield this.deleteFromRemote();
+
+    let localObjInst;
+
+    // delete remote obj
+    for(localObjInst of this.localDiffs.get('deleted').values()) {
+
+      try {
+        yield this.client.delete(this.getRemoteDeleteUrl(localObjInst));
+      } catch(err) {
+        if(err.status !== 404) {
+          //TODO: better error handling ???
+          console.log("error whe deleting object", err);
+        }
+        // otherwise, this is ok
+      }
+
+      yield put(this.doDeleteLocalObj(localObjInst.get('_id')));
+    }
+
+    for(localObjInst of this.localDiffs.get('created').values()) {
+      const remoteData = this.getRemoteData(localObjInst);
+      //TODO: SET VERSION !!!!
+      yield this.client.post(this.getRemoteCreateUrl(localObjInst), remoteData);
+      yield put(this.resetLocalObj(localObjInst));
+    }
+
+    for(localObjInst of this.localDiffs.get('updated').values()) {
+      const remoteData = this.getRemoteData(localObjInst);
+      //TODO: SET VERSION !!!!
+      yield this.client.put(this.getRemoteUpdateUrl(localObjInst), remoteData);
+      yield put(this.resetLocalObj(localObjInst));
+    }
+
+  }
+}
+
+export default SyncMixin;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/sagas/NoteSyncronizer.js	Fri Jul 28 19:40:35 2017 +0200
@@ -0,0 +1,85 @@
+import { select } from 'redux-saga/effects'
+import { getCreatedNotes, getUpdatedNotes, getDeletedNotes } from './selectors';
+import NoteRecord from '../store/noteRecord';
+import { doDeleteNote, loadNote, resetActionNote } from '../actions/notesActions';
+import Immutable from 'immutable';
+import SyncMixin from './BaseSyncronizer';
+import WebAnnotationSerializer from '../api/WebAnnotationSerializer';
+
+class NoteSyncBase {
+
+  // local diffs (immutable)
+  * getLocalDiffs() {
+    return Immutable.Map({
+      created: yield select(getCreatedNotes),
+      updated: yield select(getUpdatedNotes),
+      deleted: yield select(getDeletedNotes)
+    })
+  }
+
+  // remote urls
+  getRemoteLoadUrl() {
+    return "/api/notes/notes/";
+  }
+
+  getRemoteDeleteUrl(localObjInst) {
+    return `/api/notes/sessions/${localObjInst.get('session')}/notes/${localObjInst.get('_id')}/`;
+  }
+
+  getRemoteCreateUrl(localObjInst) {
+    return `/api/notes/sessions/${localObjInst.get('session')}/notes/`;
+  }
+
+  getRemoteUpdateUrl(localObjInst) {
+    return `/api/notes/sessions/${localObjInst.get('session')}/notes/${localObjInst.get('_id')}/`;
+  }
+
+  // build remote json message
+  getRemoteData(localObjInst) {
+
+    return {
+      ext_id: localObjInst.get('_id'),
+      session: localObjInst.get('session'),
+      raw: JSON.stringify(localObjInst.get('raw')),
+      plain: localObjInst.get('plain'),
+      html: localObjInst.get('html'),
+      tc_start: localObjInst.get('startedAt'),
+      tc_end: localObjInst.get('finishedAt'),
+      categorization: JSON.stringify(WebAnnotationSerializer.serialize(localObjInst)),
+      margin_note: localObjInst.get('marginComment'),
+    }
+
+  }
+
+  getLocalRecord(remoteObj) {
+    return new NoteRecord({
+      _id: remoteObj.ext_id,
+      session: remoteObj.session,
+      raw: JSON.parse(remoteObj.raw),
+      plain: remoteObj.plain,
+      html: remoteObj.html,
+      startedAt: remoteObj.tc_start,
+      finishedAt: remoteObj.tc_end,
+      categories: remoteObj.categorization,
+      marginComment: remoteObj.margin_note,
+    });
+  }
+
+  // actions
+  doDeleteLocalObj(localObjId) {
+    return doDeleteNote(localObjId);
+  }
+
+  resetLocalObj(localObjInst) {
+    return resetActionNote(localObjInst);
+  }
+
+  loadObj(objRecord) {
+    return loadNote(objRecord);
+  }
+
+}
+
+export class NoteSynchronizer extends SyncMixin(NoteSyncBase) {}
+
+export default NoteSynchronizer;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/sagas/SessionSyncronizer.js	Fri Jul 28 19:40:35 2017 +0200
@@ -0,0 +1,77 @@
+import { select } from 'redux-saga/effects'
+import { getCreatedSessions, getUpdatedSessions, getDeletedSessions } from './selectors';
+import { ActionEnum } from '../constants';
+import moment from 'moment';
+import SessionRecord from '../store/sessionRecord';
+import { doDeleteSession, loadSession, resetActionSession } from '../actions/sessionsActions';
+import Immutable from 'immutable';
+import SyncMixin from './BaseSyncronizer';
+
+class SessionSyncBase {
+
+  // local diffs (immutable)
+  * getLocalDiffs() {
+    return Immutable.Map({
+      created: yield select(getCreatedSessions),
+      updated: yield select(getUpdatedSessions),
+      deleted: yield select(getDeletedSessions)
+    })
+  }
+
+  // remote urls
+  getRemoteLoadUrl() {
+    return "/api/notes/sessions/";
+  }
+
+  getRemoteDeleteUrl(localObjInst) {
+    return `/api/notes/sessions/${localObjInst.get('_id')}/`;
+  }
+
+  getRemoteCreateUrl(localObjInst) {
+    return "/api/notes/sessions/";
+  }
+
+  getRemoteUpdateUrl(localObjInst) {
+    return `/api/notes/sessions/${localObjInst.get('_id')}/`;
+  }
+
+  // build remote json message
+  getRemoteData(localObjInst) {
+    return {
+      ext_id: localObjInst.get('_id'),
+      date: localObjInst.get('date'),
+      title: localObjInst.get('title'),
+      description: localObjInst.get('description'),
+      protocol: ''
+    };
+  }
+
+  getLocalRecord(remoteObj) {
+    return new SessionRecord({
+        _id: remoteObj.ext_id,
+        title: remoteObj.title,
+        description: remoteObj.description,
+        date: moment(remoteObj.date).toDate(),
+        action: ActionEnum.NONE,
+        group: null
+    });
+  }
+
+  // actions
+  doDeleteLocalObj(localObjId) {
+    return doDeleteSession(localObjId);
+  }
+
+  resetLocalObj(localObjInst) {
+    return resetActionSession(localObjInst);
+  }
+
+  loadObj(objRecord) {
+    return loadSession(objRecord);
+  }
+
+}
+
+export class SessionSynchronizer extends SyncMixin(SessionSyncBase) {}
+
+export default SessionSynchronizer;
--- a/client/src/sagas/networkSaga.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/sagas/networkSaga.js	Fri Jul 28 19:40:35 2017 +0200
@@ -4,14 +4,11 @@
 import * as persistConstants  from 'redux-persist/constants';
 import jwt_decode from 'jwt-decode';
 import moment from 'moment';
+import { delay } from './utils';
+import { setOnlineStatus } from '../actions/networkActions';
+import { getToken } from './selectors';
 
-// Utility function to delay effects
-function delay(millis) {
-    const promise = new Promise(resolve => {
-        setTimeout(() => resolve(true), millis)
-    });
-    return promise;
-}
+
 
 function pingServer(client, token) {
   const decodedToken = jwt_decode(token);
@@ -32,14 +29,14 @@
 }
 
 function* pollData(context) {
-  const token = yield select(state => state.token);
+  const token = yield select(getToken);
   // No token : we wait for a login
   if(!token) {
     yield take(types.AUTH_LOGIN_SUCCESS);
   }
   try {
     const res = yield pingServer(context.client, token);
-    yield call(context.callback, true);
+    yield put(setOnlineStatus(true));
     if(res.token) {
       yield put({
         type: types.AUTH_STORE_TOKEN_ASYNC,
@@ -47,7 +44,7 @@
       });
     }
   } catch (error) {
-    yield call(context.callback, false);
+    yield put(setOnlineStatus(false));
     //TODO: This is ugly...
     if ((error.non_field_errors &&
       error.non_field_errors &&
@@ -69,8 +66,8 @@
       // pollDate cancelled
       // if there is a token : this was a LOGIN, set status to ok
       // if there is no token : this was a LOGOUT, set status to ko and wait for login
-      const token = yield select(state => state.token);
-      yield call(context.callback, Boolean(token));
+      const token = yield select(getToken);
+      yield put(setOnlineStatus(Boolean(token)));
     }
   }
 }
@@ -83,8 +80,8 @@
       // pollDate cancelled
       // if there is a token : this was a LOGIN, set status to ok
       // if there is no token : this was a LOGOUT, set status to ko and wait for login
-      const token = yield select(state => state.token);
-      yield call(context.callback, Boolean(token));
+      const token = yield select(getToken);
+      yield put(setOnlineStatus(Boolean(token)));
     }
   }
 }
@@ -106,9 +103,7 @@
 }
 
 // Daemonize tasks in parallel
-export default function* root(baseContext) {
-  const actionRes = yield take(types.OFFLINE_CONFIG_INITIALIZED);
-  const context = {...baseContext, ...actionRes.additionalContext};
+export default function* root(context) {
   yield all([
     fork(watchPollData, context)
     // other watchers here
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/sagas/selectors.js	Fri Jul 28 19:40:35 2017 +0200
@@ -0,0 +1,49 @@
+// Define state selector for saga
+import Immutable from 'immutable';
+import { ActionEnum } from '../constants'
+
+export const getLastSync = state => state.getIn(['authStatus', 'lastSync']) || 0
+
+export const getToken = state => state.getIn(['authStatus','token'])
+
+const getSessionMapSelector = actionVal => state =>
+  state.get('sessions')
+    .filter(s => s.get('action') === actionVal)
+    .reduce(
+      (res, obj) => {
+        return res.set(obj.get('_id'), obj);
+      },
+      Immutable.Map()
+    );
+
+const getNoteMapSelector = actionVal => state => {
+  const deletedSessions = state.get('sessions')
+    .filter(s => s.get('action') === ActionEnum.DELETED)
+    .reduce(
+      (res, obj) => {
+        return res.set(obj.get('_id'), obj);
+      },
+      Immutable.Map()
+    );
+  return state.get('notes')
+    .filter(n => (n.get('action') === actionVal && !deletedSessions.has(n.get('session'))))
+    .reduce(
+      (res, obj) => {
+        return res.set(obj.get('_id'), obj);
+      },
+      Immutable.Map()
+    );
+}
+
+
+export const getUpdatedSessions = getSessionMapSelector(ActionEnum.UPDATED);
+
+export const getCreatedSessions = getSessionMapSelector(ActionEnum.CREATED);
+
+export const getDeletedSessions = getSessionMapSelector(ActionEnum.DELETED);
+
+export const getUpdatedNotes = getNoteMapSelector(ActionEnum.UPDATED);
+
+export const getCreatedNotes = getNoteMapSelector(ActionEnum.CREATED);
+
+export const getDeletedNotes = getNoteMapSelector(ActionEnum.DELETED);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/sagas/syncSaga.js	Fri Jul 28 19:40:35 2017 +0200
@@ -0,0 +1,41 @@
+import { put, take, all, select } from 'redux-saga/effects'
+import * as types from '../constants/actionTypes';
+import { getLastSync } from './selectors';
+import moment from 'moment';
+import { endSynchronize, updateLastSync } from '../actions/syncActions';
+import SessionSynchronizer from './SessionSyncronizer';
+import NoteSynchronizer from './NoteSyncronizer';
+
+
+function* watchSync(context) {
+  while (true) {
+    yield take(types.SYNC_DO_SYNC);
+    const lastSync = yield select(getLastSync);
+
+
+    //const sessions = yield context.client.get('/api/notes/sessions/', {modified_since: lastSync});
+    const nextLastSync = moment().unix()
+
+    // TODO: manage errors
+    try {
+      const syncObjects = yield context.client.get('/api/notes/sync/', { modified_since: lastSync });
+
+      const sessionSynchronizer = new SessionSynchronizer(syncObjects.sessions, context.client);
+      yield sessionSynchronizer.syncObjects();
+
+      const noteSynchronizer = new NoteSynchronizer(syncObjects.notes, context.client);
+      yield noteSynchronizer.syncObjects();
+
+      yield put(updateLastSync(nextLastSync));
+    } finally {
+      yield put(endSynchronize());
+    }
+  }
+}
+
+//--- The root saga
+export default function* rootSaga(context) {
+  yield all([
+    watchSync(context)
+  ]);
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/sagas/utils.js	Fri Jul 28 19:40:35 2017 +0200
@@ -0,0 +1,7 @@
+// Utility function to delay effects
+export const delay = function(millis) {
+    const promise = new Promise(resolve => {
+        setTimeout(() => resolve(true), millis)
+    });
+    return promise;
+}
--- a/client/src/store/configureStore.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/store/configureStore.js	Fri Jul 28 19:40:35 2017 +0200
@@ -1,39 +1,43 @@
 import rootReducer from '../reducers';
 import rootAuthSaga from '../sagas/authSaga';
 import rootGroupSaga from '../sagas/groupSaga';
+import rootSyncSaga from '../sagas/syncSaga';
 import networkSaga from '../sagas/networkSaga';
 import { compose, createStore, applyMiddleware } from 'redux';
 import { routerMiddleware } from 'react-router-redux';
 import createSagaMiddleware from 'redux-saga'
 import Immutable from 'immutable';
-import { offline } from 'redux-offline';
-import offlineDefaultConfig from 'redux-offline/lib/defaults';
+import {persistStore, autoRehydrate} from 'redux-persist-immutable'
 import localForage from 'localforage';
 import immutableTransform from 'redux-persist-transform-immutable';
 import NoteRecord from './noteRecord';
 import SessionRecord from './sessionRecord';
 import UserRecord from './userRecord';
 import APIClient from '../api/APIClient';
-import createEffect from '../api';
 import config from '../config';
-import { offlineConfigInitialized } from '../actions/networkActions';
 import asyncRequest from '../constants/asyncRequest';
-import * as types from '../constants/actionTypes';
 
-// const composeEnhancers = (process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) ?
-//     window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
-//         shouldHotReload: false,
-//     }) : compose;
-const composeEnhancers = compose;
+const composeEnhancers = (process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) ?
+    window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
+        shouldHotReload: false,
+    }) : compose;
 
 
 const defaultState = {
   sessions: Immutable.List([]),
   notes: Immutable.List([]),
   groups: Immutable.List([]),
-  isAuthenticated: false,
-  currentUser: null,
-  token: '',
+  status: Immutable.Map({
+    isSynchronizing: false,
+    online: false
+  }),
+  authStatus: Immutable.Map({
+    token: '',
+    isAuthenticated: false,
+    clientId: null,
+    lastSync: 0,
+    currentUser: null,
+  }),
   autoSubmit: false,
   login: asyncRequest,
   register: asyncRequest,
@@ -42,24 +46,19 @@
 
 const immutableTransformConfig = {
   records: [NoteRecord, SessionRecord, UserRecord],
-  whitelist: ['sessions', 'notes', 'currentUser']
+  whitelist: ['sessions', 'notes', 'authStatus']
 }
 
 const persistOptions = {
   storage: localForage,
+  records: [NoteRecord, SessionRecord, UserRecord],
   transforms: [immutableTransform(immutableTransformConfig)],
-  whitelist: ['sessions', 'notes', 'isAuthenticated', 'currentUser', 'token', 'offline', 'autoSubmit']
+  whitelist: ['sessions', 'notes', 'autoSubmit', 'authStatus']
 }
 
 const apiClient = new APIClient(config.apiRootUrl);
 
-const offlineConfig = {
-  ...offlineDefaultConfig,
-  persistOptions,
-  effect: createEffect(apiClient),
-}
-
-const storeInitialState = { ...defaultState };
+const storeInitialState = Immutable.Map({ ...defaultState });
 
 
 export default (history, initialState = storeInitialState) => {
@@ -67,10 +66,9 @@
   const router = routerMiddleware(history);
   const saga = createSagaMiddleware();
 
-  offlineConfig.detectNetwork = callback => { saga.run(networkSaga, { callback }); };
-
-  const store = offline(offlineConfig)(createStore)(rootReducer, initialState, composeEnhancers(
-    applyMiddleware(router, saga)
+  const store = createStore(rootReducer, initialState, composeEnhancers(
+    applyMiddleware(router, saga),
+    autoRehydrate()
   ));
 
   apiClient.setStore(store);
@@ -80,11 +78,12 @@
     history
   }
 
+  persistStore(store, persistOptions);
+
   saga.run(rootAuthSaga, context);
   saga.run(rootGroupSaga, context);
-
-  store.dispatch(offlineConfigInitialized({ client: apiClient }))
-  store.dispatch({ type: types.GROUP_LOAD_ASYNC })
+  saga.run(rootSyncSaga, context);
+  saga.run(networkSaga, context);
 
   return store;
 };
--- a/client/src/store/noteRecord.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/store/noteRecord.js	Fri Jul 28 19:40:35 2017 +0200
@@ -1,4 +1,5 @@
 import Immutable from 'immutable';
+import { ActionEnum } from '../constants';
 
 export default Immutable.Record({
   _id: '',
@@ -14,6 +15,5 @@
   categories: [],
 
   marginComment: '',
-  deleted: false,
-  modified: true
+  action: ActionEnum.NONE
 }, 'Note');
--- a/client/src/store/sessionRecord.js	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/src/store/sessionRecord.js	Fri Jul 28 19:40:35 2017 +0200
@@ -1,4 +1,5 @@
 import Immutable from 'immutable';
+import { ActionEnum } from '../constants';
 
 export default Immutable.Record({
   _id: '',
@@ -10,7 +11,6 @@
 
   group: null,
 
-  deleted: false,
-  modified: true
+  action: ActionEnum.NONE,
 
 }, 'Session');
--- a/client/yarn.lock	Tue Jul 25 19:11:26 2017 +0200
+++ b/client/yarn.lock	Fri Jul 28 19:40:35 2017 +0200
@@ -5186,6 +5186,10 @@
   version "6.4.0"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233"
 
+qs@^6.5.0:
+  version "6.5.0"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.0.tgz#8d04954d364def3efc55b5a0793e1e2c8b1e6e49"
+
 qs@~6.3.0:
   version "6.3.2"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.2.tgz#e75bd5f6e268122a2a0e0bda630b2550c166502c"
@@ -5523,22 +5527,23 @@
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/redux-immutable/-/redux-immutable-4.0.0.tgz#3a1a32df66366462b63691f0e1dc35e472bbc9f3"
 
-redux-offline@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/redux-offline/-/redux-offline-2.0.0.tgz#5896e95477417c8173e083f4f2d977466ec91808"
-  dependencies:
-    redux-persist "^4.5.0"
-
-redux-persist-transform-immutable@^4.3.0:
+redux-persist-immutable@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/redux-persist-immutable/-/redux-persist-immutable-4.3.0.tgz#cb919dd957622a4f9d7bb3d2ff99251b77d1c1ac"
+  dependencies:
+    redux-persist "^4.0.0"
+    redux-persist-transform-immutable "^4.1.0"
+
+redux-persist-transform-immutable@^4.1.0, redux-persist-transform-immutable@^4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/redux-persist-transform-immutable/-/redux-persist-transform-immutable-4.3.0.tgz#24720c99f0707dd99e920b95f851ae3d1baa6ed8"
   dependencies:
     transit-immutable-js "^0.7.0"
     transit-js "^0.8.846"
 
-redux-persist@^4.5.0:
-  version "4.8.0"
-  resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-4.8.0.tgz#17fd998949bdeef9275e4cf60ad5bbe1c73675fc"
+redux-persist@^4.0.0, redux-persist@^4.8.2:
+  version "4.8.2"
+  resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-4.8.2.tgz#7941202e0ce0a9fcc0263d66965f5263d34f43b9"
   dependencies:
     json-stringify-safe "^5.0.1"
     lodash "^4.17.4"
--- a/src/irinotes/settings.py	Tue Jul 25 19:11:26 2017 +0200
+++ b/src/irinotes/settings.py	Fri Jul 28 19:40:35 2017 +0200
@@ -13,6 +13,7 @@
 import datetime
 import logging
 
+from corsheaders.defaults import default_headers
 from decouple import Csv, config
 from dj_database_url import parse as db_url
 from unipath import Path
@@ -251,4 +252,8 @@
 CORS_ORIGIN_ALLOW_ALL = True
 CORS_ALLOW_CREDENTIALS = True
 
+CORS_ALLOW_HEADERS = default_headers + (
+    'auditlog-client',
+)
+
 CORS_URLS_REGEX = r'^/api/.*$'
--- a/src/notes/api/views/sync.py	Tue Jul 25 19:11:26 2017 +0200
+++ b/src/notes/api/views/sync.py	Fri Jul 28 19:40:35 2017 +0200
@@ -19,17 +19,23 @@
     """
     permission_classes = (permissions.IsAuthenticated,)
 
-    def __filter_object(self, model, user, modified_since):
+    def __filter_object(self, model, user, modified_since, client_id):
+        """
+        Log entries are filtered by model, actor and timestamp.
+        If a client id is given, log entries from this client are ignored.
+        """
         log_entries = LogEntry.objects.get_for_model(model).filter(actor=user)
         if modified_since:
             log_entries = log_entries.filter(timestamp__gte=modified_since)
+        if client_id:
+            log_entries = log_entries.exclude(client=client_id)
         return log_entries.order_by('timestamp')
 
-    def __process_log_entries(self, model, user, modified_since):
+    def __process_log_entries(self, model, user, modified_since, client_id):
         '''
         Process log entries
         '''
-        log_entries = self.__filter_object(model, user, modified_since)
+        log_entries = self.__filter_object(model, user, modified_since, client_id)
         logger.debug("LOG ENTRies %r", list(log_entries))
 
         res = {}
@@ -68,7 +74,7 @@
 
     def get(self, request, format=None):
         """
-        Return a list of all users.
+        Return an aggregations of all changes.
         """
         modified_since_str = request.query_params.get('modified_since', None)
         modified_since = None
@@ -79,8 +85,9 @@
             )
 
         user = request.user
-        res_sessions = self.__process_log_entries(Session, user, modified_since)
-        res_notes = self.__process_log_entries(Note, user, modified_since)
+        client_id = request.META.get('HTTP_AUDITLOG_CLIENT', None)
+        res_sessions = self.__process_log_entries(Session, user, modified_since, client_id)
+        res_notes = self.__process_log_entries(Note, user, modified_since, client_id)
 
         return Response({
             'sessions': res_sessions.values(),
--- a/src/notes/tests/api/sync.py	Tue Jul 25 19:11:26 2017 +0200
+++ b/src/notes/tests/api/sync.py	Fri Jul 28 19:40:35 2017 +0200
@@ -3,6 +3,7 @@
 """
 import datetime
 import logging
+import uuid
 
 from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
@@ -40,15 +41,16 @@
             password='top_secret'
         )
 
+        self.clientId = str(uuid.uuid4())
+
         url = reverse('notes:session-list')
         self.client.login(username='test_user1', password='top_secret')
         response = self.client.post(url, {
             'title': "a new session 1",
             'description': "Description 1",
             'protocol': "[]"
-        }, format='json')
+        }, format='json', HTTP_AUDITLOG_CLIENT=self.clientId)
 
-        logger.debug('REPOSNSE %r', response.json())
 
         self.session1 = Session.objects.get(ext_id=response.json()['ext_id'])
         self.client.logout()
@@ -154,7 +156,16 @@
         for sync_def in json_resp['notes']:
             self.assertEqual('note', sync_def['type'])
             self.assertEqual(0, sync_def['action'])
-            self.assertIn(sync_def['ext_id'],[str(self.note1.ext_id), str(self.note2.ext_id)])
+            self.assertIn(sync_def['ext_id'], [str(self.note1.ext_id), str(self.note2.ext_id)])
+
+    def test_simple_output_client_id(self):
+        url = reverse('notes:sync-list')
+        self.client.login(username='test_user1', password='top_secret')
+        response = self.client.get(url, HTTP_AUDITLOG_CLIENT=self.clientId)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        json_resp = response.json()
+        self.assertIn('sessions', json_resp)
+        self.assertEqual(0, len(json_resp['sessions']))
 
     def test_modified_since_empty(self):
         url = reverse('notes:sync-list')
--- a/src/requirements/base.txt	Tue Jul 25 19:11:26 2017 +0200
+++ b/src/requirements/base.txt	Fri Jul 28 19:40:35 2017 +0200
@@ -1,10 +1,10 @@
-certifi==2017.4.17
+certifi==2017.7.27.1
 chardet==3.0.4
 defusedxml==0.5.0
 dj-database-url==0.4.2
 Django==1.11.3
 django-allauth==0.32.0
-django-auditlog==0.4.3
+git+https://github.com/IRI-Research/django-auditlog@master#egg=django-auditlog
 django-colorful==1.2
 django-concurrency==1.4
 django-cors-headers==2.1.0
@@ -17,15 +17,14 @@
 djangorestframework-jwt==1.11.0
 drf-nested-routers==0.90.0
 idna==2.5
-irinotes==0.0.1
 Markdown==2.6.8
 oauthlib==2.0.2
 PyJWT==1.5.2
 python-decouple==3.0
 python3-openid==3.1.0
 pytz==2017.2
-requests==2.18.1
+requests==2.18.2
 requests-oauthlib==0.8.0
 six==1.10.0
 Unipath==1.1
-urllib3==1.21.1
+urllib3==1.22
--- a/src/requirements/base.txt.in	Tue Jul 25 19:11:26 2017 +0200
+++ b/src/requirements/base.txt.in	Fri Jul 28 19:40:35 2017 +0200
@@ -1,2 +1,3 @@
 # must run "pip install -r base.txt.in" in src/requirements
+--process-dependency-links
 -e ..
--- a/src/setup.py	Tue Jul 25 19:11:26 2017 +0200
+++ b/src/setup.py	Fri Jul 28 19:40:35 2017 +0200
@@ -140,7 +140,7 @@
             "Unipath",
             "dj-database-url",
             "six",
-            "django-auditlog",
+            "django-auditlog == 0.4.3dev",
             "django-extensions",
             "djangorestframework >= 3.6",
             "django-rest-auth[with_social]",
@@ -153,6 +153,9 @@
             "drf-nested-routers",
             "markdown"
         ],
+        dependency_links=[
+            "https://github.com/IRI-Research/django-auditlog/tarball/master#egg=django-auditlog-0.4.3dev"
+        ]
     )