Content sync

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

View File

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

View File

@ -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",

View File

@ -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;

View File

@ -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);

View File

@ -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);
}
}
},

View File

@ -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;

View File

@ -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 });

View File

@ -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">

View File

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

View File

@ -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,
});

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import DiffMatchPatch from 'diff-match-patch';
import 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);
},
};

View File

@ -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;

View File

@ -31,75 +31,17 @@ const request = (googleToken, options) => utils.request({
},
});
const saveFile = (googleToken, data, appData) => {
const options = {
method: 'POST',
url: 'https://www.googleapis.com/upload/drive/v2/files',
headers: {},
};
if (appData) {
options.method = 'PUT';
options.url = `https://www.googleapis.com/drive/v2/files/${appData.id}`;
options.headers['if-match'] = appData.etag;
}
const metadata = {
title: data.name,
parents: [{
id: 'appDataFolder',
}],
properties: Object.keys(data)
.filter(key => key !== 'name' && key !== 'tx')
.map(key => ({
key,
value: JSON.stringify(data[key]),
visibility: 'PUBLIC',
})),
};
const media = null;
const boundary = `-------${utils.uid()}`;
const delimiter = `\r\n--${boundary}\r\n`;
const closeDelimiter = `\r\n--${boundary}--`;
if (media) {
let multipartRequestBody = '';
multipartRequestBody += delimiter;
multipartRequestBody += 'Content-Type: application/json\r\n\r\n';
multipartRequestBody += JSON.stringify(metadata);
multipartRequestBody += delimiter;
multipartRequestBody += 'Content-Type: application/json\r\n\r\n';
multipartRequestBody += JSON.stringify(media);
multipartRequestBody += closeDelimiter;
return request(googleToken, {
...options,
params: {
uploadType: 'multipart',
},
headers: {
...options.headers,
'Content-Type': `multipart/mixed; boundary="${boundary}"`,
},
body: multipartRequestBody,
});
}
return request(googleToken, {
...options,
body: metadata,
}).then(res => ({
id: res.body.id,
etag: res.body.etag,
}));
};
export default {
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));
},
};

View File

@ -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());
});
},
};

View File

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

View File

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

View File

@ -3,13 +3,17 @@ import store from '../store';
import welcomeFile from '../data/welcomeFile.md';
import 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,
}));

View File

@ -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();
},

View File

@ -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;

View File

@ -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 = {

View File

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

View File

@ -1,63 +1,99 @@
import moduleTemplate from './moduleTemplate';
import 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;

View File

@ -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);

View File

@ -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 = {

View File

@ -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;
}));
}
},
},

View File

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