Added gdrive app data provider

This commit is contained in:
benweet 2017-08-25 11:37:46 +01:00
parent 0280df2bbb
commit cc2f9fa204
33 changed files with 916 additions and 496 deletions

View File

@ -17,6 +17,12 @@
<div class="navigation-bar__title navigation-bar__title--fake text-input"></div> <div class="navigation-bar__title navigation-bar__title--fake text-input"></div>
<div class="navigation-bar__title navigation-bar__title--text text-input" v-bind:style="{maxWidth: styles.titleMaxWidth + 'px'}">{{title}}</div> <div class="navigation-bar__title navigation-bar__title--text text-input" v-bind:style="{maxWidth: styles.titleMaxWidth + 'px'}">{{title}}</div>
<input class="navigation-bar__title navigation-bar__title--input text-input" :class="{'navigation-bar__title--focus': titleFocus, 'navigation-bar__title--scrolling': titleScrolling}" v-bind:style="{width: titleWidth + 'px'}" @focus="editTitle(true)" @blur="editTitle(false)" @keyup.enter="submitTitle()" @keyup.esc="submitTitle(true)" v-on:mouseenter="titleHover = true" v-on:mouseleave="titleHover = false" v-model="title"> <input class="navigation-bar__title navigation-bar__title--input text-input" :class="{'navigation-bar__title--focus': titleFocus, 'navigation-bar__title--scrolling': titleScrolling}" v-bind:style="{width: titleWidth + 'px'}" @focus="editTitle(true)" @blur="editTitle(false)" @keyup.enter="submitTitle()" @keyup.esc="submitTitle(true)" v-on:mouseenter="titleHover = true" v-on:mouseleave="titleHover = false" v-model="title">
<button v-if="!offline && isSyncPossible" class="navigation-bar__button button" :disabled="isSyncRequested" @click="requestSync">
<icon-sync></icon-sync>
</button>
<button v-if="offline && isSyncPossible" class="navigation-bar__button navigation-bar__button--sync-off button" disabled="disabled">
<icon-sync-off></icon-sync-off>
</button>
</div> </div>
<div class="navigation-bar__inner navigation-bar__inner--edit-buttons"> <div class="navigation-bar__inner navigation-bar__inner--edit-buttons">
<button class="navigation-bar__button button" @click="pagedownClick('bold')"> <button class="navigation-bar__button button" @click="pagedownClick('bold')">
@ -60,8 +66,9 @@
</template> </template>
<script> <script>
import { mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import editorSvc from '../services/editorSvc'; import editorSvc from '../services/editorSvc';
import syncSvc from '../services/syncSvc';
import animationSvc from '../services/animationSvc'; import animationSvc from '../services/animationSvc';
export default { export default {
@ -72,9 +79,19 @@ export default {
titleHover: false, titleHover: false,
}), }),
computed: { computed: {
...mapState([
'offline',
]),
...mapState('queue', [
'isSyncRequested',
]),
...mapGetters('layout', [ ...mapGetters('layout', [
'styles', 'styles',
]), ]),
isSyncPossible() {
return this.$store.getters['data/loginToken'] ||
this.$store.getters['syncLocation/current'].length;
},
showSpinner() { showSpinner() {
return !this.$store.state.queue.isEmpty; return !this.$store.state.queue.isEmpty;
}, },
@ -112,6 +129,11 @@ export default {
'toggleExplorer', 'toggleExplorer',
'toggleSideBar', 'toggleSideBar',
]), ]),
requestSync() {
if (!this.isSyncRequested) {
syncSvc.requestSync();
}
},
pagedownClick(name) { pagedownClick(name) {
editorSvc.pagedownEditor.uiManager.doClick(name); editorSvc.pagedownEditor.uiManager.doClick(name);
}, },
@ -203,6 +225,24 @@ export default {
font-size: 22px; font-size: 22px;
} }
.navigation-bar__button[disabled] {
&,
&:active,
&:focus,
&:hover {
color: $navbar-color;
}
}
.navigation-bar__button--sync-off[disabled] {
&,
&:active,
&:focus,
&:hover {
color: #f20;
}
}
.navigation-bar__title--input, .navigation-bar__title--input,
.navigation-bar__button { .navigation-bar__button {
&:active, &:active,

View File

@ -15,14 +15,15 @@
<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</div> <div>Sign in with Google</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">
<icon-login slot="icon"></icon-login> <icon-login slot="icon"></icon-login>
<div>Sign in on CouchDB</div> <div>Sign in on CouchDB</div>
<span>Save and collaborate on a CouchDB hosted by you.</span> <span>Save and collaborate on a CouchDB hosted by you.</span>
</side-bar-item> --> </side-bar-item> -->
<hr v-if="!loginToken">
<side-bar-item @click.native="panel = 'toc'"> <side-bar-item @click.native="panel = 'toc'">
<icon-toc slot="icon"></icon-toc> <icon-toc slot="icon"></icon-toc>
Table of contents Table of contents
@ -49,7 +50,7 @@ import Toc from './Toc';
import SideBarItem from './SideBarItem'; import SideBarItem from './SideBarItem';
import markdownSample from '../data/markdownSample.md'; import markdownSample from '../data/markdownSample.md';
import markdownConversionSvc from '../services/markdownConversionSvc'; import markdownConversionSvc from '../services/markdownConversionSvc';
import googleHelper from '../services/helpers/googleHelper'; import googleHelper from '../services/providers/helpers/googleHelper';
import syncSvc from '../services/syncSvc'; import syncSvc from '../services/syncSvc';
const panelNames = { const panelNames = {
@ -100,6 +101,10 @@ export default {
.side-bar { .side-bar {
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
hr {
margin: 5px 10px;
}
} }
.side-bar__inner { .side-bar__inner {

View File

@ -59,7 +59,7 @@ export default {
color: rgba(0, 0, 0, 0.75); color: rgba(0, 0, 0, 0.75);
cursor: pointer; cursor: pointer;
font-size: 10px; font-size: 10px;
padding: 10px 20px; padding: 5px 20px 40px;
white-space: nowrap; white-space: nowrap;
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
@ -73,8 +73,8 @@ export default {
.cl-toc-section { .cl-toc-section {
* { * {
margin: 0.25em 0; margin: 0.2em 0;
padding: 0.25em 0; padding: 0.2em 0;
} }
h2 { h2 {

View File

@ -83,6 +83,17 @@ textarea {
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
outline: 0; outline: 0;
} }
&[disabled] {
&,
&:active,
&:focus,
&:hover {
opacity: 0.5;
background-color: transparent;
cursor: inherit;
}
}
} }
.flex { .flex {

View File

@ -5,6 +5,5 @@ export default () => ({
properties: '\n', properties: '\n',
discussions: {}, discussions: {},
comments: {}, comments: {},
syncLocations: [], hash: 0,
updated: 0,
}); });

View File

@ -4,5 +4,5 @@ export default () => ({
selectionStart: 0, selectionStart: 0,
selectionEnd: 0, selectionEnd: 0,
scrollPosition: null, scrollPosition: null,
updated: 0, hash: 0,
}); });

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
export default () => ({
id: null,
type: 'syncLocation',
fileId: null,
hash: 0,
});

View File

@ -0,0 +1,7 @@
export default () => ({
id: null,
type: 'syncedContent',
historyData: {},
syncHistory: {},
hash: 0,
});

View File

@ -110,7 +110,7 @@ extensionSvc.onInitConverter(0, (markdown, options) => {
const anchorHash = {}; const anchorHash = {};
let headingOpenToken; let headingOpenToken;
let headingContent; let headingContent;
state.tokens.cl_each((token) => { state.tokens.forEach((token) => {
if (token.type === 'heading_open') { if (token.type === 'heading_open') {
headingContent = ''; headingContent = '';
headingOpenToken = token; headingOpenToken = token;
@ -148,7 +148,7 @@ extensionSvc.onInitConverter(0, (markdown, options) => {
]; ];
headingOpenToken = undefined; headingOpenToken = undefined;
} else if (headingOpenToken) { } else if (headingOpenToken) {
headingContent += token.children.cl_reduce((result, child) => { headingContent += token.children.reduce((result, child) => {
if (child.type !== 'footnote_ref') { if (child.type !== 'footnote_ref') {
return result + child.content; return result + child.content;
} }

5
src/icons/Sync.vue Normal file
View File

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00">
<path d="M12,18C8.69,18 6,15.31 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12C4,16.42 7.58,20 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6C15.31,6 18,8.69 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12C20,7.58 16.42,4 12,4Z" />
</svg>
</template>

5
src/icons/SyncOff.vue Normal file
View File

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="100%" height="100%" viewBox="0 0 24.00 24.00">
<path d="M20,4H14V10L16.24,7.76C17.32,8.85 18,10.34 18,12C18,13 17.75,13.94 17.32,14.77L18.78,16.23C19.55,15 20,13.56 20,12C20,9.79 19.09,7.8 17.64,6.36L20,4M2.86,5.41L5.22,7.77C4.45,9 4,10.44 4,12C4,14.21 4.91,16.2 6.36,17.64L4,20H10V14L7.76,16.24C6.68,15.15 6,13.66 6,12C6,11 6.25,10.06 6.68,9.23L14.76,17.31C14.5,17.44 14.26,17.56 14,17.65V19.74C14.79,19.53 15.54,19.2 16.22,18.78L18.58,21.14L19.85,19.87L4.14,4.14L2.86,5.41M10,6.35V4.26C9.2,4.47 8.45,4.8 7.77,5.22L9.23,6.68C9.5,6.56 9.73,6.44 10,6.35Z" />
</svg>
</template>

View File

@ -28,6 +28,8 @@ import ArrowLeft from './ArrowLeft';
import HelpCircle from './HelpCircle'; import HelpCircle from './HelpCircle';
import Toc from './Toc'; import Toc from './Toc';
import Login from './Login'; import Login from './Login';
import Sync from './Sync';
import SyncOff from './SyncOff';
Vue.component('iconFormatBold', FormatBold); Vue.component('iconFormatBold', FormatBold);
Vue.component('iconFormatItalic', FormatItalic); Vue.component('iconFormatItalic', FormatItalic);
@ -58,3 +60,5 @@ Vue.component('iconArrowLeft', ArrowLeft);
Vue.component('iconHelpCircle', HelpCircle); Vue.component('iconHelpCircle', HelpCircle);
Vue.component('iconToc', Toc); Vue.component('iconToc', Toc);
Vue.component('iconLogin', Login); Vue.component('iconLogin', Login);
Vue.component('iconSync', Sync);
Vue.component('iconSyncOff', SyncOff);

View File

@ -2,13 +2,12 @@ import DiffMatchPatch from 'diff-match-patch';
import utils from './utils'; import utils from './utils';
const diffMatchPatch = new DiffMatchPatch(); const diffMatchPatch = new DiffMatchPatch();
const diffMatchPatchStrict = new DiffMatchPatch(); diffMatchPatch.Match_Distance = 10000;
diffMatchPatchStrict.Match_Threshold = 0;
diffMatchPatchStrict.Patch_DeleteThreshold = 0;
const diffMatchPatchPermissive = new DiffMatchPatch();
diffMatchPatchPermissive.Match_Distance = 999999999;
function makePatchableText(content, markerKeys, markerIdxMap) { function makePatchableText(content, markerKeys, markerIdxMap) {
if (!content || !content.discussions) {
return null;
}
const markers = []; const markers = [];
// Sort keys to have predictable marker positions in case of same offset // Sort keys to have predictable marker positions in case of same offset
const discussionKeys = Object.keys(content.discussions).sort(); const discussionKeys = Object.keys(content.discussions).sort();
@ -58,6 +57,9 @@ function makePatchableText(content, markerKeys, markerIdxMap) {
} }
function stripDiscussionOffsets(objectMap) { function stripDiscussionOffsets(objectMap) {
if (objectMap == null) {
return objectMap;
}
const result = {}; const result = {};
Object.keys(objectMap).forEach((id) => { Object.keys(objectMap).forEach((id) => {
result[id] = { result[id] = {
@ -85,72 +87,58 @@ function restoreDiscussionOffsets(content, markerKeys) {
} }
} }
function serializeObject(obj) { function mergeText(serverText, clientText, lastMergedText) {
if (!obj) { const serverClientDiffs = diffMatchPatch.diff_main(serverText, clientText);
return obj; diffMatchPatch.diff_cleanupSemantic(serverClientDiffs);
// Fusion text is a mix of both server and client contents
const fusionText = serverClientDiffs.map(diff => diff[1]).join('');
if (!lastMergedText) {
return fusionText;
} }
return JSON.stringify(obj, (key, value) => { // Let's try to find out what text has to be removed from fusion
if (Object.prototype.toString.call(value) !== '[object Object]') { const intersectionText = serverClientDiffs
return value; // Keep only equalities
} .filter(diff => diff[0] === DiffMatchPatch.DIFF_EQUAL)
return Object.keys(value).sort().reduce((sorted, valueKey) => { .map(diff => diff[1]).join('');
sorted[valueKey] = value[valueKey]; const lastMergedTextDiffs = diffMatchPatch.diff_main(lastMergedText, intersectionText)
return sorted; // Keep only equalities and deletions
}, {}); .filter(diff => diff[0] !== DiffMatchPatch.DIFF_INSERT);
}); diffMatchPatch.diff_cleanupSemantic(serverClientDiffs);
// Make a patch with deletions only
const patches = diffMatchPatch.patch_make(lastMergedText, lastMergedTextDiffs);
// Apply patch to fusion text
return diffMatchPatch.patch_apply(patches, fusionText)[0];
} }
function mergeText(oldText, newText, serverText) { function mergeValues(serverValue, clientValue, lastMergedValue) {
let diffs = diffMatchPatch.diff_main(oldText, newText); if (!lastMergedValue) {
diffMatchPatch.diff_cleanupSemantic(diffs); return serverValue || clientValue; // Take the server value in priority
const patches = diffMatchPatch.patch_make(oldText, diffs);
const patchResult = diffMatchPatch.patch_apply(patches, serverText);
if (!patchResult[1].some(changeApplied => !changeApplied)) {
return patchResult[0];
} }
const newSerializedValue = utils.serializeObject(clientValue);
diffs = diffMatchPatchStrict.diff_main(patchResult[0], newText); const serverSerializedValue = utils.serializeObject(serverValue);
diffMatchPatch.diff_cleanupSemantic(diffs);
return diffs.map(diff => diff[1]).join('');
}
function quickPatch(oldStr, newStr, destStr, strict) {
const dmp = strict ? diffMatchPatchStrict : diffMatchPatch;
const diffs = dmp.diff_main(oldStr, newStr);
const patches = dmp.patch_make(oldStr, diffs);
const patchResult = dmp.patch_apply(patches, destStr);
return patchResult[0];
}
function mergeValue(oldValue, newValue, serverValue) {
if (!oldValue) {
return serverValue; // There might be conflict, keep the server value
}
const newSerializedValue = serializeObject(newValue);
const serverSerializedValue = serializeObject(serverValue);
if (newSerializedValue === serverSerializedValue) { if (newSerializedValue === serverSerializedValue) {
return serverValue; // no conflict return serverValue; // no conflict
} }
const oldSerializedValue = serializeObject(oldValue); const oldSerializedValue = utils.serializeObject(lastMergedValue);
if (oldSerializedValue !== newSerializedValue && !serverValue) { if (oldSerializedValue !== newSerializedValue && !serverValue) {
return newValue; // Removed on server but changed on client return clientValue; // Removed on server but changed on client
} }
if (oldSerializedValue !== serverSerializedValue && !newValue) { if (oldSerializedValue !== serverSerializedValue && !clientValue) {
return serverValue; // Removed on client but changed on server return serverValue; // Removed on client but changed on server
} }
if (oldSerializedValue !== newSerializedValue && oldSerializedValue === serverSerializedValue) { if (oldSerializedValue !== newSerializedValue && oldSerializedValue === serverSerializedValue) {
return newValue; // Take the client value return clientValue; // Take the client value
} }
return serverValue; // Take the server value otherwise return serverValue; // Take the server value
} }
function mergeObjects(oldObject, newObject, serverObject) { function mergeObjects(serverObject, clientObject, lastMergedObject = {}) {
const mergedObject = {}; const mergedObject = {};
Object.keys({ Object.keys({
...newObject, ...clientObject,
...serverObject, ...serverObject,
}).forEach((key) => { }).forEach((key) => {
const mergedValue = mergeValue(oldObject[key], newObject[key], serverObject[key]); const mergedValue = mergeValues(serverObject[key], clientObject[key], lastMergedObject[key]);
if (mergedValue != null) { if (mergedValue != null) {
mergedObject[key] = mergedValue; mergedObject[key] = mergedValue;
} }
@ -158,47 +146,41 @@ function mergeObjects(oldObject, newObject, serverObject) {
return utils.deepCopy(mergedObject); return utils.deepCopy(mergedObject);
} }
function mergeContent(oldContent, newContent, serverContent) { function mergeContent(serverContent, clientContent, lastMergedContent = {}) {
const markerKeys = []; const markerKeys = [];
const markerIdxMap = Object.create(null); const markerIdxMap = Object.create(null);
const oldText = makePatchableText(oldContent, markerKeys, markerIdxMap); const lastMergedText = makePatchableText(lastMergedContent, markerKeys, markerIdxMap);
const serverText = makePatchableText(serverContent, markerKeys, markerIdxMap); const serverText = makePatchableText(serverContent, markerKeys, markerIdxMap);
const localText = makePatchableText(newContent, markerKeys, markerIdxMap); const clientText = makePatchableText(clientContent, markerKeys, markerIdxMap);
const isServerTextChanges = oldText !== serverText; const isServerTextChanges = lastMergedText !== serverText;
const isTextSynchronized = serverText === localText; const isTextSynchronized = serverText === clientText;
const result = { const result = {
text: isTextSynchronized || !isServerTextChanges text: isTextSynchronized || !isServerTextChanges
? localText ? clientText
: mergeText(oldText, serverText, localText), : mergeText(serverText, clientText, lastMergedText),
properties: mergeValue( properties: mergeValues(
oldContent.properties,
newContent.properties,
serverContent.properties, serverContent.properties,
clientContent.properties,
lastMergedContent.properties,
), ),
discussions: mergeObjects( discussions: mergeObjects(
stripDiscussionOffsets(oldContent.discussions),
stripDiscussionOffsets(newContent.discussions),
stripDiscussionOffsets(serverContent.discussions), stripDiscussionOffsets(serverContent.discussions),
stripDiscussionOffsets(clientContent.discussions),
stripDiscussionOffsets(lastMergedContent.discussions),
), ),
comments: mergeObjects( comments: mergeObjects(
oldContent.comments,
newContent.comments,
serverContent.comments, serverContent.comments,
clientContent.comments,
lastMergedContent.comments,
), ),
}; };
restoreDiscussionOffsets(result, markerKeys); restoreDiscussionOffsets(result, markerKeys);
return result return result;
} }
export default { export default {
serializeObject,
makePatchableText, makePatchableText,
restoreDiscussionOffsets, restoreDiscussionOffsets,
applyContentChanges,
getTextPatches,
getObjectPatches,
quickPatch,
mergeObjects,
mergeContent, mergeContent,
}; };

View File

@ -1,6 +1,7 @@
import DiffMatchPatch from 'diff-match-patch'; import DiffMatchPatch from 'diff-match-patch';
import cledit from '../libs/cledit'; import cledit from '../libs/cledit';
import clDiffUtils from '../libs/cldiffutils'; import utils from './utils';
import diffUtils from './diffUtils';
import store from '../store'; import store from '../store';
let clEditor; let clEditor;
@ -34,26 +35,35 @@ function getDiscussionMarkers(discussion, discussionId, onMarker) {
getMarker('offset1'); getMarker('offset1');
} }
function syncDiscussionMarkers() { function syncDiscussionMarkers(content, writeOffsets) {
const content = store.getters['content/current']; Object.keys(discussionMarkers).forEach((markerKey) => {
Object.keys(discussionMarkers) const marker = discussionMarkers[markerKey];
.forEach((markerKey) => { // Remove marker if discussion was removed
const marker = discussionMarkers[markerKey]; const discussion = content.discussions[marker.discussionId];
// Remove marker if discussion was removed if (!discussion || discussion[marker.offsetName] === undefined) {
const discussion = content.discussions[marker.discussionId]; clEditor.removeMarker(marker);
if (!discussion || discussion[marker.offsetName] === undefined) { delete discussionMarkers[markerKey];
clEditor.removeMarker(marker); }
delete discussionMarkers[markerKey]; });
}
});
Object.keys(content.discussions) Object.keys(content.discussions).forEach((discussionId) => {
.forEach((discussionId) => { const discussion = content.discussions[discussionId];
const discussion = content.discussions[discussionId]; getDiscussionMarkers(discussion, discussionId, writeOffsets
getDiscussionMarkers(discussion, discussionId, (marker) => { ? (marker) => {
discussion[marker.offsetName] = marker.offset; discussion[marker.offsetName] = marker.offset;
}
: (marker) => {
marker.offset = discussion[marker.offsetName];
}); });
}); });
}
function removeDiscussionMarkers() {
Object.keys(discussionMarkers).forEach((markerKey) => {
const marker = discussionMarkers[markerKey];
clEditor.removeMarker(marker);
delete discussionMarkers[markerKey];
});
} }
const diffMatchPatch = new DiffMatchPatch(); const diffMatchPatch = new DiffMatchPatch();
@ -91,8 +101,6 @@ function reversePatches(patches) {
export default { export default {
clEditor: null, clEditor: null,
lastChange: 0,
lastExternalChange: 0,
createClEditor(editorElt) { createClEditor(editorElt) {
this.clEditor = cledit(editorElt, editorElt.parentNode); this.clEditor = cledit(editorElt, editorElt.parentNode);
clEditor = this.clEditor; clEditor = this.clEditor;
@ -100,42 +108,38 @@ export default {
markerIdxMap = Object.create(null); markerIdxMap = Object.create(null);
discussionMarkers = {}; discussionMarkers = {};
clEditor.on('contentChanged', (text) => { clEditor.on('contentChanged', (text) => {
store.dispatch('content/patchCurrent', { text }); const oldContent = store.getters['content/current'];
syncDiscussionMarkers(); const newContent = {
const content = store.getters['content/current']; ...oldContent,
discussions: utils.deepCopy(oldContent.discussions),
text,
};
syncDiscussionMarkers(newContent, true);
if (!isChangePatch) { if (!isChangePatch) {
previousPatchableText = currentPatchableText; previousPatchableText = currentPatchableText;
currentPatchableText = clDiffUtils.makePatchableText(content, markerKeys, markerIdxMap); currentPatchableText = diffUtils.makePatchableText(newContent, markerKeys, markerIdxMap);
} else { } else {
// Take a chance to restore discussion offsets on undo/redo // Take a chance to restore discussion offsets on undo/redo
content.text = currentPatchableText; diffUtils.restoreDiscussionOffsets(newContent, markerKeys);
clDiffUtils.restoreDiscussionOffsets(content, markerKeys); syncDiscussionMarkers(newContent, false);
content.discussions.cl_each((discussion, discussionId) => {
getDiscussionMarkers(discussion, discussionId, (marker) => {
marker.offset = discussion[marker.offsetName];
});
});
} }
store.dispatch('content/patchCurrent', newContent);
isChangePatch = false; isChangePatch = false;
this.lastChange = Date.now();
}); });
clEditor.addMarker(newDiscussionMarker0); clEditor.addMarker(newDiscussionMarker0);
clEditor.addMarker(newDiscussionMarker1); clEditor.addMarker(newDiscussionMarker1);
}, },
initClEditor(opts, reinit) { initClEditor(opts) {
const content = store.getters['content/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);
if (contentId !== content.id) { if (contentId !== content.id) {
contentId = content.id; contentId = content.id;
currentPatchableText = clDiffUtils.makePatchableText(content, markerKeys, markerIdxMap); currentPatchableText = diffUtils.makePatchableText(content, markerKeys, markerIdxMap);
previousPatchableText = currentPatchableText; previousPatchableText = currentPatchableText;
syncDiscussionMarkers(); syncDiscussionMarkers(content, false);
} const contentState = store.getters['contentState/current'];
if (reinit) {
options.content = content.text; options.content = content.text;
options.selectionStart = contentState.selectionStart; options.selectionStart = contentState.selectionStart;
options.selectionEnd = contentState.selectionEnd; options.selectionEnd = contentState.selectionEnd;
@ -143,21 +147,21 @@ export default {
options.patchHandler = { options.patchHandler = {
makePatches, makePatches,
applyPatches: patches => applyPatches(patches), applyPatches,
reversePatches, reversePatches,
}; };
clEditor.init(options); clEditor.init(options);
} }
}, },
applyContent(isExternal) { applyContent() {
if (!clEditor) { if (clEditor) {
return null; const content = store.getters['content/current'];
if (clEditor.setContent(content.text, true).range) {
// Marker will be recreated on contentChange
removeDiscussionMarkers();
} else {
syncDiscussionMarkers(content, false);
}
} }
if (isExternal) {
this.lastExternalChange = Date.now();
}
syncDiscussionMarkers();
const content = store.getters['content/current'];
return clEditor.setContent(content.text, isExternal);
}, },
}; };

View File

@ -29,7 +29,6 @@ const allowDebounce = (action, wait) => {
const diffMatchPatch = new DiffMatchPatch(); const diffMatchPatch = new DiffMatchPatch();
let lastContentId = null; let lastContentId = null;
let reinitClEditor = true;
let instantPreview = true; let instantPreview = true;
let tokens; let tokens;
const anchorHash = {}; const anchorHash = {};
@ -185,12 +184,11 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
return 0.15; return 0.15;
}, },
}; };
editorEngineSvc.initClEditor(options, reinitClEditor); editorEngineSvc.initClEditor(options);
editorEngineSvc.clEditor.toggleEditable(true); editorEngineSvc.clEditor.toggleEditable(true);
const contentId = store.getters['content/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;
this.restoreScrollPosition(); this.restoreScrollPosition();
}, },
@ -573,30 +571,26 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
const debouncedRefreshPreview = debounce(refreshPreview, 20); const debouncedRefreshPreview = debounce(refreshPreview, 20);
let newSectionList; const onEditorChanged =
let newSelectionRange; (sectionList = this.sectionList, selectionRange = this.selectionRange) => {
const onEditorChanged = () => { if (this.sectionList !== sectionList) {
if (this.sectionList !== newSectionList) { this.sectionList = sectionList;
this.sectionList = newSectionList; this.$emit('sectionList', this.sectionList);
this.$emit('sectionList', this.sectionList); if (instantPreview) {
if (instantPreview) { refreshPreview();
refreshPreview(); } else {
} else { debouncedRefreshPreview();
debouncedRefreshPreview(); }
} }
} if (this.selectionRange !== selectionRange) {
if (this.selectionRange !== newSelectionRange) { this.selectionRange = selectionRange;
this.selectionRange = newSelectionRange; this.$emit('selectionRange', this.selectionRange);
this.$emit('selectionRange', this.selectionRange); }
} this.saveContentState();
this.saveContentState(); };
};
const debouncedEditorChanged = debounce(onEditorChanged, 10);
editorEngineSvc.clEditor.selectionMgr.on('selectionChanged', (start, end, selectionRange) => { editorEngineSvc.clEditor.selectionMgr.on('selectionChanged',
newSelectionRange = selectionRange; (start, end, selectionRange) => onEditorChanged(undefined, selectionRange));
debouncedEditorChanged();
});
/* ----------------------------- /* -----------------------------
* Inline images * Inline images
@ -684,14 +678,8 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
triggerImgCacheGc(); triggerImgCacheGc();
}); });
editorEngineSvc.clEditor.on('contentChanged', (content, diffs, sectionList) => { editorEngineSvc.clEditor.on('contentChanged',
newSectionList = sectionList; (content, diffs, sectionList) => onEditorChanged(sectionList));
if (instantPreview) {
onEditorChanged();
} else {
debouncedEditorChanged();
}
});
this.$emit('inited'); this.$emit('inited');
@ -716,20 +704,20 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
// } // }
// }) // })
// Watch file content properties changes // Watch file content changes
store.watch( store.watch(
() => store.getters['content/current'].properties, () => store.getters['content/current'].hash,
(properties) => { () => {
// Track ID changes at the same time const content = store.getters['content/current'];
const contentId = store.getters['content/current'].id; // Track ID changes
let initClEditor = false; let initClEditor = false;
if (contentId !== lastContentId) { if (content.id !== lastContentId) {
reinitClEditor = true;
instantPreview = true; instantPreview = true;
lastContentId = contentId; lastContentId = content.id;
initClEditor = true; initClEditor = true;
} }
const options = extensionSvc.getOptions(properties, true); // Track properties changes
const options = extensionSvc.getOptions(content.properties, true);
if (JSON.stringify(options) !== JSON.stringify(editorSvc.options)) { if (JSON.stringify(options) !== JSON.stringify(editorSvc.options)) {
editorSvc.options = options; editorSvc.options = options;
editorSvc.initPrism(); editorSvc.initPrism();
@ -739,6 +727,8 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
if (initClEditor) { if (initClEditor) {
editorSvc.initClEditor(); editorSvc.initClEditor();
} }
// Apply possible text and discussion changes
editorEngineSvc.applyContent();
}, { }, {
immediate: true, immediate: true,
}); });

View File

@ -1,16 +1,15 @@
import 'babel-polyfill'; import 'babel-polyfill';
import 'indexeddbshim'; import 'indexeddbshim/dist/indexeddbshim';
import utils from './utils'; import utils from './utils';
import store from '../store'; import store from '../store';
let indexedDB = window.indexedDB; const indexedDB = window.indexedDB;
const localStorage = window.localStorage; const localStorage = window.localStorage;
const dbVersion = 1; const dbVersion = 1;
const dbStoreName = 'objects'; const dbStoreName = 'objects';
// Use the shim on Safari or when indexedDB is not available if (!indexedDB) {
if (window.shimIndexedDB && (!indexedDB || (navigator.userAgent.indexOf('Chrome') === -1 && navigator.userAgent.indexOf('Safari') !== -1))) { throw new Error('Your browser is not supported. Please upgrade to the latest version.');
indexedDB = window.shimIndexedDB;
} }
const deleteMarkerMaxAge = 1000; const deleteMarkerMaxAge = 1000;
@ -81,20 +80,20 @@ class Connection {
} }
} }
const updatedMap = {}; const hashMap = {};
utils.types.forEach((type) => { utils.types.forEach((type) => {
updatedMap[type] = Object.create(null); hashMap[type] = Object.create(null);
}); });
const contentTypes = { const contentTypes = {
content: true, content: true,
contentState: true, contentState: true,
syncContent: true, syncedContent: true,
}; };
export default { export default {
lastTx: 0, lastTx: 0,
updatedMap, hashMap,
connection: new Connection(), connection: new Connection(),
/** /**
@ -145,7 +144,7 @@ export default {
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
if (!item.updated && lastTx - item.tx > deleteMarkerMaxAge) { if (!item.hash && lastTx - item.tx > deleteMarkerMaxAge) {
dbStore.delete(item.id); dbStore.delete(item.id);
} }
}); });
@ -163,7 +162,7 @@ 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((type) => { Object.keys(this.hashMap).forEach((type) => {
// Remove this type only if file is deleted // Remove this type only if file is deleted
let checker = cb => id => !storeItemMap[id] && cb(id); let checker = cb => id => !storeItemMap[id] && cb(id);
if (contentTypes[type]) { if (contentTypes[type]) {
@ -177,14 +176,14 @@ export default {
} }
}; };
} }
Object.keys(this.updatedMap[type]).forEach(checker((id) => { Object.keys(this.hashMap[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, type,
tx: incrementedTx, tx: incrementedTx,
}); });
delete this.updatedMap[type][id]; delete this.hashMap[type][id];
this.lastTx = incrementedTx; // No need to read what we just wrote this.lastTx = incrementedTx; // No need to read what we just wrote
})); }));
}); });
@ -193,13 +192,13 @@ export default {
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.type][storeItem.id] !== storeItem.updated) { if (this.hashMap[storeItem.type][storeItem.id] !== storeItem.hash) {
const item = { const item = {
...storeItem, ...storeItem,
tx: incrementedTx, tx: incrementedTx,
}; };
dbStore.put(item); dbStore.put(item);
this.updatedMap[item.type][item.id] = item.updated; this.hashMap[item.type][item.id] = item.hash;
this.lastTx = incrementedTx; // No need to read what we just wrote this.lastTx = incrementedTx; // No need to read what we just wrote
} }
}); });
@ -210,20 +209,21 @@ export default {
*/ */
readDbItem(dbItem, storeItemMap) { readDbItem(dbItem, storeItemMap) {
const existingStoreItem = storeItemMap[dbItem.id]; const existingStoreItem = storeItemMap[dbItem.id];
if (!dbItem.updated) { if (!dbItem.hash) {
// DB item is a delete marker // DB item is a delete marker
delete this.updatedMap[dbItem.type][dbItem.id]; delete this.hashMap[dbItem.type][dbItem.id];
if (existingStoreItem) { if (existingStoreItem) {
// Remove item from the store // Remove item from the store
store.commit(`${existingStoreItem.type}/deleteItem`, existingStoreItem.id); store.commit(`${existingStoreItem.type}/deleteItem`, existingStoreItem.id);
delete storeItemMap[existingStoreItem.id]; delete storeItemMap[existingStoreItem.id];
} }
} else if (this.updatedMap[dbItem.type][dbItem.id] !== dbItem.updated) { } else if (this.hashMap[dbItem.type][dbItem.id] !== dbItem.hash) {
// DB item is different from the corresponding store item // DB item is different from the corresponding store item
this.updatedMap[dbItem.type][dbItem.id] = dbItem.updated; this.hashMap[dbItem.type][dbItem.id] = dbItem.hash;
// Update content only if it exists in the store // Update content only if it exists in the store
if (existingStoreItem || !contentTypes[dbItem.type]) { if (existingStoreItem || !contentTypes[dbItem.type]) {
// Put item in the store // Put item in the store
dbItem.tx = undefined;
store.commit(`${dbItem.type}/setItem`, dbItem); store.commit(`${dbItem.type}/setItem`, dbItem);
storeItemMap[dbItem.id] = dbItem; storeItemMap[dbItem.id] = dbItem;
} }
@ -248,13 +248,14 @@ export default {
const request = dbStore.get(id); const request = dbStore.get(id);
request.onsuccess = () => { request.onsuccess = () => {
const dbItem = request.result; const dbItem = request.result;
if (!dbItem || !dbItem.updated) { if (!dbItem || !dbItem.hash) {
onError(); onError();
} else { } else {
this.updatedMap[dbItem.type][dbItem.id] = dbItem.updated; this.hashMap[dbItem.type][dbItem.id] = dbItem.hash;
// Put item in the store // Put item in the store
dbItem.tx = undefined;
store.commit(`${dbItem.type}/setItem`, dbItem); store.commit(`${dbItem.type}/setItem`, dbItem);
resolve(dbItem); resolve();
} }
}; };
}, () => onError()); }, () => onError());
@ -265,15 +266,12 @@ export default {
* Unload from the store contents that haven't been opened recently * Unload from the store contents that haven't been opened recently
*/ */
unloadContents() { unloadContents() {
const lastOpenedFileIds = store.getters['data/lastOpenedIds'] // Keep only last opened files in memory
.slice(0, 10).reduce((result, id) => { const lastOpenedFileIds = new Set(store.getters['data/lastOpenedIds']);
result[id] = true;
return result;
}, {});
Object.keys(contentTypes).forEach((type) => { Object.keys(contentTypes).forEach((type) => {
store.getters(`${type}/items`).forEach((item) => { store.getters[`${type}/items`].forEach((item) => {
const [fileId] = item.id.split('/'); const [fileId] = item.id.split('/');
if (!lastOpenedFileIds[fileId]) { if (!lastOpenedFileIds.has(fileId)) {
// Remove item from the store // Remove item from the store
store.commit(`${type}/deleteItem`, item.id); store.commit(`${type}/deleteItem`, item.id);
} }

View File

@ -93,7 +93,7 @@ const deflistBlockTypeMap = createFlagMap([
function hashArray(arr, valueHash, valueArray) { function hashArray(arr, valueHash, valueArray) {
const hash = []; const hash = [];
arr.cl_each((str) => { arr.forEach((str) => {
let strHash = valueHash[str]; let strHash = valueHash[str];
if (strHash === undefined) { if (strHash === undefined) {
strHash = valueArray.length; strHash = valueArray.length;

View File

@ -1,24 +1,108 @@
import store from '../../store'; import store from '../../store';
import googleHelper from '../helpers/googleHelper'; import googleHelper from './helpers/googleHelper';
export default { export default {
downloadContent(token, fileId) { getChanges(token) {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`]; return googleHelper.getChanges(token)
.then((result) => {
const changes = result.changes.filter((change) => {
if (change.file) {
try {
change.item = JSON.parse(change.file.name);
} catch (e) {
return false;
}
// Build sync data
change.syncData = {
id: change.fileId,
itemId: change.item.id,
type: change.item.type,
hash: change.item.hash,
};
change.file = undefined;
}
return true;
});
changes.nextPageToken = result.nextPageToken;
return changes;
});
},
setAppliedChanges(token, changes) {
const lastToken = store.getters['data/googleTokens'][token.sub];
if (changes.nextPageToken !== lastToken.nextPageToken) {
store.dispatch('data/setGoogleToken', {
...lastToken,
nextPageToken: changes.nextPageToken,
});
}
},
saveItem(token, item, syncData, ifNotTooLate) {
return googleHelper.saveFile(
token,
JSON.stringify(item),
['appDataFolder'],
null,
syncData && syncData.id,
ifNotTooLate,
)
.then(file => ({
// Build sync data
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
}));
},
removeItem(token, syncData, ifNotTooLate) {
return googleHelper.removeAppDataFile(token, syncData.id, ifNotTooLate)
.then(() => syncData);
},
downloadContent(token, syncLocation) {
const syncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
if (!syncData) {
return Promise.resolve();
}
return googleHelper.downloadAppDataFile(token, syncData.id) return googleHelper.downloadAppDataFile(token, syncData.id)
.then((content) => { .then((content) => {
if (content.updated !== syncData.updated) { const item = JSON.parse(content);
if (item.hash !== syncData.hash) {
store.dispatch('data/setSyncData', { store.dispatch('data/setSyncData', {
...store.getters['data/syncData'], ...store.getters['data/syncData'],
[syncData.id]: { [syncData.id]: {
...syncData, ...syncData,
updated: content.updated, hash: item.hash,
}, },
}); });
} }
return { return item;
history: [],
...content,
};
}); });
}, },
uploadContent(token, item, syncLocation, ifNotTooLate) {
const syncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
if (syncData && syncData.hash === item.hash) {
return Promise.resolve();
}
return googleHelper.saveAppDataFile(
token,
JSON.stringify({
id: item.id,
type: item.type,
hash: item.hash,
}),
['appDataFolder'],
JSON.stringify(item),
syncData && syncData.id,
ifNotTooLate,
)
.then(file => store.dispatch('data/setSyncData', {
...store.getters['data/syncData'],
[file.id]: {
// Build sync data
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
},
}));
},
}; };

View File

@ -0,0 +1,30 @@
import store from '../../store';
import googleHelper from './helpers/googleHelper';
import providerUtils from './providerUtils';
export default {
downloadContent(token, syncLocation) {
return googleHelper.downloadFile(token, syncLocation.gdriveFileId)
.then(content => providerUtils.parseContent(content));
},
uploadContent(token, item, syncLocation, ifNotTooLate) {
const file = store.state.file.itemMap[syncLocation.fileId];
const name = (file && file.name) || 'Untitled';
const parents = [];
if (syncLocation.gdriveParentId) {
parents.push(syncLocation.gdriveParentId);
}
return googleHelper.saveFile(
token,
name,
parents,
providerUtils.serializeContent(item),
syncLocation && syncLocation.gdriveId,
ifNotTooLate,
)
.then(gdriveFile => ({
...syncLocation,
gdriveId: gdriveFile.id,
}));
},
};

View File

@ -1,9 +1,9 @@
import utils from '../utils'; import utils from '../../utils';
import store from '../../store'; import store from '../../../store';
const clientId = '241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com'; const clientId = '241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com';
const appsDomain = null; const appsDomain = null;
const tokenExpirationMargin = 10 * 60 * 1000; // 10 min const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (Google tokens expire after 1h)
// const scopeMap = { // const scopeMap = {
// profile: [ // profile: [
@ -23,6 +23,11 @@ const tokenExpirationMargin = 10 * 60 * 1000; // 10 min
// ], // ],
// }; // };
const gdriveAppDataScopes = ['https://www.googleapis.com/auth/drive.appdata'];
const getGdriveScopes = () => [store.getters['data/settings'].gdriveFullAccess === true
? 'https://www.googleapis.com/auth/drive'
: 'https://www.googleapis.com/auth/drive.file'];
const request = (token, options) => utils.request({ const request = (token, options) => utils.request({
...options, ...options,
headers: { headers: {
@ -31,17 +36,76 @@ const request = (token, options) => utils.request({
}, },
}); });
function saveFile(refreshedToken, name, parents, media = null, fileId = null,
ifNotTooLate = cb => res => cb(res),
) {
return Promise.resolve()
// Refreshing a token can take a while if an oauth window pops up, so check if it's too late
.then(ifNotTooLate(() => {
const options = {
method: 'POST',
url: 'https://www.googleapis.com/drive/v3/files',
};
const metadata = { name };
if (fileId) {
options.method = 'PATCH';
options.url = `https://www.googleapis.com/drive/v3/files/${fileId}`;
} else if (parents) {
// Parents field is not patchable
metadata.parents = parents;
}
if (media) {
const boundary = `-------${utils.uid()}`;
const delimiter = `\r\n--${boundary}\r\n`;
const closeDelimiter = `\r\n--${boundary}--`;
let multipartRequestBody = '';
multipartRequestBody += delimiter;
multipartRequestBody += 'Content-Type: application/json; charset=UTF-8\r\n\r\n';
multipartRequestBody += JSON.stringify(metadata);
multipartRequestBody += delimiter;
multipartRequestBody += 'Content-Type: text/plain; charset=UTF-8\r\n\r\n';
multipartRequestBody += 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,
}).then(res => res.body);
}
return request(refreshedToken, {
...options,
body: metadata,
}).then(res => res.body);
}));
}
function downloadFile(refreshedToken, id) {
return request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${id}?alt=media`,
raw: true,
}).then(res => res.body);
}
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',
@ -116,8 +180,10 @@ export default {
}); });
}, },
getChanges(token) { getChanges(token) {
let changes = []; const result = {
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], token) changes: [],
};
return this.refreshToken(gdriveAppDataScopes, token)
.then((refreshedToken) => { .then((refreshedToken) => {
const getPage = (pageToken = '1') => request(refreshedToken, { const getPage = (pageToken = '1') => request(refreshedToken, {
method: 'GET', method: 'GET',
@ -129,122 +195,39 @@ export default {
fields: 'nextPageToken,newStartPageToken,changes(fileId,removed,file/name,file/properties)', fields: 'nextPageToken,newStartPageToken,changes(fileId,removed,file/name,file/properties)',
}, },
}).then((res) => { }).then((res) => {
changes = changes.concat(res.body.changes.filter(item => item.fileId)); result.changes = result.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) => { result.nextPageToken = res.body.newStartPageToken;
if (change.file) { return result;
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(refreshedToken.nextPageToken); return getPage(refreshedToken.nextPageToken);
}); });
}, },
updateNextPageToken(token, changes) { saveFile(token, name, parents, media, fileId, ifNotTooLate) {
const lastToken = store.getters['data/googleTokens'][token.sub]; return this.refreshToken(getGdriveScopes(), token)
if (changes.nextPageToken !== lastToken.nextPageToken) { .then(refreshedToken => saveFile(refreshedToken, name, parents, media, fileId, ifNotTooLate));
store.dispatch('data/setGoogleToken', {
...lastToken,
nextPageToken: changes.nextPageToken,
});
}
}, },
saveItem(token, item, syncData, ifNotTooLate = cb => res => cb(res)) { saveAppDataFile(token, name, parents, media, fileId, ifNotTooLate) {
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], token) return this.refreshToken(gdriveAppDataScopes, token)
// Refreshing a token can take a while if an oauth window pops up, so check if it's too late .then(refreshedToken => saveFile(refreshedToken, name, parents, media, fileId, ifNotTooLate));
.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,
}));
}));
}, },
removeItem(token, syncData, ifNotTooLate = cb => res => cb(res)) { downloadFile(token, id) {
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], token) return this.refreshToken(getGdriveScopes(), token)
.then(refreshedToken => downloadFile(refreshedToken, id));
},
downloadAppDataFile(token, id) {
return this.refreshToken(gdriveAppDataScopes, token)
.then(refreshedToken => downloadFile(refreshedToken, id));
},
removeAppDataFile(token, id, ifNotTooLate = cb => res => cb(res)) {
return this.refreshToken(gdriveAppDataScopes, token)
// Refreshing a token can take a while if an oauth window pops up, so check if it's too late // 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, { .then(ifNotTooLate(refreshedToken => request(refreshedToken, {
method: 'DELETE', method: 'DELETE',
url: `https://www.googleapis.com/drive/v3/files/${syncData.id}`, url: `https://www.googleapis.com/drive/v3/files/${id}`,
})).then(() => syncData)); })));
},
downloadFile(refreshedToken, id) {
return request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${id}?alt=media`,
}).then(res => res.body);
},
downloadAppDataFile(token, id) {
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], token)
.then(refreshedToken => this.downloadFile(refreshedToken, id));
}, },
}; };

View File

@ -0,0 +1,48 @@
import emptyContent from '../../data/emptyContent';
const dataExtractor = /<!--stackedit_data:([A-Za-z0-9+/=\s]+)-->$/;
// https://developer.mozilla.org/en/docs/Web/API/WindowBase64/Base64_encoding_and_decoding
const b64Encode = str => btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
(match, p1) => String.fromCharCode(`0x${p1}`)));
const b64Decode = str => decodeURIComponent(atob(str).split('').map(
c => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`).join(''));
export default {
serializeContent(content) {
let result = content.text;
const data = {};
if (content.properties.length > 1) {
data.properties = content.properties;
}
if (Object.keys(content.discussions).length) {
data.discussions = content.discussions;
}
if (Object.keys(content.comments).length) {
data.comments = content.comments;
}
if (content.history && content.history.length) {
data.history = content.history;
}
if (Object.keys(data).length) {
const serializedData = b64Encode(JSON.stringify(data)).replace(/(.{50})/g, '$1\n');
result += `<!--stackedit_data:\n${serializedData}-->`;
}
return result;
},
parseContent(serializedContent) {
const result = emptyContent();
result.text = serializedContent;
const extractedData = dataExtractor.exec(serializedContent);
if (extractedData) {
try {
const serializedData = extractedData[1].replace(/\s/g, '');
Object.assign(result, JSON.parse(b64Decode(serializedData)));
result.text = serializedContent.slice(0, extractedData.index);
} catch (e) {
// Ignore
}
}
return result;
},
};

View File

@ -2,18 +2,22 @@ import localDbSvc from './localDbSvc';
import store from '../store'; 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 diffUtils from './diffUtils';
import userActivitySvc from './userActivitySvc'; import userActivitySvc from './userActivitySvc';
import gdriveAppDataProvider from './providers/gdriveAppDataProvider'; import gdriveAppDataProvider from './providers/gdriveAppDataProvider';
import googleHelper from './helpers/googleHelper';
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 restartSyncAfter = 30 * 1000; // 30 sec const restartSyncAfter = 30 * 1000; // 30 sec
const autoSyncAfter = utils.randomize(restartSyncAfter); const autoSyncAfter = utils.randomize(60 * 1000); // 60 sec
const isSyncAvailable = () => window.navigator.onLine !== false &&
!!store.getters['data/loginToken']; const isDataSyncPossible = () => !!store.getters['data/loginToken'];
const hasCurrentFileSyncLocations = () => !!store.getters['syncLocation/current'].length;
const isSyncPossible = () => !store.state.offline &&
(isDataSyncPossible() || hasCurrentFileSyncLocations());
function isSyncWindow() { function isSyncWindow() {
const storedLastSyncActivity = getStoredLastSyncActivity(); const storedLastSyncActivity = getStoredLastSyncActivity();
@ -48,13 +52,31 @@ function getSyncToken(syncLocation) {
} }
} }
function cleanSyncedContent(syncedContent) {
// Clean syncHistory from removed syncLocations
Object.keys(syncedContent.syncHistory).forEach((syncLocationId) => {
if (syncLocationId !== 'main' && !store.state.syncLocation.itemMap[syncLocationId]) {
delete syncedContent.syncHistory[syncLocationId];
}
});
const allSyncLocationHashes = new Set([].concat(
...Object.keys(syncedContent.syncHistory).map(
id => syncedContent.syncHistory[id])));
// Clean historyData from unused contents
Object.keys(syncedContent.historyData).map(hash => parseInt(hash, 10)).forEach((hash) => {
if (!allSyncLocationHashes.has(hash)) {
delete syncedContent.historyData[hash];
}
});
}
const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`) const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`)
// Item does not exist, create it // Item does not exist, create it
.catch(() => store.commit(`${type}/setItem`, { .catch(() => store.commit(`${type}/setItem`, {
id: `${fileId}/${type}`, id: `${fileId}/${type}`,
})); }));
const loadContent = loader('content'); const loadContent = loader('content');
const loadSyncContent = loader('syncContent'); const loadSyncedContent = loader('syncedContent');
const loadContentState = loader('contentState'); const loadContentState = loader('contentState');
function applyChanges(changes) { function applyChanges(changes) {
@ -73,9 +95,9 @@ function applyChanges(changes) {
} }
delete syncData[change.fileId]; delete syncData[change.fileId];
syncDataChanged = true; syncDataChanged = true;
} else if (!change.removed && change.item && change.item.updated) { } else if (!change.removed && change.item && change.item.hash) {
if (!existingSyncData || (existingSyncData.updated !== change.item.updated && ( if (!existingSyncData || (existingSyncData.hash !== change.item.hash && (
!existingItem || existingItem.updated !== change.item.updated !existingItem || existingItem.hash !== change.item.hash
))) { ))) {
// Put object in the store // Put object in the store
if (change.item.type !== 'content') { // Merge contents later if (change.item.type !== 'content') { // Merge contents later
@ -93,33 +115,184 @@ function applyChanges(changes) {
} }
} }
const LAST_SENT = 0;
const LAST_MERGED = 1;
function syncFile(fileId) {
return loadSyncedContent(fileId)
.then(() => loadContent(fileId))
.then(() => {
const getContent = () => store.state.content.itemMap[`${fileId}/content`];
const getSyncedContent = () => store.state.syncedContent.itemMap[`${fileId}/syncedContent`];
const getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId];
const downloadedLocations = {};
const isLocationSynced = syncLocation =>
getSyncHistoryItem(syncLocation.id)[LAST_SENT] === getContent().hash;
const syncOneContentLocation = () => {
const syncLocations = [
...store.getters['syncLocation/groupedByFileId'][fileId] || [],
];
if (isDataSyncPossible()) {
syncLocations.push({ id: 'main', provider: 'gdriveAppData', fileId });
}
let result;
syncLocations.some((syncLocation) => {
if (!downloadedLocations[syncLocation.id] || !isLocationSynced(syncLocation)) {
const provider = getSyncProvider(syncLocation);
const token = getSyncToken(syncLocation);
result = provider && token && provider.downloadContent(token, syncLocation)
.then((serverContent = null) => {
downloadedLocations[syncLocation.id] = true;
const syncedContent = getSyncedContent();
const syncHistoryItem = getSyncHistoryItem(syncLocation.id);
let mergedContent = (() => {
const clientContent = utils.deepCopy(getContent());
if (!serverContent) {
// Sync location has not been created yet
return clientContent;
}
if (serverContent.hash === clientContent.hash) {
// Server and client contents are synced
return clientContent;
}
if (syncedContent.historyData[serverContent.hash]) {
// Server content has not changed or has already been merged
return clientContent;
}
// Perform a merge with last merged content if any, or a simple fusion otherwise
let lastMergedContent;
serverContent.history.some((hash) => {
lastMergedContent = syncedContent.historyData[hash];
return lastMergedContent;
});
if (!lastMergedContent && syncHistoryItem) {
lastMergedContent = syncedContent.historyData[syncHistoryItem[LAST_MERGED]];
}
return diffUtils.mergeContent(serverContent, clientContent, lastMergedContent);
})();
// Update content in store
store.commit('content/patchItem', {
id: `${fileId}/content`,
...mergedContent,
});
// Retrieve content with new `hash` value and freeze it
mergedContent = utils.deepCopy(getContent());
// Make merged content history
const mergedContentHistory = serverContent ? serverContent.history.slice() : [];
let skipUpload = true;
if (mergedContentHistory[0] !== mergedContent.hash) {
// Put merged content hash at the beginning of history
mergedContentHistory.unshift(mergedContent.hash);
// Server content is either out of sync or its history is incomplete, do upload
skipUpload = false;
}
if (syncHistoryItem && syncHistoryItem[0] !== mergedContent.hash) {
// Clean up by removing the hash we've previously added
const idx = mergedContentHistory.indexOf(syncHistoryItem[LAST_SENT]);
if (idx !== -1) {
mergedContentHistory.splice(idx, 1);
}
}
// Store server content if any, and merged content which will be sent if different
const newSyncedContent = utils.deepCopy(syncedContent);
const newSyncHistoryItem = newSyncedContent.syncHistory[syncLocation.id] || [];
newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem;
if (serverContent && (serverContent.hash === newSyncHistoryItem[LAST_SENT] ||
serverContent.history.indexOf(newSyncHistoryItem[LAST_SENT]) !== -1)
) {
// The server has accepted the content we previously sent
newSyncHistoryItem[LAST_MERGED] = newSyncHistoryItem[LAST_SENT];
}
newSyncHistoryItem[LAST_SENT] = mergedContent.hash;
newSyncedContent.historyData[mergedContent.hash] = mergedContent;
// Clean synced content from unused revisions
cleanSyncedContent(newSyncedContent);
// Store synced content
store.commit('syncedContent/patchItem', newSyncedContent);
if (skipUpload) {
// Server content and merged content are equal, skip content upload
return null;
}
// Prevent from sending new content too long after old content has been fetched
const syncStartTime = Date.now();
const ifNotTooLate = cb => (res) => {
// No time to refresh a token...
if (syncStartTime + 500 < Date.now()) {
throw new Error('TOO_LATE');
}
return cb(res);
};
// Upload merged content
return provider.uploadContent(token, {
...mergedContent,
history: mergedContentHistory,
}, syncLocation, ifNotTooLate);
})
.then(() => syncOneContentLocation());
}
return result;
});
return result;
};
return syncOneContentLocation();
})
.then(() => localDbSvc.unloadContents(), (err) => {
localDbSvc.unloadContents();
throw err;
})
.catch((err) => {
if (err && err.message === 'TOO_LATE') {
// Restart sync
return syncFile(fileId);
}
throw err;
});
}
function sync() { function sync() {
const googleToken = store.getters['data/loginToken']; const googleToken = store.getters['data/loginToken'];
return googleHelper.getChanges(googleToken) return gdriveAppDataProvider.getChanges(googleToken)
.then((changes) => { .then((changes) => {
// Apply changes // Apply changes
applyChanges(changes); applyChanges(changes);
googleHelper.updateNextPageToken(googleToken, changes); gdriveAppDataProvider.setAppliedChanges(googleToken, changes);
// Prevent from sending items too long after changes have been retrieved // Prevent from sending items too long after changes have been retrieved
const syncStartTime = Date.now(); const syncStartTime = Date.now();
const ifNotTooLate = cb => (res) => { const ifNotTooLate = cb => (res) => {
if (syncStartTime + restartSyncAfter < Date.now()) { if (syncStartTime + restartSyncAfter < Date.now()) {
throw new Error('too_late'); throw new Error('TOO_LATE');
} }
return cb(res); return cb(res);
}; };
// Called until no item to save // Called until no item to save
const saveNextItem = ifNotTooLate(() => { const saveNextItem = ifNotTooLate(() => {
const storeItemMap = store.getters.syncedItemMap; const storeItemMap = {
...store.state.file.itemMap,
...store.state.folder.itemMap,
...store.state.syncLocation.itemMap,
// Deal with contents later
};
const syncDataByItemId = store.getters['data/syncDataByItemId']; const syncDataByItemId = store.getters['data/syncDataByItemId'];
let result; let result;
Object.keys(storeItemMap).some((id) => { Object.keys(storeItemMap).some((id) => {
const item = storeItemMap[id]; const item = storeItemMap[id];
const existingSyncData = syncDataByItemId[id]; const existingSyncData = syncDataByItemId[id];
if (!existingSyncData || existingSyncData.updated !== item.updated) { if (!existingSyncData || existingSyncData.hash !== item.hash) {
result = googleHelper.saveItem( result = gdriveAppDataProvider.saveItem(
googleToken, googleToken,
// Use deepCopy to freeze objects // Use deepCopy to freeze objects
utils.deepCopy(item), utils.deepCopy(item),
@ -138,15 +311,23 @@ function sync() {
// Called until no item to remove // Called until no item to remove
const removeNextItem = ifNotTooLate(() => { const removeNextItem = ifNotTooLate(() => {
const storeItemMap = store.getters.syncedItemMap; const storeItemMap = {
...store.state.file.itemMap,
...store.state.folder.itemMap,
...store.state.syncLocation.itemMap,
...store.state.content.itemMap, // Deal with contents now
};
const syncData = store.getters['data/syncData']; const syncData = store.getters['data/syncData'];
let result; let result;
Object.keys(syncData).some((id) => { Object.keys(syncData).some((id) => {
const existingSyncData = syncData[id]; const existingSyncData = syncData[id];
if (!storeItemMap[existingSyncData.itemId]) { if (!storeItemMap[existingSyncData.itemId] &&
// Remove content only if file has been removed
(existingSyncData.type !== 'content' || !storeItemMap[existingSyncData.itemId.split('/')[0]])
) {
// Use deepCopy to freeze objects // Use deepCopy to freeze objects
const syncDataToRemove = utils.deepCopy(existingSyncData); const syncDataToRemove = utils.deepCopy(existingSyncData);
result = googleHelper.removeItem(googleToken, syncDataToRemove, ifNotTooLate) result = gdriveAppDataProvider.removeItem(googleToken, syncDataToRemove, ifNotTooLate)
.then(() => { .then(() => {
const syncDataCopy = { ...store.getters['data/syncData'] }; const syncDataCopy = { ...store.getters['data/syncData'] };
delete syncDataCopy[syncDataToRemove.id]; delete syncDataCopy[syncDataToRemove.id];
@ -159,98 +340,45 @@ function sync() {
return result; return result;
}); });
// Get content `updated` field from itemMap or from localDbSvc if not loaded const getOneFileIdToSync = () => {
const getContentUpdated = (contentId) => { const allContentIds = Object.keys({
const loadedContent = store.state.content.itemMap[contentId]; ...store.state.content.itemMap,
return loadedContent ? loadedContent.updated : localDbSvc.updatedMap.content[contentId]; ...store.getters['data/syncDataByType'].content,
});
let fileId;
allContentIds.some((contentId) => {
// Get content hash from itemMap or from localDbSvc if not loaded
const loadedContent = store.state.content.itemMap[contentId];
const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId];
const syncData = store.getters['data/syncDataByItemId'][contentId];
// Sync if item hash and syncData hash are different
if (!hash || !syncData || hash !== syncData.hash) {
[fileId] = contentId.split('/');
}
return fileId;
});
return fileId;
}; };
// Download current file content and contents that have changed const syncNextFile = () => {
const forceContentIds = { [`${store.getters['file/current'].id}/content`]: true }; const fileId = getOneFileIdToSync();
store.getters['file/items'].forEach((file) => { return fileId && syncFile(fileId)
const contentId = `${file.id}/content`; .then(() => syncNextFile());
const updated = getContentUpdated(contentId); };
const existingSyncData = store.getters['data/syncDataByItemId'][contentId];
});
const syncOneContent = fileId => loadSyncContent(fileId)
.then(() => loadContent(fileId))
.then(() => {
const getContent = () => store.state.content.itemMap[`${fileId}/content`];
const getSyncContent = () => store.state.content.itemMap[`${fileId}/syncContent`];
const syncLocations = [
{ id: 'main', provider: 'gdriveAppData' },
...getContent().syncLocations.filter(syncLocation => getSyncToken(syncLocation),
)];
const downloadedLocations = {};
const syncOneContentLocation = () => {
let result;
syncLocations.some((syncLocation) => {
if (!downloadedLocations[syncLocation.id]) {
const provider = getSyncProvider(syncLocation);
const token = getSyncToken(syncLocation);
result = provider && token && provider.downloadContent(fileId, syncLocation)
.then((content) => {
const syncContent = getSyncContent();
const syncLocationData = syncContent.syncLocationData[syncLocation.id] || {
history: [],
};
let lastMergedContent;
syncLocationData.history.some((updated) => {
if (content.history.indexOf(updated) !== -1) {
}
return lastMergedContent;
});
})
.then(() => syncOneContentLocation());
}
return result;
});
return result;
};
return syncOneContentLocation();
})
.then(() => localDbSvc.unloadContents(), (err) => {
localDbSvc.unloadContents();
throw err;
});
// 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.loadItem(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() return Promise.resolve()
.then(() => saveNextItem()) .then(() => saveNextItem())
.then(() => removeNextItem()) .then(() => removeNextItem())
.then(() => {
if (store.getters['content/current'].id) {
// Sync current file first
return syncFile(store.getters['file/current'].id)
.then(() => syncNextFile());
}
return syncNextFile();
})
.catch((err) => { .catch((err) => {
if (err && err.message === 'too_late') { if (err && err.message === 'TOO_LATE') {
// Restart sync // Restart sync
return sync(); return sync();
} }
@ -266,19 +394,30 @@ function requestSync() {
// Only start syncing when these conditions are met // Only start syncing when these conditions are met
if (userActivitySvc.isActive() && isSyncWindow()) { if (userActivitySvc.isActive() && isSyncWindow()) {
clearInterval(intervalId); clearInterval(intervalId);
if (!isSyncAvailable()) { if (!isSyncPossible()) {
// Cancel sync // Cancel sync
reject(); reject();
} else { return;
// Call setLastSyncActivity periodically
intervalId = utils.setInterval(() => setLastSyncActivity(), 1000);
setLastSyncActivity();
const cleaner = cb => (res) => {
clearInterval(intervalId);
cb(res);
};
sync().then(cleaner(resolve), cleaner(reject));
} }
// Call setLastSyncActivity periodically
intervalId = utils.setInterval(() => setLastSyncActivity(), 1000);
setLastSyncActivity();
const cleaner = cb => (res) => {
clearInterval(intervalId);
cb(res);
};
Promise.resolve()
.then(() => {
if (isDataSyncPossible()) {
return sync();
}
if (hasCurrentFileSyncLocations()) {
return syncFile(store.getters['file/current'].id);
}
return null;
})
.then(cleaner(resolve), cleaner(reject));
} }
}; };
intervalId = utils.setInterval(() => attempt(), 1000); intervalId = utils.setInterval(() => attempt(), 1000);
@ -288,7 +427,7 @@ function requestSync() {
// Sync periodically // Sync periodically
utils.setInterval(() => { utils.setInterval(() => {
if (isSyncAvailable() && if (isSyncPossible() &&
userActivitySvc.isActive() && userActivitySvc.isActive() &&
isSyncWindow() && isSyncWindow() &&
isAutoSyncReady() isAutoSyncReady()
@ -337,8 +476,8 @@ localDbSvc.sync()
return Promise.resolve() return Promise.resolve()
// Load contentState from DB // Load contentState from DB
.then(() => loadContentState(currentFile.id)) .then(() => loadContentState(currentFile.id))
// Load syncContent from DB // Load syncedContent from DB
.then(() => loadSyncContent(currentFile.id)) .then(() => loadSyncedContent(currentFile.id))
// Load content from DB // Load content from DB
.then(() => localDbSvc.loadItem(`${currentFile.id}/content`)); .then(() => localDbSvc.loadItem(`${currentFile.id}/content`));
}), }),
@ -358,6 +497,6 @@ utils.setInterval(() => {
}, 5000); }, 5000);
export default { export default {
isSyncAvailable, isSyncPossible,
requestSync, requestSync,
}; };

View File

@ -14,14 +14,36 @@ 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'], types: ['contentState', 'syncedContent', 'content', 'file', 'folder', 'syncLocation', 'data'],
deepCopy(obj) { deepCopy(obj) {
return obj === undefined ? obj : JSON.parse(JSON.stringify(obj)); return obj == null ? obj : JSON.parse(JSON.stringify(obj));
},
serializeObject(obj) {
return obj === undefined ? obj : JSON.stringify(obj, (key, value) => {
if (Object.prototype.toString.call(value) !== '[object Object]') {
return value;
}
// Sort keys to have a predictable result
return Object.keys(value).sort().reduce((sorted, valueKey) => {
sorted[valueKey] = value[valueKey];
return sorted;
}, {});
});
}, },
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('');
}, },
hash(str) {
let hash = 0;
if (!str) return hash;
for (let i = 0; i < str.length; i += 1) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char; // eslint-disable-line no-bitwise
hash |= 0; // eslint-disable-line no-bitwise
}
return hash;
},
randomize(value) { randomize(value) {
return Math.floor((1 + (Math.random() * 0.2)) * value); return Math.floor((1 + (Math.random() * 0.2)) * value);
}, },
@ -73,6 +95,7 @@ export default {
oauth2Context.iframeElt.onload = () => { oauth2Context.iframeElt.onload = () => {
oauth2Context.closeTimeout = setTimeout(() => oauth2Context.clean(), 5 * 1000); oauth2Context.closeTimeout = setTimeout(() => oauth2Context.clean(), 5 * 1000);
}; };
oauth2Context.iframeElt.onerror = () => oauth2Context.clean();
oauth2Context.iframeElt.src = authorizeUrl; oauth2Context.iframeElt.src = authorizeUrl;
document.body.appendChild(oauth2Context.iframeElt); document.body.appendChild(oauth2Context.iframeElt);
oauth2Context.wnd = oauth2Context.iframeElt.contentWindow; oauth2Context.wnd = oauth2Context.iframeElt.contentWindow;
@ -96,7 +119,9 @@ 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));
} }
@ -130,9 +155,11 @@ export default {
let retryAfter = 500; // 500 ms let retryAfter = 500; // 500 ms
const maxRetryAfter = 30 * 1000; // 30 sec const maxRetryAfter = 30 * 1000; // 30 sec
const config = Object.assign({}, configParam); const config = Object.assign({}, configParam);
config.headers = Object.assign({ config.headers = Object.assign({}, config.headers);
'Content-Type': 'application/json', if (config.body && typeof config.body === 'object') {
}, config.headers); config.body = JSON.stringify(config.body);
config.headers['Content-Type'] = 'application/json';
}
function parseHeaders(xhr) { function parseHeaders(xhr) {
const pairs = xhr.getAllResponseHeaders().trim().split('\n'); const pairs = xhr.getAllResponseHeaders().trim().split('\n');
@ -174,10 +201,12 @@ export default {
headers: parseHeaders(xhr), headers: parseHeaders(xhr),
body: xhr.responseText, body: xhr.responseText,
}; };
try { if (!config.raw) {
result.body = JSON.parse(result.body); try {
} catch (e) { result.body = JSON.parse(result.body);
// ignore } catch (e) {
// ignore
}
} }
if (result.status >= 200 && result.status < 300) { if (result.status >= 200 && result.status < 300) {
resolve(result); resolve(result);
@ -202,7 +231,7 @@ export default {
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 || null);
}) })
.catch((err) => { .catch((err) => {
// Try again later in case of retriable error // Try again later in case of retriable error

View File

@ -3,10 +3,11 @@ import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import utils from '../services/utils'; import utils from '../services/utils';
import contentState from './modules/contentState'; import contentState from './modules/contentState';
import syncContent from './modules/syncContent'; import syncedContent from './modules/syncedContent';
import content from './modules/content'; import content from './modules/content';
import file from './modules/file'; import file from './modules/file';
import folder from './modules/folder'; import folder from './modules/folder';
import syncLocation from './modules/syncLocation';
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';
@ -18,9 +19,10 @@ Vue.use(Vuex);
const debug = process.env.NODE_ENV !== 'production'; const debug = process.env.NODE_ENV !== 'production';
export default new Vuex.Store({ const store = new Vuex.Store({
state: { state: {
ready: false, ready: false,
offline: false,
}, },
getters: { getters: {
allItemMap: (state) => { allItemMap: (state) => {
@ -28,23 +30,22 @@ export default new Vuex.Store({
utils.types.forEach(type => Object.assign(result, state[type].itemMap)); utils.types.forEach(type => Object.assign(result, state[type].itemMap));
return result; 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;
}, },
setOffline: (state, value) => {
state.offline = value;
},
}, },
modules: { modules: {
contentState, contentState,
syncContent, syncedContent,
content, content,
file, file,
folder, folder,
syncLocation,
data, data,
layout, layout,
editor, editor,
@ -55,3 +56,15 @@ export default new Vuex.Store({
strict: debug, strict: debug,
plugins: debug ? [createLogger()] : [], plugins: debug ? [createLogger()] : [],
}); });
function checkOffline() {
const isOffline = window.navigator.onLine === false;
if (isOffline !== store.state.offline) {
store.commit('setOffline', isOffline);
}
}
utils.setInterval(checkOffline, 1000);
window.addEventListener('online', checkOffline);
window.addEventListener('offline', checkOffline);
export default store;

View File

@ -1,7 +1,7 @@
import moduleTemplate from './moduleTemplate'; import moduleTemplate from './moduleTemplate';
import empty from '../../data/emptyContentState'; import empty from '../../data/emptyContentState';
const module = moduleTemplate(empty); const module = moduleTemplate(empty, true);
module.getters = { module.getters = {
...module.getters, ...module.getters,

View File

@ -1,7 +1,8 @@
import moduleTemplate from './moduleTemplate'; import moduleTemplate from './moduleTemplate';
import utils from '../../services/utils';
import defaultLocalSettings from '../../data/defaultLocalSettings'; import defaultLocalSettings from '../../data/defaultLocalSettings';
const itemTemplate = (id, data = {}) => ({ id, type: 'data', data, updated: 0 }); const itemTemplate = (id, data = {}) => ({ id, type: 'data', data, hash: 0 });
const empty = (id) => { const empty = (id) => {
switch (id) { switch (id) {
@ -11,7 +12,7 @@ const empty = (id) => {
return itemTemplate(id); return itemTemplate(id);
} }
}; };
const module = moduleTemplate(empty); const module = moduleTemplate(empty, true);
const getter = id => state => (state.itemMap[id] || empty(id)).data; const getter = id => state => (state.itemMap[id] || empty(id)).data;
const setter = id => ({ commit }, data) => commit('setItem', itemTemplate(id, data)); const setter = id => ({ commit }, data) => commit('setItem', itemTemplate(id, data));
@ -73,6 +74,20 @@ module.getters.syncDataByItemId = (state, getters) => {
}); });
return result; return result;
}; };
module.getters.syncDataByType = (state, getters) => {
const result = {};
utils.types.forEach((type) => {
result[type] = {};
});
const syncData = getters.syncData;
Object.keys(syncData).forEach((id) => {
const item = syncData[id];
if (result[item.type]) {
result[item.type][item.itemId] = item;
}
});
return result;
};
module.actions.patchSyncData = patcher('syncData'); module.actions.patchSyncData = patcher('syncData');
module.actions.setSyncData = setter('syncData'); module.actions.setSyncData = setter('syncData');

View File

@ -1,10 +1,17 @@
import Vue from 'vue'; import Vue from 'vue';
import utils from '../../services/utils';
export default (empty, simpleHash = false) => {
// Use Date.now as a simple hash function, which is ok for not-synced types
const hashFunc = simpleHash ? Date.now : item => utils.hash(utils.serializeObject({
...item,
hash: undefined,
}));
export default (empty) => {
function setItem(state, value) { function setItem(state, value) {
const item = Object.assign(empty(value.id), value); const item = Object.assign(empty(value.id), value);
if (!item.updated) { if (!item.hash) {
item.updated = Date.now(); item.hash = hashFunc(item);
} }
Vue.set(state.itemMap, item.id, item); Vue.set(state.itemMap, item.id, item);
} }
@ -13,7 +20,7 @@ export default (empty) => {
const item = state.itemMap[patch.id]; const item = state.itemMap[patch.id];
if (item) { if (item) {
Object.assign(item, patch); Object.assign(item, patch);
item.updated = Date.now(); // Trigger sync item.hash = hashFunc(item);
Vue.set(state.itemMap, item.id, item); Vue.set(state.itemMap, item.id, item);
return true; return true;
} }

View File

@ -1,12 +0,0 @@
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;

View File

@ -0,0 +1,21 @@
import moduleTemplate from './moduleTemplate';
import empty from '../../data/emptySyncLocation';
const module = moduleTemplate(empty);
module.getters = {
...module.getters,
groupedByFileId: (state, getters) => {
const result = {};
getters.items.forEach((item) => {
const list = result[item.fileId] || [];
list.push(item);
result[item.fileId] = list;
});
return result;
},
current: (state, getters, rootState, rootGetters) =>
getters.groupedByFileId[rootGetters['file/current'].id] || [],
};
export default module;

View File

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