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
--- 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"></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"
+ ]
)