Content sync
This commit is contained in:
parent
1e74fb00dc
commit
d258d1c9c4
@ -1,3 +1,3 @@
|
||||
build/*.js
|
||||
config/*.js
|
||||
src/cledit/*.js
|
||||
src/libs/*.js
|
||||
|
@ -13,7 +13,6 @@
|
||||
"dependencies": {
|
||||
"bezier-easing": "^1.1.0",
|
||||
"clunderscore": "^1.0.3",
|
||||
"debug": "^2.6.8",
|
||||
"diff-match-patch": "^1.0.0",
|
||||
"indexeddbshim": "^3.0.4",
|
||||
"katex": "^0.7.1",
|
||||
|
@ -21,7 +21,7 @@ export default {
|
||||
'ready',
|
||||
]),
|
||||
loading() {
|
||||
return !this.$store.getters['contents/current'].id;
|
||||
return !this.$store.getters['content/current'].id;
|
||||
},
|
||||
showModal() {
|
||||
return !!this.$store.state.modal.content;
|
||||
|
@ -70,13 +70,13 @@ export default {
|
||||
const recursiveDelete = (folderNode) => {
|
||||
folderNode.folders.forEach(recursiveDelete);
|
||||
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);
|
||||
} else {
|
||||
this.$store.commit('files/deleteItem', selectedNode.item.id);
|
||||
this.$store.commit('file/deleteItem', selectedNode.item.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -85,7 +85,7 @@ export default {
|
||||
},
|
||||
created() {
|
||||
this.$store.watch(
|
||||
() => this.$store.getters['files/current'].id,
|
||||
() => this.$store.getters['file/current'].id,
|
||||
(currentFileId) => {
|
||||
this.$store.commit('explorer/setSelectedId', currentFileId);
|
||||
this.$store.dispatch('explorer/openNode', currentFileId);
|
||||
|
@ -91,7 +91,7 @@ export default {
|
||||
if (node.isFolder) {
|
||||
this.$store.commit('explorer/toggleOpenNode', id);
|
||||
} 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) {
|
||||
const id = utils.uid();
|
||||
if (newChildNode.isFolder) {
|
||||
this.$store.commit('folders/setItem', {
|
||||
this.$store.commit('folder/setItem', {
|
||||
...newChildNode.item,
|
||||
id,
|
||||
});
|
||||
} else {
|
||||
const contentId = utils.uid();
|
||||
this.$store.commit('contents/setItem', {
|
||||
id: contentId,
|
||||
this.$store.commit('content/setItem', {
|
||||
id: `${id}/content`,
|
||||
text: defaultContent,
|
||||
});
|
||||
this.$store.commit('files/setItem', {
|
||||
this.$store.commit('file/setItem', {
|
||||
...newChildNode.item,
|
||||
id,
|
||||
contentId,
|
||||
});
|
||||
}
|
||||
this.select(id);
|
||||
@ -124,7 +122,7 @@ export default {
|
||||
const id = this.$store.getters['explorer/editingNode'].item.id;
|
||||
const value = this.editingValue;
|
||||
if (!cancel && id && value) {
|
||||
this.$store.commit('files/patchItem', {
|
||||
this.$store.commit('file/patchItem', {
|
||||
id,
|
||||
name: value.slice(0, 250),
|
||||
});
|
||||
@ -152,9 +150,9 @@ export default {
|
||||
parentId: targetNode.item.id,
|
||||
};
|
||||
if (sourceNode.isFolder) {
|
||||
this.$store.commit('folders/patchItem', patch);
|
||||
this.$store.commit('folder/patchItem', patch);
|
||||
} 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--status-bar,
|
||||
.layout__panel--side-bar,
|
||||
.layout__panel--explorer,
|
||||
.layout__panel--navigation-bar {
|
||||
.app--loading & > * {
|
||||
opacity: 0.5;
|
||||
|
@ -122,9 +122,9 @@ export default {
|
||||
} else {
|
||||
const title = this.title.trim();
|
||||
if (title) {
|
||||
this.$store.dispatch('files/patchCurrent', { name: title.slice(0, 250) });
|
||||
this.$store.dispatch('file/patchCurrent', { name: title.slice(0, 250) });
|
||||
} else {
|
||||
this.title = this.$store.getters['files/current'].name;
|
||||
this.title = this.$store.getters['file/current'].name;
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -137,7 +137,7 @@ export default {
|
||||
},
|
||||
created() {
|
||||
this.$store.watch(
|
||||
() => this.$store.getters['files/current'].name,
|
||||
() => this.$store.getters['file/current'].name,
|
||||
(name) => {
|
||||
this.title = name;
|
||||
}, { immediate: true });
|
||||
|
@ -15,7 +15,7 @@
|
||||
<div v-if="panel === 'menu'" class="side-bar__panel side-bar__panel--menu">
|
||||
<side-bar-item v-if="!loginToken" @click.native="signin">
|
||||
<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>
|
||||
</side-bar-item>
|
||||
<!-- <side-bar-item @click.native="signin">
|
||||
|
@ -108,7 +108,7 @@ export default {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-panel__block {
|
||||
|
@ -1,5 +1,4 @@
|
||||
export default () => ({
|
||||
id: 'localSettings',
|
||||
showNavigationBar: true,
|
||||
showEditor: true,
|
||||
showSidePreview: true,
|
||||
@ -7,5 +6,4 @@ export default () => ({
|
||||
showSideBar: false,
|
||||
showExplorer: false,
|
||||
focusMode: false,
|
||||
updated: 0,
|
||||
});
|
||||
|
@ -1,10 +1,10 @@
|
||||
export default () => ({
|
||||
id: null,
|
||||
type: 'content',
|
||||
state: {},
|
||||
text: '\n',
|
||||
properties: {},
|
||||
properties: '\n',
|
||||
discussions: {},
|
||||
comments: {},
|
||||
syncLocations: [],
|
||||
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',
|
||||
name: '',
|
||||
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 cledit from '../cledit/cledit';
|
||||
import clDiffUtils from '../cledit/cldiffutils';
|
||||
import cledit from '../libs/cledit';
|
||||
import clDiffUtils from '../libs/cldiffutils';
|
||||
import store from '../store';
|
||||
|
||||
let clEditor;
|
||||
@ -35,7 +35,7 @@ function getDiscussionMarkers(discussion, discussionId, onMarker) {
|
||||
}
|
||||
|
||||
function syncDiscussionMarkers() {
|
||||
const content = store.getters['contents/current'];
|
||||
const content = store.getters['content/current'];
|
||||
Object.keys(discussionMarkers)
|
||||
.forEach((markerKey) => {
|
||||
const marker = discussionMarkers[markerKey];
|
||||
@ -100,9 +100,9 @@ export default {
|
||||
markerIdxMap = Object.create(null);
|
||||
discussionMarkers = {};
|
||||
clEditor.on('contentChanged', (text) => {
|
||||
store.dispatch('contents/patchCurrent', { text });
|
||||
store.dispatch('content/patchCurrent', { text });
|
||||
syncDiscussionMarkers();
|
||||
const content = store.getters['contents/current'];
|
||||
const content = store.getters['content/current'];
|
||||
if (!isChangePatch) {
|
||||
previousPatchableText = currentPatchableText;
|
||||
currentPatchableText = clDiffUtils.makePatchableText(content, markerKeys, markerIdxMap);
|
||||
@ -123,7 +123,8 @@ export default {
|
||||
clEditor.addMarker(newDiscussionMarker1);
|
||||
},
|
||||
initClEditor(opts, reinit) {
|
||||
const content = store.getters['contents/current'];
|
||||
const content = store.getters['content/current'];
|
||||
const contentState = store.getters['contentState/current'];
|
||||
if (content) {
|
||||
const options = Object.assign({}, opts);
|
||||
|
||||
@ -136,8 +137,8 @@ export default {
|
||||
|
||||
if (reinit) {
|
||||
options.content = content.text;
|
||||
options.selectionStart = content.state.selectionStart;
|
||||
options.selectionEnd = content.state.selectionEnd;
|
||||
options.selectionStart = contentState.selectionStart;
|
||||
options.selectionEnd = contentState.selectionEnd;
|
||||
}
|
||||
|
||||
options.patchHandler = {
|
||||
@ -156,7 +157,7 @@ export default {
|
||||
this.lastExternalChange = Date.now();
|
||||
}
|
||||
syncDiscussionMarkers();
|
||||
const content = store.getters['contents/current'];
|
||||
const content = store.getters['content/current'];
|
||||
return clEditor.setContent(content.text, isExternal);
|
||||
},
|
||||
};
|
||||
|
@ -2,9 +2,9 @@ import Vue from 'vue';
|
||||
import DiffMatchPatch from 'diff-match-patch';
|
||||
import Prism from 'prismjs';
|
||||
import markdownItPandocRenderer from 'markdown-it-pandoc-renderer';
|
||||
import cledit from '../cledit/cledit';
|
||||
import pagedown from '../cledit/pagedown';
|
||||
import htmlSanitizer from '../cledit/htmlSanitizer';
|
||||
import cledit from '../libs/cledit';
|
||||
import pagedown from '../libs/pagedown';
|
||||
import htmlSanitizer from '../libs/htmlSanitizer';
|
||||
import markdownConversionSvc from './markdownConversionSvc';
|
||||
import markdownGrammarSvc from './markdownGrammarSvc';
|
||||
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.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
|
||||
editorEngineSvc.clEditor.toggleEditable(!!contentId);
|
||||
reinitClEditor = false;
|
||||
@ -361,13 +361,11 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
|
||||
*/
|
||||
saveContentState: allowDebounce(() => {
|
||||
const scrollPosition = editorSvc.getScrollPosition() ||
|
||||
store.getters['contents/current'].state.scrollPosition;
|
||||
store.dispatch('contents/patchCurrent', {
|
||||
state: {
|
||||
selectionStart: editorEngineSvc.clEditor.selectionMgr.selectionStart,
|
||||
selectionEnd: editorEngineSvc.clEditor.selectionMgr.selectionEnd,
|
||||
scrollPosition,
|
||||
},
|
||||
store.getters['contentState/current'].scrollPosition;
|
||||
store.dispatch('contentState/patchCurrent', {
|
||||
selectionStart: editorEngineSvc.clEditor.selectionMgr.selectionStart,
|
||||
selectionEnd: editorEngineSvc.clEditor.selectionMgr.selectionEnd,
|
||||
scrollPosition,
|
||||
});
|
||||
}, 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.
|
||||
*/
|
||||
restoreScrollPosition() {
|
||||
const scrollPosition = store.getters['contents/current'].state.scrollPosition;
|
||||
const scrollPosition = store.getters['contentState/current'].scrollPosition;
|
||||
if (scrollPosition && this.sectionDescMeasuredList) {
|
||||
const objectToScroll = this.getObjectToScroll();
|
||||
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 = {
|
||||
// file: {
|
||||
// name: rootState.files.currentFile.name,
|
||||
// name: rootState.file.currentFile.name,
|
||||
// content: {
|
||||
// properties: rootState.files.currentFile.content.properties,
|
||||
// text: rootState.files.currentFile.content.text,
|
||||
// properties: rootState.file.currentFile.content.properties,
|
||||
// text: rootState.file.currentFile.content.text,
|
||||
// html: state.previewHtml,
|
||||
// toc,
|
||||
// },
|
||||
@ -720,10 +718,10 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
|
||||
|
||||
// Watch file content properties changes
|
||||
store.watch(
|
||||
() => store.getters['contents/current'].properties,
|
||||
() => store.getters['content/current'].properties,
|
||||
(properties) => {
|
||||
// Track ID changes at the same time
|
||||
const contentId = store.getters['contents/current'].id;
|
||||
const contentId = store.getters['content/current'].id;
|
||||
let initClEditor = false;
|
||||
if (contentId !== lastContentId) {
|
||||
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 {
|
||||
startOauth2(scopes, sub = null, silent = false) {
|
||||
return utils.startOauth2(
|
||||
'https://accounts.google.com/o/oauth2/v2/auth', {
|
||||
client_id: clientId,
|
||||
response_type: 'token',
|
||||
scope: scopes.join(' '),
|
||||
hd: appsDomain,
|
||||
login_hint: sub,
|
||||
prompt: silent ? 'none' : null,
|
||||
}, silent)
|
||||
'https://accounts.google.com/o/oauth2/v2/auth', {
|
||||
client_id: clientId,
|
||||
response_type: 'token',
|
||||
scope: scopes.join(' '),
|
||||
hd: appsDomain,
|
||||
login_hint: sub,
|
||||
prompt: silent ? 'none' : null,
|
||||
}, silent)
|
||||
// Call the tokeninfo endpoint
|
||||
.then(data => utils.request({
|
||||
method: 'POST',
|
||||
@ -137,9 +79,9 @@ export default {
|
||||
if (!sub) {
|
||||
throw new Error('Google account already linked.');
|
||||
}
|
||||
// Add isLogin and lastChangeId to googleToken
|
||||
// Add isLogin and nextPageToken to googleToken
|
||||
googleToken.isLogin = existingToken.isLogin;
|
||||
googleToken.lastChangeId = existingToken.lastChangeId;
|
||||
googleToken.nextPageToken = existingToken.nextPageToken;
|
||||
}
|
||||
// Add googleToken to googleTokens
|
||||
store.dispatch('data/setGoogleToken', googleToken);
|
||||
@ -177,48 +119,122 @@ export default {
|
||||
let changes = [];
|
||||
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken)
|
||||
.then((refreshedToken) => {
|
||||
const lastChangeId = refreshedToken.lastChangeId || 0;
|
||||
const getPage = pageToken => request(refreshedToken, {
|
||||
const getPage = (pageToken = '1') => request(refreshedToken, {
|
||||
method: 'GET',
|
||||
url: 'https://www.googleapis.com/drive/v2/changes',
|
||||
url: 'https://www.googleapis.com/drive/v3/changes',
|
||||
params: {
|
||||
pageToken,
|
||||
startChangeId: pageToken || !lastChangeId ? null : lastChangeId + 1,
|
||||
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) => {
|
||||
changes = changes.concat(res.body.items);
|
||||
changes = changes.concat(res.body.changes.filter(item => item.fileId));
|
||||
if (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 getPage();
|
||||
return getPage(refreshedToken.nextPageToken);
|
||||
});
|
||||
},
|
||||
updateLastChangeId(googleToken, changes) {
|
||||
const refreshedToken = store.getters['data/googleTokens'][googleToken.sub];
|
||||
let lastChangeId = refreshedToken.lastChangeId || 0;
|
||||
changes.forEach((change) => {
|
||||
if (change.id > lastChangeId) {
|
||||
lastChangeId = change.id;
|
||||
}
|
||||
});
|
||||
if (lastChangeId !== refreshedToken.lastChangeId) {
|
||||
updateNextPageToken(googleToken, changes) {
|
||||
const lastToken = store.getters['data/googleTokens'][googleToken.sub];
|
||||
if (changes.nextPageToken !== lastToken.nextPageToken) {
|
||||
store.dispatch('data/setGoogleToken', {
|
||||
...refreshedToken,
|
||||
lastChangeId,
|
||||
...lastToken,
|
||||
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)
|
||||
.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)
|
||||
.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 'indexeddbshim';
|
||||
import debug from 'debug';
|
||||
import utils from './utils';
|
||||
import store from '../store';
|
||||
|
||||
const dbg = debug('stackedit:localDbSvc');
|
||||
|
||||
let indexedDB = window.indexedDB;
|
||||
const localStorage = window.localStorage;
|
||||
const dbVersion = 1;
|
||||
@ -15,12 +13,6 @@ if (window.shimIndexedDB && (!indexedDB || (navigator.userAgent.indexOf('Chrome'
|
||||
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;
|
||||
|
||||
class Connection {
|
||||
@ -39,7 +31,7 @@ class Connection {
|
||||
localStorage.localDbVersion = this.db.version; // Safari does not support onversionchange
|
||||
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;
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
this.getTxCbs.push(cb);
|
||||
return;
|
||||
return this.getTxCbs.push({ onTx, onError });
|
||||
}
|
||||
|
||||
// If DB version has changed (Safari support)
|
||||
if (parseInt(localStorage.localDbVersion, 10) !== this.db.version) {
|
||||
window.location.reload();
|
||||
return;
|
||||
return window.location.reload();
|
||||
}
|
||||
|
||||
// Open transaction in read/write will prevent conflict with other tabs
|
||||
const tx = this.db.transaction(this.db.objectStoreNames, 'readwrite');
|
||||
tx.onerror = (evt) => {
|
||||
dbg('Rollback transaction', evt);
|
||||
};
|
||||
// Read the current txCounter
|
||||
const dbStore = tx.objectStore(dbStoreName);
|
||||
const request = dbStore.get('txCounter');
|
||||
request.onsuccess = () => {
|
||||
tx.txCounter = request.result ? request.result.tx : 0;
|
||||
tx.txCounter += 1;
|
||||
cb(tx);
|
||||
};
|
||||
tx.onerror = onError;
|
||||
|
||||
return onTx(tx);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMap = {};
|
||||
utils.types.forEach((type) => {
|
||||
updatedMap[type] = Object.create(null);
|
||||
});
|
||||
|
||||
function isContentType(type) {
|
||||
switch (type) {
|
||||
case 'content':
|
||||
case 'contentState':
|
||||
case 'syncContent':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
lastTx: 0,
|
||||
updatedMap: Object.create(null),
|
||||
updatedMap,
|
||||
connection: new Connection(),
|
||||
|
||||
/**
|
||||
@ -109,14 +108,8 @@ export default {
|
||||
* since the previous transaction, then write all the changes from the store.
|
||||
*/
|
||||
sync() {
|
||||
return new Promise((resolve) => {
|
||||
const storeItemMap = {};
|
||||
[
|
||||
store.state.contents,
|
||||
store.state.files,
|
||||
store.state.folders,
|
||||
store.state.data,
|
||||
].forEach(moduleState => Object.assign(storeItemMap, moduleState.itemMap));
|
||||
return new Promise((resolve, reject) => {
|
||||
const storeItemMap = { ...store.getters.allItemMap };
|
||||
this.connection.createTx((tx) => {
|
||||
this.readAll(storeItemMap, tx, () => {
|
||||
this.writeAll(storeItemMap, tx);
|
||||
@ -125,7 +118,7 @@ export default {
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}, () => reject(new Error('Local DB access error.')));
|
||||
});
|
||||
},
|
||||
|
||||
@ -154,9 +147,6 @@ export default {
|
||||
changes.push(item);
|
||||
cursor.continue();
|
||||
} else {
|
||||
if (changes.length) {
|
||||
dbg(`Got ${changes.length} changes`);
|
||||
}
|
||||
changes.forEach((item) => {
|
||||
this.readDbItem(item, storeItemMap);
|
||||
// If item is an old delete marker, remove it from the DB
|
||||
@ -178,30 +168,43 @@ export default {
|
||||
const incrementedTx = this.lastTx + 1;
|
||||
|
||||
// Remove deleted store items
|
||||
Object.keys(this.updatedMap).forEach((id) => {
|
||||
if (!storeItemMap[id]) {
|
||||
Object.keys(this.updatedMap).forEach((type) => {
|
||||
// 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
|
||||
dbStore.put({
|
||||
id,
|
||||
type,
|
||||
tx: incrementedTx,
|
||||
});
|
||||
delete this.updatedMap[id];
|
||||
delete this.updatedMap[type][id];
|
||||
this.lastTx = incrementedTx; // No need to read what we just wrote
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
// Put changes
|
||||
Object.keys(storeItemMap).forEach((id) => {
|
||||
const storeItem = storeItemMap[id];
|
||||
// Store object has changed
|
||||
if (this.updatedMap[storeItem.id] !== storeItem.updated) {
|
||||
if (this.updatedMap[storeItem.type][storeItem.id] !== storeItem.updated) {
|
||||
const item = {
|
||||
...storeItem,
|
||||
tx: incrementedTx,
|
||||
};
|
||||
dbg('Putting 1 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
|
||||
}
|
||||
});
|
||||
@ -211,23 +214,56 @@ export default {
|
||||
* Read and apply one DB change.
|
||||
*/
|
||||
readDbItem(dbItem, storeItemMap) {
|
||||
const existingStoreItem = storeItemMap[dbItem.id];
|
||||
if (!dbItem.updated) {
|
||||
// DB item is a delete marker
|
||||
delete this.updatedMap[dbItem.id];
|
||||
const existingStoreItem = storeItemMap[dbItem.id];
|
||||
delete this.updatedMap[dbItem.type][dbItem.id];
|
||||
if (existingStoreItem) {
|
||||
// Remove item from the store
|
||||
store.commit(`${existingStoreItem.type}/deleteItem`, 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
|
||||
this.updatedMap[dbItem.id] = dbItem.updated;
|
||||
storeItemMap[dbItem.id] = dbItem;
|
||||
// Put object in the store
|
||||
const prefix = getStorePrefixFromType(dbItem.type);
|
||||
store.commit(`${prefix}/setItem`, dbItem);
|
||||
this.updatedMap[dbItem.type][dbItem.id] = dbItem.updated;
|
||||
// Update content only if it exists in the store
|
||||
if (existingStoreItem || !isContentType(dbItem.type)) {
|
||||
// Put item in the store
|
||||
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.getters['files/current'].id,
|
||||
() => store.getters['file/current'].id,
|
||||
() => {
|
||||
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 utils from './utils';
|
||||
import userActivitySvc from './userActivitySvc';
|
||||
import gdriveAppDataProvider from './providers/gdriveAppDataProvider';
|
||||
import googleHelper from './helpers/googleHelper';
|
||||
import emptyContent from '../data/emptyContent';
|
||||
import emptySyncContent from '../data/emptySyncContent';
|
||||
|
||||
const lastSyncActivityKey = 'lastSyncActivity';
|
||||
let lastSyncActivity;
|
||||
const getStoredLastSyncActivity = () => parseInt(localStorage[lastSyncActivityKey], 10) || 0;
|
||||
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 &&
|
||||
!!store.getters['data/loginToken'];
|
||||
|
||||
@ -19,7 +23,7 @@ function isSyncWindow() {
|
||||
Date.now() > inactivityThreshold + storedLastSyncActivity;
|
||||
}
|
||||
|
||||
function isAutoSyncNeeded() {
|
||||
function isAutoSyncReady() {
|
||||
const storedLastSyncActivity = getStoredLastSyncActivity();
|
||||
return Date.now() > autoSyncAfter + storedLastSyncActivity;
|
||||
}
|
||||
@ -30,28 +34,191 @@ function setLastSyncActivity() {
|
||||
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() {
|
||||
const googleToken = store.getters['data/loginToken'];
|
||||
return googleHelper.getChanges(googleToken)
|
||||
.then((changes) => {
|
||||
console.log(changes);
|
||||
const localChanges = [];
|
||||
[
|
||||
store.state.files,
|
||||
].forEach((moduleState) => {
|
||||
Object.keys(moduleState.itemMap).forEach((id) => {
|
||||
localChanges.push(moduleState.itemMap[id]);
|
||||
});
|
||||
});
|
||||
const uploadLocalChange = () => {
|
||||
const localChange = localChanges.pop();
|
||||
if (!localChange) {
|
||||
return null;
|
||||
// Apply changes
|
||||
applyChanges(changes);
|
||||
googleHelper.updateNextPageToken(googleToken, changes);
|
||||
|
||||
// Prevent from sending items too long after changes have been retrieved
|
||||
const syncStartTime = Date.now();
|
||||
const ifNotTooLate = cb => (res) => {
|
||||
if (syncStartTime + restartSyncAfter < Date.now()) {
|
||||
throw new Error('too_late');
|
||||
}
|
||||
return googleHelper.insertAppData(googleToken, localChange)
|
||||
.then(() => uploadLocalChange());
|
||||
return cb(res);
|
||||
};
|
||||
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() &&
|
||||
userActivitySvc.isActive() &&
|
||||
isSyncWindow() &&
|
||||
isAutoSyncNeeded()
|
||||
isAutoSyncReady()
|
||||
) {
|
||||
requestSync();
|
||||
}
|
||||
@ -104,29 +271,49 @@ const ifNoId = cb => (obj) => {
|
||||
localDbSvc.sync()
|
||||
// And watch file changing
|
||||
.then(() => store.watch(
|
||||
() => store.getters['files/current'].id,
|
||||
() => Promise.resolve(store.getters['files/current'])
|
||||
// If current file has no ID, get the most recent file
|
||||
.then(ifNoId(() => store.getters['files/mostRecent']))
|
||||
// If still no ID, create a new file
|
||||
.then(ifNoId(() => {
|
||||
const contentId = utils.uid();
|
||||
store.commit('contents/setItem', {
|
||||
id: contentId,
|
||||
text: welcomeFile,
|
||||
});
|
||||
const fileId = utils.uid();
|
||||
store.commit('files/setItem', {
|
||||
id: fileId,
|
||||
name: 'Welcome file',
|
||||
contentId,
|
||||
});
|
||||
return store.state.files.itemMap[fileId];
|
||||
}))
|
||||
.then((currentFile) => {
|
||||
store.commit('files/setCurrentId', currentFile.id);
|
||||
store.dispatch('files/patchCurrent', {}); // Update `updated` field to make it the mostRecent
|
||||
}), {
|
||||
() => store.getters['file/current'].id,
|
||||
() => Promise.resolve(store.getters['file/current'])
|
||||
// If current file has no ID, get the most recent file
|
||||
.then(ifNoId(() => store.getters['file/lastOpened']))
|
||||
// If still no ID, create a new file
|
||||
.then(ifNoId(() => {
|
||||
const id = utils.uid();
|
||||
store.commit('content/setItem', {
|
||||
id: `${id}/content`,
|
||||
text: welcomeFile,
|
||||
});
|
||||
store.commit('file/setItem', {
|
||||
id,
|
||||
name: 'Welcome file',
|
||||
});
|
||||
return store.state.file.itemMap[id];
|
||||
}))
|
||||
.then((currentFile) => {
|
||||
// Fix current file ID
|
||||
if (store.getters['file/current'].id !== currentFile.id) {
|
||||
store.commit('file/setCurrentId', currentFile.id);
|
||||
// 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,
|
||||
}));
|
||||
|
||||
|
@ -4,7 +4,7 @@ const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ
|
||||
const radix = alphabet.length;
|
||||
const array = new Uint32Array(20);
|
||||
|
||||
// For addQueryParam()
|
||||
// For addQueryParams()
|
||||
const urlParser = window.document.createElement('a');
|
||||
|
||||
// For loadScript()
|
||||
@ -14,16 +14,23 @@ const scriptLoadingPromises = Object.create(null);
|
||||
const origin = `${location.protocol}//${location.host}`;
|
||||
|
||||
export default {
|
||||
types: ['contentState', 'syncContent', 'content', 'file', 'folder', 'data'],
|
||||
deepCopy(obj) {
|
||||
return obj === undefined ? obj : JSON.parse(JSON.stringify(obj));
|
||||
},
|
||||
uid() {
|
||||
crypto.getRandomValues(array);
|
||||
return array.cl_map(value => alphabet[value % radix]).join('');
|
||||
},
|
||||
setInterval(func, interval) {
|
||||
const randomizedInterval = Math.floor((1 + (Math.random() * 0.1)) * interval);
|
||||
return setInterval(() => func(), randomizedInterval);
|
||||
randomize(value) {
|
||||
return Math.floor((1 + (Math.random() * 0.2)) * value);
|
||||
},
|
||||
addQueryParam(url, key, value) {
|
||||
if (!url || !key || value == null) {
|
||||
setInterval(func, interval) {
|
||||
return setInterval(() => func(), this.randomize(interval));
|
||||
},
|
||||
addQueryParams(url = '', params = {}) {
|
||||
const keys = Object.keys(params).filter(key => params[key] != null);
|
||||
if (!keys.length) {
|
||||
return url;
|
||||
}
|
||||
urlParser.href = url;
|
||||
@ -32,7 +39,7 @@ export default {
|
||||
} else {
|
||||
urlParser.search = '?';
|
||||
}
|
||||
urlParser.search += `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
urlParser.search += keys.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&');
|
||||
return urlParser.href;
|
||||
},
|
||||
loadScript(url) {
|
||||
@ -57,10 +64,7 @@ export default {
|
||||
const state = this.uid();
|
||||
params.state = state;
|
||||
params.redirect_uri = `${origin}/oauth2/callback.html`;
|
||||
let authorizeUrl = url;
|
||||
Object.keys(params).forEach((key) => {
|
||||
authorizeUrl = this.addQueryParam(authorizeUrl, key, params[key]);
|
||||
});
|
||||
const authorizeUrl = this.addQueryParams(url, params);
|
||||
if (silent) {
|
||||
// Use an iframe as wnd for silent mode
|
||||
oauth2Context.iframeElt = document.createElement('iframe');
|
||||
@ -92,7 +96,7 @@ export default {
|
||||
}
|
||||
clearTimeout(oauth2Context.closeTimeout);
|
||||
window.removeEventListener('message', oauth2Context.msgHandler);
|
||||
oauth2Context.clean = () => {}; // Prevent from cleaning several times
|
||||
oauth2Context.clean = () => { }; // Prevent from cleaning several times
|
||||
if (errorMsg) {
|
||||
reject(new Error(errorMsg));
|
||||
}
|
||||
@ -193,32 +197,26 @@ export default {
|
||||
}, timeout);
|
||||
|
||||
// Add query params to URL
|
||||
let url = config.url || '';
|
||||
if (config.params) {
|
||||
Object.keys(config.params).forEach((key) => {
|
||||
url = this.addQueryParam(url, key, config.params[key]);
|
||||
});
|
||||
}
|
||||
|
||||
const url = this.addQueryParams(config.url, config.params);
|
||||
xhr.open(config.method, url);
|
||||
Object.keys(config.headers).forEach((key) => {
|
||||
xhr.setRequestHeader(key, config.headers[key]);
|
||||
});
|
||||
xhr.send(config.body ? JSON.stringify(config.body) : null);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Try again later in case of retriable error
|
||||
if (isRetriable(err) && retryAfter < maxRetryAfter) {
|
||||
return new Promise(
|
||||
.catch((err) => {
|
||||
// Try again later in case of retriable error
|
||||
if (isRetriable(err) && retryAfter < maxRetryAfter) {
|
||||
return new Promise(
|
||||
(resolve) => {
|
||||
setTimeout(resolve, retryAfter);
|
||||
// Exponential backoff
|
||||
retryAfter *= 2;
|
||||
})
|
||||
.then(attempt);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
.then(attempt);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
return attempt();
|
||||
},
|
||||
|
@ -1,9 +1,12 @@
|
||||
import createLogger from 'vuex/dist/logger';
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import contents from './modules/contents';
|
||||
import files from './modules/files';
|
||||
import folders from './modules/folders';
|
||||
import utils from '../services/utils';
|
||||
import contentState from './modules/contentState';
|
||||
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 layout from './modules/layout';
|
||||
import editor from './modules/editor';
|
||||
@ -15,19 +18,33 @@ Vue.use(Vuex);
|
||||
|
||||
const debug = process.env.NODE_ENV !== 'production';
|
||||
|
||||
const store = new Vuex.Store({
|
||||
export default new Vuex.Store({
|
||||
state: {
|
||||
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: {
|
||||
setReady: (state) => {
|
||||
state.ready = true;
|
||||
},
|
||||
},
|
||||
modules: {
|
||||
contents,
|
||||
files,
|
||||
folders,
|
||||
contentState,
|
||||
syncContent,
|
||||
content,
|
||||
file,
|
||||
folder,
|
||||
data,
|
||||
layout,
|
||||
editor,
|
||||
@ -38,5 +55,3 @@ const store = new Vuex.Store({
|
||||
strict: debug,
|
||||
plugins: debug ? [createLogger()] : [],
|
||||
});
|
||||
|
||||
export default store;
|
||||
|
@ -6,7 +6,7 @@ const module = moduleTemplate(empty);
|
||||
module.getters = {
|
||||
...module.getters,
|
||||
current: (state, getters, rootState, rootGetters) =>
|
||||
state.itemMap[rootGetters['files/current'].contentId] || empty(),
|
||||
state.itemMap[`${rootGetters['file/current'].id}/content`] || empty(),
|
||||
};
|
||||
|
||||
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 defaultLocalSettings from '../../data/defaultLocalSettings';
|
||||
|
||||
const itemTemplate = (id, data = {}) => ({ id, type: 'data', data, updated: 0 });
|
||||
|
||||
const empty = (id) => {
|
||||
switch (id) {
|
||||
case 'localSettings':
|
||||
return defaultLocalSettings();
|
||||
return itemTemplate(id, defaultLocalSettings());
|
||||
default:
|
||||
return { id, updated: 0 };
|
||||
return itemTemplate(id);
|
||||
}
|
||||
};
|
||||
const module = moduleTemplate(empty);
|
||||
|
||||
const getter = id => state => state.itemMap[id] || empty(id);
|
||||
|
||||
module.getters = {
|
||||
...module.getters,
|
||||
localSettings: getter('localSettings'),
|
||||
settings: getter('settings'),
|
||||
syncData: getter('syncData'),
|
||||
tokens: getter('tokens'),
|
||||
googleTokens: (state, getters) => getters.tokens.google || {},
|
||||
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 getter = id => state => (state.itemMap[id] || empty(id)).data;
|
||||
const setter = id => ({ commit }, data) => commit('setItem', itemTemplate(id, data));
|
||||
const patcher = id => ({ state, commit }, data) => {
|
||||
const item = state.itemMap[id] || empty(id);
|
||||
commit('patchOrSetItem', {
|
||||
...item,
|
||||
data: {
|
||||
...item.data,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const patcher = id => ({ getters, commit }, value) => commit('patchOrSetItem', {
|
||||
...value,
|
||||
id,
|
||||
});
|
||||
|
||||
const localSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLocalSettings', {
|
||||
[propertyName]: value === undefined ? !getters.localSettings[propertyName] : value,
|
||||
});
|
||||
|
||||
module.actions = {
|
||||
...module.actions,
|
||||
patchLocalSettings: patcher('localSettings'),
|
||||
patchSyncData: patcher('syncData'),
|
||||
patchTokens: patcher('tokens'),
|
||||
setGoogleToken({ getters, dispatch }, googleToken) {
|
||||
dispatch('patchTokens', {
|
||||
google: {
|
||||
...getters.googleTokens,
|
||||
[googleToken.sub]: googleToken,
|
||||
},
|
||||
// Local settings
|
||||
module.getters.localSettings = getter('localSettings');
|
||||
module.actions.patchLocalSettings = patcher('localSettings');
|
||||
module.actions.toggleNavigationBar = localSettingsToggler('showNavigationBar');
|
||||
module.actions.toggleEditor = localSettingsToggler('showEditor');
|
||||
module.actions.toggleSidePreview = localSettingsToggler('showSidePreview');
|
||||
module.actions.toggleStatusBar = localSettingsToggler('showStatusBar');
|
||||
module.actions.toggleSideBar = localSettingsToggler('showSideBar');
|
||||
module.actions.toggleExplorer = localSettingsToggler('showExplorer');
|
||||
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];
|
||||
});
|
||||
},
|
||||
toggleNavigationBar: localSettingsToggler('showNavigationBar'),
|
||||
toggleEditor: localSettingsToggler('showEditor'),
|
||||
toggleSidePreview: localSettingsToggler('showSidePreview'),
|
||||
toggleStatusBar: localSettingsToggler('showStatusBar'),
|
||||
toggleSideBar: localSettingsToggler('showSideBar'),
|
||||
toggleExplorer: localSettingsToggler('showExplorer'),
|
||||
toggleFocusMode: localSettingsToggler('focusMode'),
|
||||
commit('setItem', itemTemplate('lastOpened', lastOpened));
|
||||
};
|
||||
|
||||
// Sync data
|
||||
module.getters.syncData = getter('syncData');
|
||||
module.getters.syncDataByItemId = (state, getters) => {
|
||||
const result = {};
|
||||
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;
|
||||
|
@ -68,10 +68,10 @@ export default {
|
||||
getters: {
|
||||
nodeStructure: (state, getters, rootState, rootGetters) => {
|
||||
const nodeMap = {};
|
||||
rootGetters['folders/items'].forEach((item) => {
|
||||
rootGetters['folder/items'].forEach((item) => {
|
||||
nodeMap[item.id] = new Node(item, true);
|
||||
});
|
||||
rootGetters['files/items'].forEach((item) => {
|
||||
rootGetters['file/items'].forEach((item) => {
|
||||
nodeMap[item.id] = new Node(item);
|
||||
});
|
||||
const rootNode = new Node(emptyFolder(), true, true);
|
||||
|
@ -11,9 +11,8 @@ module.state = {
|
||||
module.getters = {
|
||||
...module.getters,
|
||||
current: state => state.itemMap[state.currentId] || empty(),
|
||||
itemsByUpdated: (state, getters) =>
|
||||
getters.items.slice().sort((file1, file2) => file2.updated - file1.updated),
|
||||
mostRecent: (state, getters) => getters.itemsByUpdated[0] || empty(),
|
||||
lastOpened: (state, getters, rootState, rootGetters) =>
|
||||
state.itemMap[rootGetters['data/lastOpenedIds'][0]] || getters.items[0] || empty(),
|
||||
};
|
||||
|
||||
module.mutations = {
|
@ -35,7 +35,10 @@ export default {
|
||||
if (!state.isSyncRequested) {
|
||||
commit('setIsSyncRequested', true);
|
||||
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