Content sync
This commit is contained in:
parent
1e74fb00dc
commit
d258d1c9c4
@ -1,3 +1,3 @@
|
|||||||
build/*.js
|
build/*.js
|
||||||
config/*.js
|
config/*.js
|
||||||
src/cledit/*.js
|
src/libs/*.js
|
||||||
|
@ -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",
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -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;
|
||||||
|
@ -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 });
|
||||||
|
@ -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">
|
||||||
|
@ -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 {
|
||||||
|
@ -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,
|
|
||||||
});
|
});
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
|
8
src/data/emptyContentState.js
Normal file
8
src/data/emptyContentState.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export default () => ({
|
||||||
|
id: null,
|
||||||
|
type: 'contentState',
|
||||||
|
selectionStart: 0,
|
||||||
|
selectionEnd: 0,
|
||||||
|
scrollPosition: null,
|
||||||
|
updated: 0,
|
||||||
|
});
|
@ -3,5 +3,4 @@ export default () => ({
|
|||||||
type: 'file',
|
type: 'file',
|
||||||
name: '',
|
name: '',
|
||||||
parentId: null,
|
parentId: null,
|
||||||
contentId: null,
|
|
||||||
});
|
});
|
||||||
|
7
src/data/emptySyncContent.js
Normal file
7
src/data/emptySyncContent.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default () => ({
|
||||||
|
id: null,
|
||||||
|
type: 'syncContent',
|
||||||
|
contentRevisions: {},
|
||||||
|
syncLocationData: {},
|
||||||
|
updated: 0,
|
||||||
|
});
|
@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
@ -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));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -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());
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
@ -174,7 +174,7 @@ store.watch(
|
|||||||
});
|
});
|
||||||
|
|
||||||
store.watch(
|
store.watch(
|
||||||
() => store.getters['files/current'].id,
|
() => store.getters['file/current'].id,
|
||||||
() => {
|
() => {
|
||||||
skipAnimation = true;
|
skipAnimation = true;
|
||||||
});
|
});
|
||||||
|
1
src/services/providers/gdriveAppDataProvider.js
Normal file
1
src/services/providers/gdriveAppDataProvider.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default {};
|
@ -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,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -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();
|
||||||
},
|
},
|
||||||
|
@ -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;
|
|
||||||
|
@ -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 = {
|
22
src/store/modules/contentState.js
Normal file
22
src/store/modules/contentState.js
Normal 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;
|
@ -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;
|
||||||
|
@ -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);
|
||||||
|
@ -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 = {
|
@ -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;
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
12
src/store/modules/syncContent.js
Normal file
12
src/store/modules/syncContent.js
Normal 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;
|
Loading…
Reference in New Issue
Block a user