# HG changeset patch # User ymh # Date 1501263635 -7200 # Node ID d48946d164c61aef7744b967826412718fd405ef # Parent 34a75bd8d0b958046b94e9c414d61c0545d0e97c 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 diff -r 34a75bd8d0b9 -r d48946d164c6 client/package.json --- 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", diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/actions/authActions.js --- 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 } } diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/actions/networkActions.js --- 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 + } +} diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/actions/notesActions.js --- 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 }; +} + diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/actions/sessionsActions.js --- 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 diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/actions/syncActions.js --- /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 + } +} diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/api/APIClient.js --- 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) => { diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/api/WebAnnotationSerializer.js --- 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) => { diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/components/Login.js --- 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') }; } diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/components/Navbar.js --- 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 ( - signal_wifi_4_bar + signal_wifi_4_bar + + ) +} + +const SyncButton = ({ onSyncClick, isSynchronizing }) => { + return ( + + Sync + {isSynchronizing && } ) } @@ -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 ( @@ -102,6 +118,7 @@ Sessions @@ -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) } } diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/components/Register.js --- 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') }; } diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/components/Session.js --- 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 { diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/components/SessionForm.js --- 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 }; } diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/components/SessionList.js --- 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) }; } diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/components/Settings.js --- 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']), }; } diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/constants/actionTypes.js --- 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'; diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/constants/index.js --- /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 +} diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/misc/AuthenticatedRoute.js --- 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 ; diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/reducers/authReducer.js --- 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: diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/reducers/index.js --- 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; diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/reducers/miscReducer.js --- 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; } + } diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/reducers/notesReducer.js --- 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; } diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/reducers/sessionsReducer.js --- 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; } diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/reducers/syncReducer.js --- /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; + } +} diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/sagas/BaseSyncronizer.js --- /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; diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/sagas/NoteSyncronizer.js --- /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; diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/sagas/SessionSyncronizer.js --- /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; diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/sagas/networkSaga.js --- 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 diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/sagas/selectors.js --- /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); diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/sagas/syncSaga.js --- /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) + ]); +} diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/sagas/utils.js --- /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; +} diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/store/configureStore.js --- 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; }; diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/store/noteRecord.js --- 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'); diff -r 34a75bd8d0b9 -r d48946d164c6 client/src/store/sessionRecord.js --- 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'); diff -r 34a75bd8d0b9 -r d48946d164c6 client/yarn.lock --- 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" diff -r 34a75bd8d0b9 -r d48946d164c6 src/irinotes/settings.py --- 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/.*$' diff -r 34a75bd8d0b9 -r d48946d164c6 src/notes/api/views/sync.py --- 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(), diff -r 34a75bd8d0b9 -r d48946d164c6 src/notes/tests/api/sync.py --- 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') diff -r 34a75bd8d0b9 -r d48946d164c6 src/requirements/base.txt --- 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 diff -r 34a75bd8d0b9 -r d48946d164c6 src/requirements/base.txt.in --- 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 .. diff -r 34a75bd8d0b9 -r d48946d164c6 src/setup.py --- 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" + ] )