Content sync

This commit is contained in:
benweet 2017-08-18 00:10:35 +01:00
parent 1e74fb00dc
commit d258d1c9c4
45 changed files with 664 additions and 330 deletions

View File

@ -1,3 +1,3 @@
build/*.js build/*.js
config/*.js config/*.js
src/cledit/*.js src/libs/*.js

View File

@ -13,7 +13,6 @@
"dependencies": { "dependencies": {
"bezier-easing": "^1.1.0", "bezier-easing": "^1.1.0",
"clunderscore": "^1.0.3", "clunderscore": "^1.0.3",
"debug": "^2.6.8",
"diff-match-patch": "^1.0.0", "diff-match-patch": "^1.0.0",
"indexeddbshim": "^3.0.4", "indexeddbshim": "^3.0.4",
"katex": "^0.7.1", "katex": "^0.7.1",

View File

@ -21,7 +21,7 @@ export default {
'ready', 'ready',
]), ]),
loading() { loading() {
return !this.$store.getters['contents/current'].id; return !this.$store.getters['content/current'].id;
}, },
showModal() { showModal() {
return !!this.$store.state.modal.content; return !!this.$store.state.modal.content;

View File

@ -70,13 +70,13 @@ export default {
const recursiveDelete = (folderNode) => { const recursiveDelete = (folderNode) => {
folderNode.folders.forEach(recursiveDelete); folderNode.folders.forEach(recursiveDelete);
folderNode.files.forEach((fileNode) => { folderNode.files.forEach((fileNode) => {
this.$store.commit('files/deleteItem', fileNode.item.id); this.$store.commit('file/deleteItem', fileNode.item.id);
}); });
this.$store.commit('folders/deleteItem', folderNode.item.id); this.$store.commit('folder/deleteItem', folderNode.item.id);
}; };
recursiveDelete(selectedNode); recursiveDelete(selectedNode);
} else { } else {
this.$store.commit('files/deleteItem', selectedNode.item.id); this.$store.commit('file/deleteItem', selectedNode.item.id);
} }
} }
}); });
@ -85,7 +85,7 @@ export default {
}, },
created() { created() {
this.$store.watch( this.$store.watch(
() => this.$store.getters['files/current'].id, () => this.$store.getters['file/current'].id,
(currentFileId) => { (currentFileId) => {
this.$store.commit('explorer/setSelectedId', currentFileId); this.$store.commit('explorer/setSelectedId', currentFileId);
this.$store.dispatch('explorer/openNode', currentFileId); this.$store.dispatch('explorer/openNode', currentFileId);

View File

@ -91,7 +91,7 @@ export default {
if (node.isFolder) { if (node.isFolder) {
this.$store.commit('explorer/toggleOpenNode', id); this.$store.commit('explorer/toggleOpenNode', id);
} else { } else {
this.$store.commit('files/setCurrentId', id); this.$store.commit('file/setCurrentId', id);
} }
} }
}, },
@ -100,20 +100,18 @@ export default {
if (!cancel && !newChildNode.isNil && newChildNode.item.name) { if (!cancel && !newChildNode.isNil && newChildNode.item.name) {
const id = utils.uid(); const id = utils.uid();
if (newChildNode.isFolder) { if (newChildNode.isFolder) {
this.$store.commit('folders/setItem', { this.$store.commit('folder/setItem', {
...newChildNode.item, ...newChildNode.item,
id, id,
}); });
} else { } else {
const contentId = utils.uid(); this.$store.commit('content/setItem', {
this.$store.commit('contents/setItem', { id: `${id}/content`,
id: contentId,
text: defaultContent, text: defaultContent,
}); });
this.$store.commit('files/setItem', { this.$store.commit('file/setItem', {
...newChildNode.item, ...newChildNode.item,
id, id,
contentId,
}); });
} }
this.select(id); this.select(id);
@ -124,7 +122,7 @@ export default {
const id = this.$store.getters['explorer/editingNode'].item.id; const id = this.$store.getters['explorer/editingNode'].item.id;
const value = this.editingValue; const value = this.editingValue;
if (!cancel && id && value) { if (!cancel && id && value) {
this.$store.commit('files/patchItem', { this.$store.commit('file/patchItem', {
id, id,
name: value.slice(0, 250), name: value.slice(0, 250),
}); });
@ -152,9 +150,9 @@ export default {
parentId: targetNode.item.id, parentId: targetNode.item.id,
}; };
if (sourceNode.isFolder) { if (sourceNode.isFolder) {
this.$store.commit('folders/patchItem', patch); this.$store.commit('folder/patchItem', patch);
} else { } else {
this.$store.commit('files/patchItem', patch); this.$store.commit('file/patchItem', patch);
} }
} }
}, },

View File

@ -127,6 +127,7 @@ export default {
.layout__panel--button-bar, .layout__panel--button-bar,
.layout__panel--status-bar, .layout__panel--status-bar,
.layout__panel--side-bar, .layout__panel--side-bar,
.layout__panel--explorer,
.layout__panel--navigation-bar { .layout__panel--navigation-bar {
.app--loading & > * { .app--loading & > * {
opacity: 0.5; opacity: 0.5;

View File

@ -122,9 +122,9 @@ export default {
} else { } else {
const title = this.title.trim(); const title = this.title.trim();
if (title) { if (title) {
this.$store.dispatch('files/patchCurrent', { name: title.slice(0, 250) }); this.$store.dispatch('file/patchCurrent', { name: title.slice(0, 250) });
} else { } else {
this.title = this.$store.getters['files/current'].name; this.title = this.$store.getters['file/current'].name;
} }
} }
}, },
@ -137,7 +137,7 @@ export default {
}, },
created() { created() {
this.$store.watch( this.$store.watch(
() => this.$store.getters['files/current'].name, () => this.$store.getters['file/current'].name,
(name) => { (name) => {
this.title = name; this.title = name;
}, { immediate: true }); }, { immediate: true });

View File

@ -15,7 +15,7 @@
<div v-if="panel === 'menu'" class="side-bar__panel side-bar__panel--menu"> <div v-if="panel === 'menu'" class="side-bar__panel side-bar__panel--menu">
<side-bar-item v-if="!loginToken" @click.native="signin"> <side-bar-item v-if="!loginToken" @click.native="signin">
<icon-login slot="icon"></icon-login> <icon-login slot="icon"></icon-login>
<div>Sign in with Google</div> <div>Sign in</div>
<span>Have all your files and settings backed up and synced.</span> <span>Have all your files and settings backed up and synced.</span>
</side-bar-item> </side-bar-item>
<!-- <side-bar-item @click.native="signin"> <!-- <side-bar-item @click.native="signin">

View File

@ -108,7 +108,7 @@ export default {
width: 100%; width: 100%;
height: 100%; height: 100%;
color: #fff; color: #fff;
font-size: 13px; font-size: 12px;
} }
.stat-panel__block { .stat-panel__block {

View File

@ -1,5 +1,4 @@
export default () => ({ export default () => ({
id: 'localSettings',
showNavigationBar: true, showNavigationBar: true,
showEditor: true, showEditor: true,
showSidePreview: true, showSidePreview: true,
@ -7,5 +6,4 @@ export default () => ({
showSideBar: false, showSideBar: false,
showExplorer: false, showExplorer: false,
focusMode: false, focusMode: false,
updated: 0,
}); });

View File

@ -1,10 +1,10 @@
export default () => ({ export default () => ({
id: null, id: null,
type: 'content', type: 'content',
state: {},
text: '\n', text: '\n',
properties: {}, properties: '\n',
discussions: {}, discussions: {},
comments: {}, comments: {},
syncLocations: [],
updated: 0, updated: 0,
}); });

View File

@ -0,0 +1,8 @@
export default () => ({
id: null,
type: 'contentState',
selectionStart: 0,
selectionEnd: 0,
scrollPosition: null,
updated: 0,
});

View File

@ -3,5 +3,4 @@ export default () => ({
type: 'file', type: 'file',
name: '', name: '',
parentId: null, parentId: null,
contentId: null,
}); });

View File

@ -0,0 +1,7 @@
export default () => ({
id: null,
type: 'syncContent',
contentRevisions: {},
syncLocationData: {},
updated: 0,
});

View File

@ -1,6 +1,6 @@
import DiffMatchPatch from 'diff-match-patch'; import DiffMatchPatch from 'diff-match-patch';
import cledit from '../cledit/cledit'; import cledit from '../libs/cledit';
import clDiffUtils from '../cledit/cldiffutils'; import clDiffUtils from '../libs/cldiffutils';
import store from '../store'; import store from '../store';
let clEditor; let clEditor;
@ -35,7 +35,7 @@ function getDiscussionMarkers(discussion, discussionId, onMarker) {
} }
function syncDiscussionMarkers() { function syncDiscussionMarkers() {
const content = store.getters['contents/current']; const content = store.getters['content/current'];
Object.keys(discussionMarkers) Object.keys(discussionMarkers)
.forEach((markerKey) => { .forEach((markerKey) => {
const marker = discussionMarkers[markerKey]; const marker = discussionMarkers[markerKey];
@ -100,9 +100,9 @@ export default {
markerIdxMap = Object.create(null); markerIdxMap = Object.create(null);
discussionMarkers = {}; discussionMarkers = {};
clEditor.on('contentChanged', (text) => { clEditor.on('contentChanged', (text) => {
store.dispatch('contents/patchCurrent', { text }); store.dispatch('content/patchCurrent', { text });
syncDiscussionMarkers(); syncDiscussionMarkers();
const content = store.getters['contents/current']; const content = store.getters['content/current'];
if (!isChangePatch) { if (!isChangePatch) {
previousPatchableText = currentPatchableText; previousPatchableText = currentPatchableText;
currentPatchableText = clDiffUtils.makePatchableText(content, markerKeys, markerIdxMap); currentPatchableText = clDiffUtils.makePatchableText(content, markerKeys, markerIdxMap);
@ -123,7 +123,8 @@ export default {
clEditor.addMarker(newDiscussionMarker1); clEditor.addMarker(newDiscussionMarker1);
}, },
initClEditor(opts, reinit) { initClEditor(opts, reinit) {
const content = store.getters['contents/current']; const content = store.getters['content/current'];
const contentState = store.getters['contentState/current'];
if (content) { if (content) {
const options = Object.assign({}, opts); const options = Object.assign({}, opts);
@ -136,8 +137,8 @@ export default {
if (reinit) { if (reinit) {
options.content = content.text; options.content = content.text;
options.selectionStart = content.state.selectionStart; options.selectionStart = contentState.selectionStart;
options.selectionEnd = content.state.selectionEnd; options.selectionEnd = contentState.selectionEnd;
} }
options.patchHandler = { options.patchHandler = {
@ -156,7 +157,7 @@ export default {
this.lastExternalChange = Date.now(); this.lastExternalChange = Date.now();
} }
syncDiscussionMarkers(); syncDiscussionMarkers();
const content = store.getters['contents/current']; const content = store.getters['content/current'];
return clEditor.setContent(content.text, isExternal); return clEditor.setContent(content.text, isExternal);
}, },
}; };

View File

@ -2,9 +2,9 @@ import Vue from 'vue';
import DiffMatchPatch from 'diff-match-patch'; import DiffMatchPatch from 'diff-match-patch';
import Prism from 'prismjs'; import Prism from 'prismjs';
import markdownItPandocRenderer from 'markdown-it-pandoc-renderer'; import markdownItPandocRenderer from 'markdown-it-pandoc-renderer';
import cledit from '../cledit/cledit'; import cledit from '../libs/cledit';
import pagedown from '../cledit/pagedown'; import pagedown from '../libs/pagedown';
import htmlSanitizer from '../cledit/htmlSanitizer'; import htmlSanitizer from '../libs/htmlSanitizer';
import markdownConversionSvc from './markdownConversionSvc'; import markdownConversionSvc from './markdownConversionSvc';
import markdownGrammarSvc from './markdownGrammarSvc'; import markdownGrammarSvc from './markdownGrammarSvc';
import sectionUtils from './sectionUtils'; import sectionUtils from './sectionUtils';
@ -187,7 +187,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
}; };
editorEngineSvc.initClEditor(options, reinitClEditor); editorEngineSvc.initClEditor(options, reinitClEditor);
editorEngineSvc.clEditor.toggleEditable(true); editorEngineSvc.clEditor.toggleEditable(true);
const contentId = store.getters['contents/current'].id; const contentId = store.getters['content/current'].id;
// Switch off the editor when no content is loaded // Switch off the editor when no content is loaded
editorEngineSvc.clEditor.toggleEditable(!!contentId); editorEngineSvc.clEditor.toggleEditable(!!contentId);
reinitClEditor = false; reinitClEditor = false;
@ -361,13 +361,11 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
*/ */
saveContentState: allowDebounce(() => { saveContentState: allowDebounce(() => {
const scrollPosition = editorSvc.getScrollPosition() || const scrollPosition = editorSvc.getScrollPosition() ||
store.getters['contents/current'].state.scrollPosition; store.getters['contentState/current'].scrollPosition;
store.dispatch('contents/patchCurrent', { store.dispatch('contentState/patchCurrent', {
state: { selectionStart: editorEngineSvc.clEditor.selectionMgr.selectionStart,
selectionStart: editorEngineSvc.clEditor.selectionMgr.selectionStart, selectionEnd: editorEngineSvc.clEditor.selectionMgr.selectionEnd,
selectionEnd: editorEngineSvc.clEditor.selectionMgr.selectionEnd, scrollPosition,
scrollPosition,
},
}); });
}, 100), }, 100),
@ -375,7 +373,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
* Restore the scroll position from the current file content state. * Restore the scroll position from the current file content state.
*/ */
restoreScrollPosition() { restoreScrollPosition() {
const scrollPosition = store.getters['contents/current'].state.scrollPosition; const scrollPosition = store.getters['contentState/current'].scrollPosition;
if (scrollPosition && this.sectionDescMeasuredList) { if (scrollPosition && this.sectionDescMeasuredList) {
const objectToScroll = this.getObjectToScroll(); const objectToScroll = this.getObjectToScroll();
const sectionDesc = this.sectionDescMeasuredList[scrollPosition.sectionIdx]; const sectionDesc = this.sectionDescMeasuredList[scrollPosition.sectionIdx];
@ -504,10 +502,10 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
// const view = { // const view = {
// file: { // file: {
// name: rootState.files.currentFile.name, // name: rootState.file.currentFile.name,
// content: { // content: {
// properties: rootState.files.currentFile.content.properties, // properties: rootState.file.currentFile.content.properties,
// text: rootState.files.currentFile.content.text, // text: rootState.file.currentFile.content.text,
// html: state.previewHtml, // html: state.previewHtml,
// toc, // toc,
// }, // },
@ -720,10 +718,10 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
// Watch file content properties changes // Watch file content properties changes
store.watch( store.watch(
() => store.getters['contents/current'].properties, () => store.getters['content/current'].properties,
(properties) => { (properties) => {
// Track ID changes at the same time // Track ID changes at the same time
const contentId = store.getters['contents/current'].id; const contentId = store.getters['content/current'].id;
let initClEditor = false; let initClEditor = false;
if (contentId !== lastContentId) { if (contentId !== lastContentId) {
reinitClEditor = true; reinitClEditor = true;

View File

@ -31,75 +31,17 @@ const request = (googleToken, options) => utils.request({
}, },
}); });
const saveFile = (googleToken, data, appData) => {
const options = {
method: 'POST',
url: 'https://www.googleapis.com/upload/drive/v2/files',
headers: {},
};
if (appData) {
options.method = 'PUT';
options.url = `https://www.googleapis.com/drive/v2/files/${appData.id}`;
options.headers['if-match'] = appData.etag;
}
const metadata = {
title: data.name,
parents: [{
id: 'appDataFolder',
}],
properties: Object.keys(data)
.filter(key => key !== 'name' && key !== 'tx')
.map(key => ({
key,
value: JSON.stringify(data[key]),
visibility: 'PUBLIC',
})),
};
const media = null;
const boundary = `-------${utils.uid()}`;
const delimiter = `\r\n--${boundary}\r\n`;
const closeDelimiter = `\r\n--${boundary}--`;
if (media) {
let multipartRequestBody = '';
multipartRequestBody += delimiter;
multipartRequestBody += 'Content-Type: application/json\r\n\r\n';
multipartRequestBody += JSON.stringify(metadata);
multipartRequestBody += delimiter;
multipartRequestBody += 'Content-Type: application/json\r\n\r\n';
multipartRequestBody += JSON.stringify(media);
multipartRequestBody += closeDelimiter;
return request(googleToken, {
...options,
params: {
uploadType: 'multipart',
},
headers: {
...options.headers,
'Content-Type': `multipart/mixed; boundary="${boundary}"`,
},
body: multipartRequestBody,
});
}
return request(googleToken, {
...options,
body: metadata,
}).then(res => ({
id: res.body.id,
etag: res.body.etag,
}));
};
export default { export default {
startOauth2(scopes, sub = null, silent = false) { startOauth2(scopes, sub = null, silent = false) {
return utils.startOauth2( return utils.startOauth2(
'https://accounts.google.com/o/oauth2/v2/auth', { 'https://accounts.google.com/o/oauth2/v2/auth', {
client_id: clientId, client_id: clientId,
response_type: 'token', response_type: 'token',
scope: scopes.join(' '), scope: scopes.join(' '),
hd: appsDomain, hd: appsDomain,
login_hint: sub, login_hint: sub,
prompt: silent ? 'none' : null, prompt: silent ? 'none' : null,
}, silent) }, silent)
// Call the tokeninfo endpoint // Call the tokeninfo endpoint
.then(data => utils.request({ .then(data => utils.request({
method: 'POST', method: 'POST',
@ -137,9 +79,9 @@ export default {
if (!sub) { if (!sub) {
throw new Error('Google account already linked.'); throw new Error('Google account already linked.');
} }
// Add isLogin and lastChangeId to googleToken // Add isLogin and nextPageToken to googleToken
googleToken.isLogin = existingToken.isLogin; googleToken.isLogin = existingToken.isLogin;
googleToken.lastChangeId = existingToken.lastChangeId; googleToken.nextPageToken = existingToken.nextPageToken;
} }
// Add googleToken to googleTokens // Add googleToken to googleTokens
store.dispatch('data/setGoogleToken', googleToken); store.dispatch('data/setGoogleToken', googleToken);
@ -177,48 +119,122 @@ export default {
let changes = []; let changes = [];
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken) return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken)
.then((refreshedToken) => { .then((refreshedToken) => {
const lastChangeId = refreshedToken.lastChangeId || 0; const getPage = (pageToken = '1') => request(refreshedToken, {
const getPage = pageToken => request(refreshedToken, {
method: 'GET', method: 'GET',
url: 'https://www.googleapis.com/drive/v2/changes', url: 'https://www.googleapis.com/drive/v3/changes',
params: { params: {
pageToken, pageToken,
startChangeId: pageToken || !lastChangeId ? null : lastChangeId + 1,
spaces: 'appDataFolder', spaces: 'appDataFolder',
fields: 'nextPageToken,items(deleted,file/id,file/etag,file/title,file/properties(key,value))', pageSize: 1000,
fields: 'nextPageToken,newStartPageToken,changes(fileId,removed,file/name,file/properties)',
}, },
}).then((res) => { }).then((res) => {
changes = changes.concat(res.body.items); changes = changes.concat(res.body.changes.filter(item => item.fileId));
if (res.body.nextPageToken) { if (res.body.nextPageToken) {
return getPage(res.body.nextPageToken); return getPage(res.body.nextPageToken);
} }
changes.forEach((change) => {
if (change.file) {
change.item = {
name: change.file.name,
};
if (change.file.properties) {
Object.keys(change.file.properties).forEach((key) => {
change.item[key] = JSON.parse(change.file.properties[key]);
});
}
change.syncData = {
id: change.fileId,
itemId: change.item.id,
updated: change.item.updated,
};
change.file = undefined;
}
});
changes.nextPageToken = res.body.newStartPageToken;
return changes; return changes;
}); });
return getPage(); return getPage(refreshedToken.nextPageToken);
}); });
}, },
updateLastChangeId(googleToken, changes) { updateNextPageToken(googleToken, changes) {
const refreshedToken = store.getters['data/googleTokens'][googleToken.sub]; const lastToken = store.getters['data/googleTokens'][googleToken.sub];
let lastChangeId = refreshedToken.lastChangeId || 0; if (changes.nextPageToken !== lastToken.nextPageToken) {
changes.forEach((change) => {
if (change.id > lastChangeId) {
lastChangeId = change.id;
}
});
if (lastChangeId !== refreshedToken.lastChangeId) {
store.dispatch('data/setGoogleToken', { store.dispatch('data/setGoogleToken', {
...refreshedToken, ...lastToken,
lastChangeId, nextPageToken: changes.nextPageToken,
}); });
} }
}, },
insertData(googleToken, data) { saveItem(googleToken, item, syncData, ifNotTooLate = cb => res => cb(res)) {
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken) return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken)
.then(refreshedToken => saveFile(refreshedToken, data)); // Refreshing a token can take a while if an oauth window pops up, so check if it's too late
.then(ifNotTooLate((refreshedToken) => {
const options = {
method: 'POST',
url: 'https://www.googleapis.com/drive/v3/files',
};
const metadata = {
name: item.name,
properties: {},
};
if (syncData) {
options.method = 'PATCH';
options.url = `https://www.googleapis.com/drive/v3/files/${syncData.id}`;
} else {
// Parents field is not patchable
metadata.parents = ['appDataFolder'];
}
Object.keys(item).forEach((key) => {
if (key !== 'name' && key !== 'tx') {
metadata.properties[key] = JSON.stringify(item[key]);
}
});
const media = null;
const boundary = `-------${utils.uid()}`;
const delimiter = `\r\n--${boundary}\r\n`;
const closeDelimiter = `\r\n--${boundary}--`;
if (media) {
let multipartRequestBody = '';
multipartRequestBody += delimiter;
multipartRequestBody += 'Content-Type: application/json\r\n\r\n';
multipartRequestBody += JSON.stringify(metadata);
multipartRequestBody += delimiter;
multipartRequestBody += 'Content-Type: application/json\r\n\r\n';
multipartRequestBody += JSON.stringify(media);
multipartRequestBody += closeDelimiter;
options.url = options.url.replace(
'https://www.googleapis.com/',
'https://www.googleapis.com/upload/');
return request(refreshedToken, {
...options,
params: {
uploadType: 'multipart',
},
headers: {
'Content-Type': `multipart/mixed; boundary="${boundary}"`,
},
body: multipartRequestBody,
});
}
return request(refreshedToken, {
...options,
body: metadata,
}).then(res => ({
// Build sync data
id: res.body.id,
itemId: item.id,
updated: item.updated,
}));
}));
}, },
updateData(googleToken, data, appData) { removeItem(googleToken, syncData, ifNotTooLate = cb => res => cb(res)) {
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken) return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken)
.then(refreshedToken => saveFile(refreshedToken, data, appData)); // Refreshing a token can take a while if an oauth window pops up, so check if it's too late
.then(ifNotTooLate(refreshedToken => request(refreshedToken, {
method: 'DELETE',
url: `https://www.googleapis.com/drive/v3/files/${syncData.id}`,
})).then(() => syncData));
}, },
}; };

View File

@ -1,10 +1,8 @@
import 'babel-polyfill'; import 'babel-polyfill';
import 'indexeddbshim'; import 'indexeddbshim';
import debug from 'debug'; import utils from './utils';
import store from '../store'; import store from '../store';
const dbg = debug('stackedit:localDbSvc');
let indexedDB = window.indexedDB; let indexedDB = window.indexedDB;
const localStorage = window.localStorage; const localStorage = window.localStorage;
const dbVersion = 1; const dbVersion = 1;
@ -15,12 +13,6 @@ if (window.shimIndexedDB && (!indexedDB || (navigator.userAgent.indexOf('Chrome'
indexedDB = window.shimIndexedDB; indexedDB = window.shimIndexedDB;
} }
function getStorePrefixFromType(type) {
// Return `files` for type `file`, `folders` for type `folder`, etc...
const prefix = `${type}s`;
return store.state[prefix] ? prefix : 'data';
}
const deleteMarkerMaxAge = 1000; const deleteMarkerMaxAge = 1000;
class Connection { class Connection {
@ -39,7 +31,7 @@ class Connection {
localStorage.localDbVersion = this.db.version; // Safari does not support onversionchange localStorage.localDbVersion = this.db.version; // Safari does not support onversionchange
this.db.onversionchange = () => window.location.reload(); this.db.onversionchange = () => window.location.reload();
this.getTxCbs.forEach(cb => this.createTx(cb)); this.getTxCbs.forEach(({ onTx, onError }) => this.createTx(onTx, onError));
this.getTxCbs = null; this.getTxCbs = null;
}; };
@ -68,39 +60,46 @@ class Connection {
} }
/** /**
* Create a connection asynchronously. * Create a transaction asynchronously.
*/ */
createTx(cb) { createTx(onTx, onError) {
// If DB is not ready, keep callbacks for later
if (!this.db) { if (!this.db) {
this.getTxCbs.push(cb); return this.getTxCbs.push({ onTx, onError });
return;
} }
// If DB version has changed (Safari support) // If DB version has changed (Safari support)
if (parseInt(localStorage.localDbVersion, 10) !== this.db.version) { if (parseInt(localStorage.localDbVersion, 10) !== this.db.version) {
window.location.reload(); return window.location.reload();
return;
} }
// Open transaction in read/write will prevent conflict with other tabs // Open transaction in read/write will prevent conflict with other tabs
const tx = this.db.transaction(this.db.objectStoreNames, 'readwrite'); const tx = this.db.transaction(this.db.objectStoreNames, 'readwrite');
tx.onerror = (evt) => { tx.onerror = onError;
dbg('Rollback transaction', evt);
}; return onTx(tx);
// Read the current txCounter }
const dbStore = tx.objectStore(dbStoreName); }
const request = dbStore.get('txCounter');
request.onsuccess = () => { const updatedMap = {};
tx.txCounter = request.result ? request.result.tx : 0; utils.types.forEach((type) => {
tx.txCounter += 1; updatedMap[type] = Object.create(null);
cb(tx); });
};
function isContentType(type) {
switch (type) {
case 'content':
case 'contentState':
case 'syncContent':
return true;
default:
return false;
} }
} }
export default { export default {
lastTx: 0, lastTx: 0,
updatedMap: Object.create(null), updatedMap,
connection: new Connection(), connection: new Connection(),
/** /**
@ -109,14 +108,8 @@ export default {
* since the previous transaction, then write all the changes from the store. * since the previous transaction, then write all the changes from the store.
*/ */
sync() { sync() {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
const storeItemMap = {}; const storeItemMap = { ...store.getters.allItemMap };
[
store.state.contents,
store.state.files,
store.state.folders,
store.state.data,
].forEach(moduleState => Object.assign(storeItemMap, moduleState.itemMap));
this.connection.createTx((tx) => { this.connection.createTx((tx) => {
this.readAll(storeItemMap, tx, () => { this.readAll(storeItemMap, tx, () => {
this.writeAll(storeItemMap, tx); this.writeAll(storeItemMap, tx);
@ -125,7 +118,7 @@ export default {
} }
resolve(); resolve();
}); });
}); }, () => reject(new Error('Local DB access error.')));
}); });
}, },
@ -154,9 +147,6 @@ export default {
changes.push(item); changes.push(item);
cursor.continue(); cursor.continue();
} else { } else {
if (changes.length) {
dbg(`Got ${changes.length} changes`);
}
changes.forEach((item) => { changes.forEach((item) => {
this.readDbItem(item, storeItemMap); this.readDbItem(item, storeItemMap);
// If item is an old delete marker, remove it from the DB // If item is an old delete marker, remove it from the DB
@ -178,30 +168,43 @@ export default {
const incrementedTx = this.lastTx + 1; const incrementedTx = this.lastTx + 1;
// Remove deleted store items // Remove deleted store items
Object.keys(this.updatedMap).forEach((id) => { Object.keys(this.updatedMap).forEach((type) => {
if (!storeItemMap[id]) { // Remove this type only if file is deleted
let checker = cb => id => !storeItemMap[id] && cb(id);
if (isContentType(type)) {
// For content types, remove only if file is deleted
checker = cb => (id) => {
if (!storeItemMap[id]) {
const [fileId] = id.split('/');
if (!store.state.file.itemMap[fileId]) {
cb(id);
}
}
};
}
Object.keys(this.updatedMap[type]).forEach(checker((id) => {
// Put a delete marker to notify other tabs // Put a delete marker to notify other tabs
dbStore.put({ dbStore.put({
id, id,
type,
tx: incrementedTx, tx: incrementedTx,
}); });
delete this.updatedMap[id]; delete this.updatedMap[type][id];
this.lastTx = incrementedTx; // No need to read what we just wrote this.lastTx = incrementedTx; // No need to read what we just wrote
} }));
}); });
// Put changes // Put changes
Object.keys(storeItemMap).forEach((id) => { Object.keys(storeItemMap).forEach((id) => {
const storeItem = storeItemMap[id]; const storeItem = storeItemMap[id];
// Store object has changed // Store object has changed
if (this.updatedMap[storeItem.id] !== storeItem.updated) { if (this.updatedMap[storeItem.type][storeItem.id] !== storeItem.updated) {
const item = { const item = {
...storeItem, ...storeItem,
tx: incrementedTx, tx: incrementedTx,
}; };
dbg('Putting 1 item');
dbStore.put(item); dbStore.put(item);
this.updatedMap[item.id] = item.updated; this.updatedMap[item.type][item.id] = item.updated;
this.lastTx = incrementedTx; // No need to read what we just wrote this.lastTx = incrementedTx; // No need to read what we just wrote
} }
}); });
@ -211,23 +214,56 @@ export default {
* Read and apply one DB change. * Read and apply one DB change.
*/ */
readDbItem(dbItem, storeItemMap) { readDbItem(dbItem, storeItemMap) {
const existingStoreItem = storeItemMap[dbItem.id];
if (!dbItem.updated) { if (!dbItem.updated) {
// DB item is a delete marker // DB item is a delete marker
delete this.updatedMap[dbItem.id]; delete this.updatedMap[dbItem.type][dbItem.id];
const existingStoreItem = storeItemMap[dbItem.id];
if (existingStoreItem) { if (existingStoreItem) {
// Remove item from the store
store.commit(`${existingStoreItem.type}/deleteItem`, existingStoreItem.id);
delete storeItemMap[existingStoreItem.id]; delete storeItemMap[existingStoreItem.id];
// Remove object from the store
const prefix = getStorePrefixFromType(existingStoreItem.type);
store.commit(`${prefix}/deleteItem`, existingStoreItem.id);
} }
} else if (this.updatedMap[dbItem.id] !== dbItem.updated) { } else if (this.updatedMap[dbItem.type][dbItem.id] !== dbItem.updated) {
// DB item is different from the corresponding store item // DB item is different from the corresponding store item
this.updatedMap[dbItem.id] = dbItem.updated; this.updatedMap[dbItem.type][dbItem.id] = dbItem.updated;
storeItemMap[dbItem.id] = dbItem; // Update content only if it exists in the store
// Put object in the store if (existingStoreItem || !isContentType(dbItem.type)) {
const prefix = getStorePrefixFromType(dbItem.type); // Put item in the store
store.commit(`${prefix}/setItem`, dbItem); store.commit(`${dbItem.type}/setItem`, dbItem);
storeItemMap[dbItem.id] = dbItem;
}
} }
}, },
/**
* Retrieve an item from the DB.
*/
retrieveItem(id) {
// Check if item is in the store
const itemInStore = store.getters.allItemMap[id];
if (itemInStore) {
// Use deepCopy to freeze item
return Promise.resolve(itemInStore);
}
return new Promise((resolve, reject) => {
// Get the item from DB
const onError = () => reject(new Error('Data not available.'));
this.connection.createTx((tx) => {
const dbStore = tx.objectStore(dbStoreName);
const request = dbStore.get(id);
request.onsuccess = () => {
const dbItem = request.result;
if (!dbItem || !dbItem.updated) {
onError();
} else {
this.updatedMap[dbItem.type][dbItem.id] = dbItem.updated;
// Put item in the store
store.commit(`${dbItem.type}/setItem`, dbItem);
// Use deepCopy to freeze item
resolve(dbItem);
}
};
}, () => onError());
});
},
}; };

View File

@ -174,7 +174,7 @@ store.watch(
}); });
store.watch( store.watch(
() => store.getters['files/current'].id, () => store.getters['file/current'].id,
() => { () => {
skipAnimation = true; skipAnimation = true;
}); });

View File

@ -0,0 +1 @@
export default {};

View File

@ -3,13 +3,17 @@ import store from '../store';
import welcomeFile from '../data/welcomeFile.md'; import welcomeFile from '../data/welcomeFile.md';
import utils from './utils'; import utils from './utils';
import userActivitySvc from './userActivitySvc'; import userActivitySvc from './userActivitySvc';
import gdriveAppDataProvider from './providers/gdriveAppDataProvider';
import googleHelper from './helpers/googleHelper'; import googleHelper from './helpers/googleHelper';
import emptyContent from '../data/emptyContent';
import emptySyncContent from '../data/emptySyncContent';
const lastSyncActivityKey = 'lastSyncActivity'; const lastSyncActivityKey = 'lastSyncActivity';
let lastSyncActivity; let lastSyncActivity;
const getStoredLastSyncActivity = () => parseInt(localStorage[lastSyncActivityKey], 10) || 0; const getStoredLastSyncActivity = () => parseInt(localStorage[lastSyncActivityKey], 10) || 0;
const inactivityThreshold = 3 * 1000; // 3 sec const inactivityThreshold = 3 * 1000; // 3 sec
const autoSyncAfter = 60 * 1000; // 1 min const restartSyncAfter = 30 * 1000; // 30 sec
const autoSyncAfter = utils.randomize(restartSyncAfter);
const isSyncAvailable = () => window.navigator.onLine !== false && const isSyncAvailable = () => window.navigator.onLine !== false &&
!!store.getters['data/loginToken']; !!store.getters['data/loginToken'];
@ -19,7 +23,7 @@ function isSyncWindow() {
Date.now() > inactivityThreshold + storedLastSyncActivity; Date.now() > inactivityThreshold + storedLastSyncActivity;
} }
function isAutoSyncNeeded() { function isAutoSyncReady() {
const storedLastSyncActivity = getStoredLastSyncActivity(); const storedLastSyncActivity = getStoredLastSyncActivity();
return Date.now() > autoSyncAfter + storedLastSyncActivity; return Date.now() > autoSyncAfter + storedLastSyncActivity;
} }
@ -30,28 +34,191 @@ function setLastSyncActivity() {
localStorage[lastSyncActivityKey] = currentDate; localStorage[lastSyncActivityKey] = currentDate;
} }
function getSyncProvider(syncLocation) {
switch (syncLocation.provider) {
default:
return gdriveAppDataProvider;
}
}
function getSyncToken(syncLocation) {
switch (syncLocation.provider) {
default:
return store.getters['data/loginToken'];
}
}
function applyChanges(changes) {
const storeItemMap = { ...store.getters.allItemMap };
const syncData = { ...store.getters['data/syncData'] };
let syncDataChanged = false;
changes.forEach((change) => {
const existingSyncData = syncData[change.fileId];
const existingItem = existingSyncData && storeItemMap[existingSyncData.itemId];
if (change.removed && existingSyncData) {
if (existingItem) {
// Remove object from the store
store.commit(`${existingItem.type}/deleteItem`, existingItem.id);
delete storeItemMap[existingItem.id];
}
delete syncData[change.fileId];
syncDataChanged = true;
} else if (!change.removed && change.item && change.item.updated) {
if (!existingSyncData || (existingSyncData.updated !== change.item.updated && (
!existingItem || existingItem.updated !== change.item.updated
))) {
// Put object in the store
if (change.item.type !== 'content') { // Merge contents later
store.commit(`${change.item.type}/setItem`, change.item);
storeItemMap[change.item.id] = change.item;
}
}
syncData[change.fileId] = change.syncData;
syncDataChanged = true;
}
});
if (syncDataChanged) {
store.dispatch('data/setSyncData', syncData);
}
}
function sync() { function sync() {
const googleToken = store.getters['data/loginToken']; const googleToken = store.getters['data/loginToken'];
return googleHelper.getChanges(googleToken) return googleHelper.getChanges(googleToken)
.then((changes) => { .then((changes) => {
console.log(changes); // Apply changes
const localChanges = []; applyChanges(changes);
[ googleHelper.updateNextPageToken(googleToken, changes);
store.state.files,
].forEach((moduleState) => { // Prevent from sending items too long after changes have been retrieved
Object.keys(moduleState.itemMap).forEach((id) => { const syncStartTime = Date.now();
localChanges.push(moduleState.itemMap[id]); const ifNotTooLate = cb => (res) => {
}); if (syncStartTime + restartSyncAfter < Date.now()) {
}); throw new Error('too_late');
const uploadLocalChange = () => {
const localChange = localChanges.pop();
if (!localChange) {
return null;
} }
return googleHelper.insertAppData(googleToken, localChange) return cb(res);
.then(() => uploadLocalChange());
}; };
return uploadLocalChange();
// Called until no item to save
const saveNextItem = ifNotTooLate(() => {
const storeItemMap = store.getters.syncedItemMap;
const syncDataByItemId = store.getters['data/syncDataByItemId'];
let result;
Object.keys(storeItemMap).some((id) => {
const item = storeItemMap[id];
const existingSyncData = syncDataByItemId[id];
if (!existingSyncData || existingSyncData.updated !== item.updated) {
result = googleHelper.saveItem(
googleToken,
// Use deepCopy to freeze objects
utils.deepCopy(item),
utils.deepCopy(existingSyncData),
ifNotTooLate,
)
.then(resultSyncData => store.dispatch('data/patchSyncData', {
[resultSyncData.id]: resultSyncData,
}))
.then(() => saveNextItem());
}
return result;
});
return result;
});
// Called until no item to remove
const removeNextItem = ifNotTooLate(() => {
const storeItemMap = store.getters.syncedItemMap;
const syncData = store.getters['data/syncData'];
let result;
Object.keys(syncData).some((id) => {
const existingSyncData = syncData[id];
if (!storeItemMap[existingSyncData.itemId]) {
// Use deepCopy to freeze objects
const syncDataToRemove = utils.deepCopy(existingSyncData);
result = googleHelper.removeItem(googleToken, syncDataToRemove, ifNotTooLate)
.then(() => {
const syncDataCopy = { ...store.getters['data/syncData'] };
delete syncDataCopy[syncDataToRemove.id];
store.dispatch('data/setSyncData', syncDataCopy);
})
.then(() => removeNextItem());
}
return result;
});
return result;
});
// Get content `updated` field from itemMap or from localDbSvc if not loaded
const getContentUpdated = (contentId) => {
const loadedContent = store.state.content.itemMap[contentId];
return loadedContent ? loadedContent.updated : localDbSvc.updatedMap.content[contentId];
};
// Download current file content and contents that have changed
const forceContentIds = { [`${store.getters['file/current'].id}/content`]: true };
store.getters['file/items'].forEach((file) => {
const contentId = `${file.id}/content`;
const updated = getContentUpdated(contentId);
const existingSyncData = store.getters['data/syncDataByItemId'][contentId];
});
const syncOneContent = fileId => localDbSvc.retrieveItem(`${fileId}/syncContent`)
.catch(() => ({ ...emptySyncContent(), id: `${fileId}/syncContent` }))
.then(syncContent => localDbSvc.retrieveItem(`${fileId}/content`)
.catch(() => ({ ...emptyContent(), id: `${fileId}/content` }))
.then((content) => {
const syncOneContentLocation = (syncLocation) => {
return Promise.resolve()
.then(() => {
const provider = getSyncProvider(syncLocation);
const token = getSyncToken(syncLocation);
return provider && token && provider.downloadContent()
});
};
const syncLocations = [{ provider: null }, ...content.syncLocations];
return syncOneContentLocation(syncLocations[0]);
}));
// Called until no content to save
const saveNextContent = ifNotTooLate(() => {
let saveContentPromise;
const getSaveContentPromise = (contentId) => {
const updated = getContentUpdated(contentId);
const existingSyncData = store.getters['data/syncDataByItemId'][contentId];
if (!existingSyncData || existingSyncData.updated !== updated) {
saveContentPromise = localDbSvc.retrieveItem(contentId)
.then(content => googleHelper.saveItem(
googleToken,
// Use deepCopy to freeze objects
utils.deepCopy(content),
utils.deepCopy(existingSyncData),
ifNotTooLate,
))
.then(resultSyncData => store.dispatch('data/patchSyncData', {
[resultSyncData.id]: resultSyncData,
}))
.then(() => saveNextContent());
}
return saveContentPromise;
};
Object.keys(localDbSvc.updatedMap.content)
.some(id => getSaveContentPromise(id, syncDataByItemId));
return saveContentPromise;
});
return Promise.resolve()
.then(() => saveNextItem())
.then(() => removeNextItem())
.catch((err) => {
if (err && err.message === 'too_late') {
// Restart sync
return sync();
}
throw err;
});
}); });
} }
@ -87,7 +254,7 @@ utils.setInterval(() => {
if (isSyncAvailable() && if (isSyncAvailable() &&
userActivitySvc.isActive() && userActivitySvc.isActive() &&
isSyncWindow() && isSyncWindow() &&
isAutoSyncNeeded() isAutoSyncReady()
) { ) {
requestSync(); requestSync();
} }
@ -104,29 +271,49 @@ const ifNoId = cb => (obj) => {
localDbSvc.sync() localDbSvc.sync()
// And watch file changing // And watch file changing
.then(() => store.watch( .then(() => store.watch(
() => store.getters['files/current'].id, () => store.getters['file/current'].id,
() => Promise.resolve(store.getters['files/current']) () => Promise.resolve(store.getters['file/current'])
// If current file has no ID, get the most recent file // If current file has no ID, get the most recent file
.then(ifNoId(() => store.getters['files/mostRecent'])) .then(ifNoId(() => store.getters['file/lastOpened']))
// If still no ID, create a new file // If still no ID, create a new file
.then(ifNoId(() => { .then(ifNoId(() => {
const contentId = utils.uid(); const id = utils.uid();
store.commit('contents/setItem', { store.commit('content/setItem', {
id: contentId, id: `${id}/content`,
text: welcomeFile, text: welcomeFile,
}); });
const fileId = utils.uid(); store.commit('file/setItem', {
store.commit('files/setItem', { id,
id: fileId, name: 'Welcome file',
name: 'Welcome file', });
contentId, return store.state.file.itemMap[id];
}); }))
return store.state.files.itemMap[fileId]; .then((currentFile) => {
})) // Fix current file ID
.then((currentFile) => { if (store.getters['file/current'].id !== currentFile.id) {
store.commit('files/setCurrentId', currentFile.id); store.commit('file/setCurrentId', currentFile.id);
store.dispatch('files/patchCurrent', {}); // Update `updated` field to make it the mostRecent // Wait for the next watch tick
}), { return null;
}
// Set last opened
store.dispatch('data/setLastOpenedId', currentFile.id);
return Promise.resolve()
// Load contentState from DB
.then(() => localDbSvc.retrieveItem(`${currentFile.id}/contentState`)
// contentState does not exist, create it
.catch(() => store.commit('contentState/setItem', {
id: `${currentFile.id}/contentState`,
})))
// Load syncContent from DB
.then(() => localDbSvc.retrieveItem(`${currentFile.id}/syncContent`)
// syncContent does not exist, create it
.catch(() => store.commit('syncContent/setItem', {
id: `${currentFile.id}/syncContent`,
})))
// Load content from DB
.then(() => localDbSvc.retrieveItem(`${currentFile.id}/content`));
}),
{
immediate: true, immediate: true,
})); }));

View File

@ -4,7 +4,7 @@ const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ
const radix = alphabet.length; const radix = alphabet.length;
const array = new Uint32Array(20); const array = new Uint32Array(20);
// For addQueryParam() // For addQueryParams()
const urlParser = window.document.createElement('a'); const urlParser = window.document.createElement('a');
// For loadScript() // For loadScript()
@ -14,16 +14,23 @@ const scriptLoadingPromises = Object.create(null);
const origin = `${location.protocol}//${location.host}`; const origin = `${location.protocol}//${location.host}`;
export default { export default {
types: ['contentState', 'syncContent', 'content', 'file', 'folder', 'data'],
deepCopy(obj) {
return obj === undefined ? obj : JSON.parse(JSON.stringify(obj));
},
uid() { uid() {
crypto.getRandomValues(array); crypto.getRandomValues(array);
return array.cl_map(value => alphabet[value % radix]).join(''); return array.cl_map(value => alphabet[value % radix]).join('');
}, },
setInterval(func, interval) { randomize(value) {
const randomizedInterval = Math.floor((1 + (Math.random() * 0.1)) * interval); return Math.floor((1 + (Math.random() * 0.2)) * value);
return setInterval(() => func(), randomizedInterval);
}, },
addQueryParam(url, key, value) { setInterval(func, interval) {
if (!url || !key || value == null) { return setInterval(() => func(), this.randomize(interval));
},
addQueryParams(url = '', params = {}) {
const keys = Object.keys(params).filter(key => params[key] != null);
if (!keys.length) {
return url; return url;
} }
urlParser.href = url; urlParser.href = url;
@ -32,7 +39,7 @@ export default {
} else { } else {
urlParser.search = '?'; urlParser.search = '?';
} }
urlParser.search += `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; urlParser.search += keys.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&');
return urlParser.href; return urlParser.href;
}, },
loadScript(url) { loadScript(url) {
@ -57,10 +64,7 @@ export default {
const state = this.uid(); const state = this.uid();
params.state = state; params.state = state;
params.redirect_uri = `${origin}/oauth2/callback.html`; params.redirect_uri = `${origin}/oauth2/callback.html`;
let authorizeUrl = url; const authorizeUrl = this.addQueryParams(url, params);
Object.keys(params).forEach((key) => {
authorizeUrl = this.addQueryParam(authorizeUrl, key, params[key]);
});
if (silent) { if (silent) {
// Use an iframe as wnd for silent mode // Use an iframe as wnd for silent mode
oauth2Context.iframeElt = document.createElement('iframe'); oauth2Context.iframeElt = document.createElement('iframe');
@ -92,7 +96,7 @@ export default {
} }
clearTimeout(oauth2Context.closeTimeout); clearTimeout(oauth2Context.closeTimeout);
window.removeEventListener('message', oauth2Context.msgHandler); window.removeEventListener('message', oauth2Context.msgHandler);
oauth2Context.clean = () => {}; // Prevent from cleaning several times oauth2Context.clean = () => { }; // Prevent from cleaning several times
if (errorMsg) { if (errorMsg) {
reject(new Error(errorMsg)); reject(new Error(errorMsg));
} }
@ -193,32 +197,26 @@ export default {
}, timeout); }, timeout);
// Add query params to URL // Add query params to URL
let url = config.url || ''; const url = this.addQueryParams(config.url, config.params);
if (config.params) {
Object.keys(config.params).forEach((key) => {
url = this.addQueryParam(url, key, config.params[key]);
});
}
xhr.open(config.method, url); xhr.open(config.method, url);
Object.keys(config.headers).forEach((key) => { Object.keys(config.headers).forEach((key) => {
xhr.setRequestHeader(key, config.headers[key]); xhr.setRequestHeader(key, config.headers[key]);
}); });
xhr.send(config.body ? JSON.stringify(config.body) : null); xhr.send(config.body ? JSON.stringify(config.body) : null);
}) })
.catch((err) => { .catch((err) => {
// Try again later in case of retriable error // Try again later in case of retriable error
if (isRetriable(err) && retryAfter < maxRetryAfter) { if (isRetriable(err) && retryAfter < maxRetryAfter) {
return new Promise( return new Promise(
(resolve) => { (resolve) => {
setTimeout(resolve, retryAfter); setTimeout(resolve, retryAfter);
// Exponential backoff // Exponential backoff
retryAfter *= 2; retryAfter *= 2;
}) })
.then(attempt); .then(attempt);
} }
throw err; throw err;
}); });
return attempt(); return attempt();
}, },

View File

@ -1,9 +1,12 @@
import createLogger from 'vuex/dist/logger'; import createLogger from 'vuex/dist/logger';
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import contents from './modules/contents'; import utils from '../services/utils';
import files from './modules/files'; import contentState from './modules/contentState';
import folders from './modules/folders'; import syncContent from './modules/syncContent';
import content from './modules/content';
import file from './modules/file';
import folder from './modules/folder';
import data from './modules/data'; import data from './modules/data';
import layout from './modules/layout'; import layout from './modules/layout';
import editor from './modules/editor'; import editor from './modules/editor';
@ -15,19 +18,33 @@ Vue.use(Vuex);
const debug = process.env.NODE_ENV !== 'production'; const debug = process.env.NODE_ENV !== 'production';
const store = new Vuex.Store({ export default new Vuex.Store({
state: { state: {
ready: false, ready: false,
}, },
getters: {
allItemMap: (state) => {
const result = {};
utils.types.forEach(type => Object.assign(result, state[type].itemMap));
return result;
},
syncedItemMap: (state) => {
const result = {};
['file', 'folder'].forEach(type => Object.assign(result, state[type].itemMap));
return result;
},
},
mutations: { mutations: {
setReady: (state) => { setReady: (state) => {
state.ready = true; state.ready = true;
}, },
}, },
modules: { modules: {
contents, contentState,
files, syncContent,
folders, content,
file,
folder,
data, data,
layout, layout,
editor, editor,
@ -38,5 +55,3 @@ const store = new Vuex.Store({
strict: debug, strict: debug,
plugins: debug ? [createLogger()] : [], plugins: debug ? [createLogger()] : [],
}); });
export default store;

View File

@ -6,7 +6,7 @@ const module = moduleTemplate(empty);
module.getters = { module.getters = {
...module.getters, ...module.getters,
current: (state, getters, rootState, rootGetters) => current: (state, getters, rootState, rootGetters) =>
state.itemMap[rootGetters['files/current'].contentId] || empty(), state.itemMap[`${rootGetters['file/current'].id}/content`] || empty(),
}; };
module.actions = { module.actions = {

View File

@ -0,0 +1,22 @@
import moduleTemplate from './moduleTemplate';
import empty from '../../data/emptyContentState';
const module = moduleTemplate(empty);
module.getters = {
...module.getters,
current: (state, getters, rootState, rootGetters) =>
state.itemMap[`${rootGetters['file/current'].id}/contentState`] || empty(),
};
module.actions = {
...module.actions,
patchCurrent({ getters, commit }, value) {
commit('patchItem', {
...value,
id: getters.current.id,
});
},
};
export default module;

View File

@ -1,63 +1,99 @@
import moduleTemplate from './moduleTemplate'; import moduleTemplate from './moduleTemplate';
import defaultLocalSettings from '../../data/defaultLocalSettings'; import defaultLocalSettings from '../../data/defaultLocalSettings';
const itemTemplate = (id, data = {}) => ({ id, type: 'data', data, updated: 0 });
const empty = (id) => { const empty = (id) => {
switch (id) { switch (id) {
case 'localSettings': case 'localSettings':
return defaultLocalSettings(); return itemTemplate(id, defaultLocalSettings());
default: default:
return { id, updated: 0 }; return itemTemplate(id);
} }
}; };
const module = moduleTemplate(empty); const module = moduleTemplate(empty);
const getter = id => state => state.itemMap[id] || empty(id); const getter = id => state => (state.itemMap[id] || empty(id)).data;
const setter = id => ({ commit }, data) => commit('setItem', itemTemplate(id, data));
module.getters = { const patcher = id => ({ state, commit }, data) => {
...module.getters, const item = state.itemMap[id] || empty(id);
localSettings: getter('localSettings'), commit('patchOrSetItem', {
settings: getter('settings'), ...item,
syncData: getter('syncData'), data: {
tokens: getter('tokens'), ...item.data,
googleTokens: (state, getters) => getters.tokens.google || {}, ...data,
loginToken: (state, getters) => { },
const googleTokens = getters.googleTokens; });
// Return the first googleToken that has the isLogin flag
const loginSubs = Object.keys(googleTokens)
.filter(sub => googleTokens[sub].isLogin);
return googleTokens[loginSubs[0]];
},
}; };
const patcher = id => ({ getters, commit }, value) => commit('patchOrSetItem', {
...value,
id,
});
const localSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLocalSettings', { const localSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLocalSettings', {
[propertyName]: value === undefined ? !getters.localSettings[propertyName] : value, [propertyName]: value === undefined ? !getters.localSettings[propertyName] : value,
}); });
module.actions = { // Local settings
...module.actions, module.getters.localSettings = getter('localSettings');
patchLocalSettings: patcher('localSettings'), module.actions.patchLocalSettings = patcher('localSettings');
patchSyncData: patcher('syncData'), module.actions.toggleNavigationBar = localSettingsToggler('showNavigationBar');
patchTokens: patcher('tokens'), module.actions.toggleEditor = localSettingsToggler('showEditor');
setGoogleToken({ getters, dispatch }, googleToken) { module.actions.toggleSidePreview = localSettingsToggler('showSidePreview');
dispatch('patchTokens', { module.actions.toggleStatusBar = localSettingsToggler('showStatusBar');
google: { module.actions.toggleSideBar = localSettingsToggler('showSideBar');
...getters.googleTokens, module.actions.toggleExplorer = localSettingsToggler('showExplorer');
[googleToken.sub]: googleToken, module.actions.toggleFocusMode = localSettingsToggler('focusMode');
},
// Settings
module.getters.settings = getter('settings');
// Last opened
module.getters.lastOpened = getter('lastOpened');
const getLastOpenedIds = (lastOpened, rootState) => Object.keys(lastOpened)
.filter(id => rootState.file.itemMap[id])
.sort((id1, id2) => lastOpened[id2] - lastOpened[id1])
.slice(0, 10);
module.getters.lastOpenedIds = (state, getters, rootState) =>
getLastOpenedIds(getters.lastOpened, rootState);
module.actions.setLastOpenedId = ({ getters, commit, rootState }, fileId) => {
const lastOpened = { ...getters.lastOpened };
lastOpened[fileId] = Date.now();
const filteredLastOpened = {};
getLastOpenedIds(lastOpened, rootState)
.forEach((id) => {
filteredLastOpened[id] = lastOpened[id];
}); });
}, commit('setItem', itemTemplate('lastOpened', lastOpened));
toggleNavigationBar: localSettingsToggler('showNavigationBar'), };
toggleEditor: localSettingsToggler('showEditor'),
toggleSidePreview: localSettingsToggler('showSidePreview'), // Sync data
toggleStatusBar: localSettingsToggler('showStatusBar'), module.getters.syncData = getter('syncData');
toggleSideBar: localSettingsToggler('showSideBar'), module.getters.syncDataByItemId = (state, getters) => {
toggleExplorer: localSettingsToggler('showExplorer'), const result = {};
toggleFocusMode: localSettingsToggler('focusMode'), const syncData = getters.syncData;
Object.keys(syncData).forEach((id) => {
const value = syncData[id];
result[value.itemId] = value;
});
return result;
};
module.actions.patchSyncData = patcher('syncData');
module.actions.setSyncData = setter('syncData');
// Tokens
module.getters.tokens = getter('tokens');
module.getters.googleTokens = (state, getters) => getters.tokens.google || {};
module.getters.loginToken = (state, getters) => {
// Return the first googleToken that has the isLogin flag
const googleTokens = getters.googleTokens;
const loginSubs = Object.keys(googleTokens)
.filter(sub => googleTokens[sub].isLogin);
return googleTokens[loginSubs[0]];
};
module.actions.patchTokens = patcher('tokens');
module.actions.setGoogleToken = ({ getters, dispatch }, googleToken) => {
dispatch('patchTokens', {
google: {
...getters.googleTokens,
[googleToken.sub]: googleToken,
},
});
}; };
export default module; export default module;

View File

@ -68,10 +68,10 @@ export default {
getters: { getters: {
nodeStructure: (state, getters, rootState, rootGetters) => { nodeStructure: (state, getters, rootState, rootGetters) => {
const nodeMap = {}; const nodeMap = {};
rootGetters['folders/items'].forEach((item) => { rootGetters['folder/items'].forEach((item) => {
nodeMap[item.id] = new Node(item, true); nodeMap[item.id] = new Node(item, true);
}); });
rootGetters['files/items'].forEach((item) => { rootGetters['file/items'].forEach((item) => {
nodeMap[item.id] = new Node(item); nodeMap[item.id] = new Node(item);
}); });
const rootNode = new Node(emptyFolder(), true, true); const rootNode = new Node(emptyFolder(), true, true);

View File

@ -11,9 +11,8 @@ module.state = {
module.getters = { module.getters = {
...module.getters, ...module.getters,
current: state => state.itemMap[state.currentId] || empty(), current: state => state.itemMap[state.currentId] || empty(),
itemsByUpdated: (state, getters) => lastOpened: (state, getters, rootState, rootGetters) =>
getters.items.slice().sort((file1, file2) => file2.updated - file1.updated), state.itemMap[rootGetters['data/lastOpenedIds'][0]] || getters.items[0] || empty(),
mostRecent: (state, getters) => getters.itemsByUpdated[0] || empty(),
}; };
module.mutations = { module.mutations = {

View File

@ -35,7 +35,10 @@ export default {
if (!state.isSyncRequested) { if (!state.isSyncRequested) {
commit('setIsSyncRequested', true); commit('setIsSyncRequested', true);
const unset = () => commit('setIsSyncRequested', false); const unset = () => commit('setIsSyncRequested', false);
dispatch('enqueue', () => cb().then(unset, unset)); dispatch('enqueue', () => cb().then(unset, (err) => {
unset();
throw err;
}));
} }
}, },
}, },

View File

@ -0,0 +1,12 @@
import moduleTemplate from './moduleTemplate';
import empty from '../../data/emptySyncContent';
const module = moduleTemplate(empty);
module.getters = {
...module.getters,
current: (state, getters, rootState, rootGetters) =>
state.itemMap[`${rootGetters['file/current'].id}/syncContent`] || empty(),
};
export default module;