+
Custom settings
-
@@ -27,9 +27,9 @@
diff --git a/src/components/TemplatesModal.vue b/src/components/modals/TemplatesModal.vue
similarity index 83%
rename from src/components/TemplatesModal.vue
rename to src/components/modals/TemplatesModal.vue
index b5a884eb..73f7d2be 100644
--- a/src/components/TemplatesModal.vue
+++ b/src/components/modals/TemplatesModal.vue
@@ -51,11 +51,13 @@
+
+
diff --git a/src/icons/Stackedit.vue b/src/icons/Stackedit.vue
deleted file mode 100644
index dc8b5300..00000000
--- a/src/icons/Stackedit.vue
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
diff --git a/src/icons/index.js b/src/icons/index.js
index ad88ec0f..1f82e3ee 100644
--- a/src/icons/index.js
+++ b/src/icons/index.js
@@ -20,7 +20,7 @@ import FileMultiple from './FileMultiple';
import FolderPlus from './FolderPlus';
import Delete from './Delete';
import Close from './Close';
-import FolderMultiple from './FolderMultiple';
+import FolderOpen from './FolderOpen';
import Pen from './Pen';
import Target from './Target';
import ArrowLeft from './ArrowLeft';
@@ -36,10 +36,11 @@ import HardDisk from './HardDisk';
import Download from './Download';
import CodeTags from './CodeTags';
import CodeBraces from './CodeBraces';
+import OpenInNew from './OpenInNew';
+import Information from './Information';
+import Alert from './Alert';
// Providers
-import Stackedit from './Stackedit';
-import GoogleDrive from './GoogleDrive';
-import GooglePhotos from './GooglePhotos';
+import Provider from './Provider';
Vue.component('iconFormatBold', FormatBold);
Vue.component('iconFormatItalic', FormatItalic);
@@ -62,7 +63,7 @@ Vue.component('iconFileMultiple', FileMultiple);
Vue.component('iconFolderPlus', FolderPlus);
Vue.component('iconDelete', Delete);
Vue.component('iconClose', Close);
-Vue.component('iconFolderMultiple', FolderMultiple);
+Vue.component('iconFolderOpen', FolderOpen);
Vue.component('iconPen', Pen);
Vue.component('iconTarget', Target);
Vue.component('iconArrowLeft', ArrowLeft);
@@ -78,7 +79,8 @@ Vue.component('iconHardDisk', HardDisk);
Vue.component('iconDownload', Download);
Vue.component('iconCodeTags', CodeTags);
Vue.component('iconCodeBraces', CodeBraces);
+Vue.component('iconOpenInNew', OpenInNew);
+Vue.component('iconInformation', Information);
+Vue.component('iconAlert', Alert);
// Providers
-Vue.component('iconStackedit', Stackedit);
-Vue.component('iconGoogleDrive', GoogleDrive);
-Vue.component('iconGooglePhotos', GooglePhotos);
+Vue.component('iconProvider', Provider);
diff --git a/src/services/editorSvc.js b/src/services/editorSvc.js
index a5666980..bcfe7b7b 100644
--- a/src/services/editorSvc.js
+++ b/src/services/editorSvc.js
@@ -356,7 +356,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
if (sectionDesc) {
const scrollTop = sectionDesc[objectToScroll.dimensionKey].startOffset +
(sectionDesc[objectToScroll.dimensionKey].height * scrollPosition.posInSection);
- objectToScroll.elt.scrollTop = scrollTop;
+ objectToScroll.elt.scrollTop = Math.floor(scrollTop);
}
}
},
@@ -468,6 +468,7 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
});
this.editorElt.parentNode.addEventListener('scroll', () => this.saveContentState(true));
+ this.previewElt.parentNode.addEventListener('scroll', () => this.saveContentState(true));
const refreshPreview = () => {
this.convert();
@@ -594,12 +595,6 @@ const editorSvc = Object.assign(new Vue(), { // Use a vue instance as an event b
this.$emit('inited');
- // scope.$watch('editorLayoutSvc.currentControl', function (currentControl) {
- // !currentControl && setTimeout(function () {
- // !scope.isDialogOpen && clEditorSvc.cledit && clEditorSvc.cledit.focus()
- // }, 1)
- // })
-
// clEditorSvc.setPreviewElt(element[0].querySelector('.preview__inner-2'))
// var previewElt = element[0].querySelector('.preview')
// clEditorSvc.isPreviewTop = previewElt.scrollTop < 10
diff --git a/src/services/localDbSvc.js b/src/services/localDbSvc.js
index f10c426e..5840d373 100644
--- a/src/services/localDbSvc.js
+++ b/src/services/localDbSvc.js
@@ -4,8 +4,8 @@ import utils from './utils';
import store from '../store';
const indexedDB = window.indexedDB;
-const localStorage = window.localStorage;
const dbVersion = 1;
+const dbVersionKey = `${utils.workspaceId}/localDbVersion`;
const dbStoreName = 'objects';
if (!indexedDB) {
@@ -27,7 +27,7 @@ class Connection {
request.onsuccess = (event) => {
this.db = event.target.result;
- localStorage.localDbVersion = this.db.version; // Safari does not support onversionchange
+ localStorage[dbVersionKey] = this.db.version; // Safari does not support onversionchange
this.db.onversionchange = () => window.location.reload();
this.getTxCbs.forEach(({ onTx, onError }) => this.createTx(onTx, onError));
@@ -68,7 +68,7 @@ class Connection {
}
// If DB version has changed (Safari support)
- if (parseInt(localStorage.localDbVersion, 10) !== this.db.version) {
+ if (parseInt(localStorage[dbVersionKey], 10) !== this.db.version) {
return window.location.reload();
}
@@ -292,8 +292,8 @@ export default {
request.onsuccess = resolve;
})
.then(() => {
- localStorage.removeItem('localDbVersion');
+ localStorage.removeItem(dbVersionKey);
window.location.reload();
- }, () => console.error('Could not delete local database.'));
+ }, () => store.dispatch('notification/error', 'Could not delete local database.'));
},
};
diff --git a/src/services/optional/scrollSync.js b/src/services/optional/scrollSync.js
index 41083078..7eb4649b 100644
--- a/src/services/optional/scrollSync.js
+++ b/src/services/optional/scrollSync.js
@@ -167,9 +167,9 @@ editorSvc.$on('previewText', () => {
store.watch(
() => store.getters['layout/styles'],
- () => {
- isScrollEditor = true;
- isScrollPreview = false;
+ (styles) => {
+ isScrollEditor = styles.showEditor;
+ isScrollPreview = !styles.showEditor;
skipAnimation = true;
});
diff --git a/src/services/optional/shortcuts.js b/src/services/optional/shortcuts.js
index d8de54e3..219d7f78 100644
--- a/src/services/optional/shortcuts.js
+++ b/src/services/optional/shortcuts.js
@@ -4,7 +4,7 @@ import editorSvc from '../../services/editorSvc';
import syncSvc from '../../services/syncSvc';
// Skip shortcuts if modal is open or editor is hidden
-Mousetrap.prototype.stopCallback = () => store.state.modal.config || !store.getters['layout/styles'].showEditor;
+Mousetrap.prototype.stopCallback = () => store.getters['modal/config'] || !store.getters['layout/styles'].showEditor;
const pagedownHandler = name => () => editorSvc.pagedownEditor.uiManager.doClick(name);
diff --git a/src/services/providers/bloggerPageProvider.js b/src/services/providers/bloggerPageProvider.js
new file mode 100644
index 00000000..83231525
--- /dev/null
+++ b/src/services/providers/bloggerPageProvider.js
@@ -0,0 +1,48 @@
+import store from '../../store';
+import googleHelper from './helpers/googleHelper';
+import providerRegistry from './providerRegistry';
+
+export default providerRegistry.register({
+ id: 'bloggerPage',
+ getToken(location) {
+ const token = store.getters['data/googleTokens'][location.sub];
+ return token && token.isBlogger ? token : null;
+ },
+ getUrl(location) {
+ return `https://www.blogger.com/blogger.g?blogID=${location.blogId}#editor/target=page;pageID=${location.pageId}`;
+ },
+ getDescription(location) {
+ const token = this.getToken(location);
+ return `${location.pageId} — ${location.blogUrl} — ${token.name}`;
+ },
+ publish(token, html, metadata, publishLocation) {
+ return googleHelper.uploadBlogger(
+ token,
+ publishLocation.blogUrl,
+ publishLocation.blogId,
+ publishLocation.pageId,
+ metadata.title,
+ html,
+ null,
+ null,
+ null,
+ true,
+ )
+ .then(page => ({
+ ...publishLocation,
+ blogId: page.blog.id,
+ pageId: page.id,
+ }));
+ },
+ makeLocation(token, blogUrl, pageId) {
+ const location = {
+ providerId: this.id,
+ sub: token.sub,
+ blogUrl,
+ };
+ if (pageId) {
+ location.pageId = pageId;
+ }
+ return location;
+ },
+});
diff --git a/src/services/providers/bloggerProvider.js b/src/services/providers/bloggerProvider.js
new file mode 100644
index 00000000..f43fc1bb
--- /dev/null
+++ b/src/services/providers/bloggerProvider.js
@@ -0,0 +1,47 @@
+import store from '../../store';
+import googleHelper from './helpers/googleHelper';
+import providerRegistry from './providerRegistry';
+
+export default providerRegistry.register({
+ id: 'blogger',
+ getToken(location) {
+ const token = store.getters['data/googleTokens'][location.sub];
+ return token && token.isBlogger ? token : null;
+ },
+ getUrl(location) {
+ return `https://www.blogger.com/blogger.g?blogID=${location.blogId}#editor/target=post;postID=${location.postId}`;
+ },
+ getDescription(location) {
+ const token = this.getToken(location);
+ return `${location.postId} — ${location.blogUrl} — ${token.name}`;
+ },
+ publish(token, html, metadata, publishLocation) {
+ return googleHelper.uploadBlogger(
+ token,
+ publishLocation.blogUrl,
+ publishLocation.blogId,
+ publishLocation.postId,
+ metadata.title,
+ html,
+ metadata.tags,
+ metadata.status === 'draft',
+ metadata.date,
+ )
+ .then(post => ({
+ ...publishLocation,
+ blogId: post.blog.id,
+ postId: post.id,
+ }));
+ },
+ makeLocation(token, blogUrl, postId) {
+ const location = {
+ providerId: this.id,
+ sub: token.sub,
+ blogUrl,
+ };
+ if (postId) {
+ location.postId = postId;
+ }
+ return location;
+ },
+});
diff --git a/src/services/providers/dropboxProvider.js b/src/services/providers/dropboxProvider.js
new file mode 100644
index 00000000..0eceae2e
--- /dev/null
+++ b/src/services/providers/dropboxProvider.js
@@ -0,0 +1,143 @@
+import store from '../../store';
+import dropboxHelper from './helpers/dropboxHelper';
+import providerUtils from './providerUtils';
+import providerRegistry from './providerRegistry';
+import utils from '../utils';
+
+const restrictedFolder = '/Applications/StackEdit (restricted)';
+const restrictedFolderRegexp = /^\/Applications\/StackEdit \(restricted\)/;
+
+export default providerRegistry.register({
+ id: 'dropbox',
+ fullAccess: true,
+ getToken(location) {
+ const token = store.getters['data/dropboxTokens'][location.sub];
+ if (token && !!token.fullAccess === this.fullAccess) {
+ return token;
+ }
+ return null;
+ },
+ getUrl(location) {
+ const pathComponents = location.path.split('/').map(encodeURIComponent);
+ const filename = pathComponents.pop();
+ let baseUrl = 'https://www.dropbox.com/home';
+ if (!this.fullAccess) {
+ baseUrl += encodeURIComponent(restrictedFolder);
+ }
+ return `${baseUrl}${pathComponents.join('/')}?preview=${filename}`;
+ },
+ getDescription(location) {
+ const token = this.getToken(location);
+ if (this.fullAccess) {
+ return `${location.path} — ${token.name}`;
+ }
+ return `${location.path} — ${token.name} (restricted)`;
+ },
+ checkPath(path) {
+ return path && path.match(/^\/[^\\<>:"|?*]+$/);
+ },
+ downloadContent(token, location) {
+ return dropboxHelper.downloadFile(token, location.path, location.dropboxFileId)
+ .then(({ content }) => providerUtils.parseContent(content));
+ },
+ uploadContent(token, content, location) {
+ return dropboxHelper.uploadFile(
+ token,
+ location.path,
+ providerUtils.serializeContent(content),
+ location.dropboxFileId,
+ )
+ .then(dropboxFile => ({
+ ...location,
+ path: dropboxFile.path_display,
+ dropboxFileId: dropboxFile.id,
+ }));
+ },
+ publish(token, html, metadata, location) {
+ return dropboxHelper.uploadFile(
+ token,
+ location.path,
+ html,
+ location.dropboxFileId,
+ )
+ .then(dropboxFile => ({
+ ...location,
+ path: dropboxFile.path_display,
+ dropboxFileId: dropboxFile.id,
+ }));
+ },
+ openFiles(token, paths) {
+ const openOneFile = () => {
+ let path = paths.pop();
+ if (!path) {
+ return null;
+ }
+ if (!token.fullAccess) {
+ path = path.replace(restrictedFolderRegexp, '');
+ }
+ let syncLocation;
+ // Try to find an existing sync location
+ store.getters['syncLocation/items'].some((existingSyncLocation) => {
+ if (existingSyncLocation.providerId === this.id &&
+ existingSyncLocation.path === path
+ ) {
+ syncLocation = existingSyncLocation;
+ }
+ return syncLocation;
+ });
+ if (syncLocation) {
+ // Sync location already exists, just open the file
+ store.commit('file/setCurrentId', syncLocation.fileId);
+ return openOneFile();
+ }
+ // Sync location does not exist, download content from Dropbox and create the file
+ syncLocation = {
+ path,
+ providerId: this.id,
+ sub: token.sub,
+ };
+ return this.downloadContent(token, syncLocation)
+ .then((content) => {
+ const id = utils.uid();
+ delete content.history;
+ store.commit('content/setItem', {
+ ...content,
+ id: `${id}/content`,
+ });
+ let name = path;
+ const slashPos = name.lastIndexOf('/');
+ if (slashPos > -1 && slashPos < name.length - 1) {
+ name = name.slice(slashPos + 1);
+ }
+ const dotPos = name.lastIndexOf('.');
+ if (dotPos > 0 && slashPos < name.length) {
+ name = name.slice(0, dotPos);
+ }
+ store.commit('file/setItem', {
+ id,
+ name: name.slice(0, 250),
+ parentId: store.getters['file/current'].parentId,
+ });
+ store.commit('syncLocation/setItem', {
+ ...syncLocation,
+ id: utils.uid(),
+ fileId: id,
+ });
+ store.commit('file/setCurrentId', id);
+ store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Dropbox.`);
+ }, () => {
+ store.dispatch('notification/error', `Could not open file ${path}.`);
+ })
+ .then(() => openOneFile());
+ };
+ return Promise.resolve()
+ .then(() => openOneFile());
+ },
+ makeLocation(token, path) {
+ return {
+ providerId: this.id,
+ sub: token.sub,
+ path,
+ };
+ },
+});
diff --git a/src/services/providers/dropboxRestrictedProvider.js b/src/services/providers/dropboxRestrictedProvider.js
new file mode 100644
index 00000000..1b34e962
--- /dev/null
+++ b/src/services/providers/dropboxRestrictedProvider.js
@@ -0,0 +1,8 @@
+import dropboxProvider from './dropboxProvider';
+import providerRegistry from './providerRegistry';
+
+export default providerRegistry.register({
+ ...dropboxProvider,
+ id: 'dropboxRestricted',
+ fullAccess: false,
+});
diff --git a/src/services/providers/gistProvider.js b/src/services/providers/gistProvider.js
new file mode 100644
index 00000000..ac4779fc
--- /dev/null
+++ b/src/services/providers/gistProvider.js
@@ -0,0 +1,63 @@
+import store from '../../store';
+import githubHelper from './helpers/githubHelper';
+import providerUtils from './providerUtils';
+import providerRegistry from './providerRegistry';
+
+const defaultDescription = 'Untitled';
+
+export default providerRegistry.register({
+ id: 'gist',
+ getToken(location) {
+ return store.getters['data/githubTokens'][location.sub];
+ },
+ getUrl(location) {
+ return `https://gist.github.com/${location.gistId}`;
+ },
+ getDescription(location) {
+ const token = this.getToken(location);
+ return `${location.filename} — ${location.gistId} — ${token.name}`;
+ },
+ downloadContent(token, location) {
+ return githubHelper.downloadGist(token, location.gistId, location.filename)
+ .then(content => providerUtils.parseContent(content));
+ },
+ uploadContent(token, content, location) {
+ const file = store.state.file.itemMap[location.fileId];
+ const description = (file && file.name) || defaultDescription;
+ return githubHelper.uploadGist(
+ token,
+ description,
+ location.filename,
+ providerUtils.serializeContent(content),
+ location.isPublic,
+ location.gistId,
+ )
+ .then(gist => ({
+ ...location,
+ gistId: gist.id,
+ }));
+ },
+ publish(token, html, metadata, location) {
+ return githubHelper.uploadGist(
+ token,
+ metadata.title,
+ location.filename,
+ html,
+ location.isPublic,
+ location.gistId,
+ )
+ .then(gist => ({
+ ...location,
+ gistId: gist.id,
+ }));
+ },
+ makeLocation(token, filename, isPublic, gistId) {
+ return {
+ providerId: this.id,
+ sub: token.sub,
+ filename,
+ isPublic,
+ gistId,
+ };
+ },
+});
diff --git a/src/services/providers/githubProvider.js b/src/services/providers/githubProvider.js
new file mode 100644
index 00000000..f8e6adf5
--- /dev/null
+++ b/src/services/providers/githubProvider.js
@@ -0,0 +1,71 @@
+import store from '../../store';
+import githubHelper from './helpers/githubHelper';
+import providerUtils from './providerUtils';
+import providerRegistry from './providerRegistry';
+
+const savedSha = {};
+
+export default providerRegistry.register({
+ id: 'github',
+ getToken(location) {
+ return store.getters['data/githubTokens'][location.sub];
+ },
+ getUrl(location) {
+ return `https://github.com/${encodeURIComponent(location.owner)}/${encodeURIComponent(location.repo)}/blob/${encodeURIComponent(location.branch)}/${encodeURIComponent(location.path)}`;
+ },
+ getDescription(location) {
+ const token = this.getToken(location);
+ return `${location.path} — ${location.owner}/${location.repo} — ${token.name}`;
+ },
+ downloadContent(token, location) {
+ return githubHelper.downloadFile(
+ token, location.owner, location.repo, location.branch, location.path,
+ )
+ .then(({ sha, content }) => {
+ savedSha[location.id] = sha;
+ return providerUtils.parseContent(content);
+ })
+ .catch(() => null); // Ignore error, without the sha upload is going to fail anyway
+ },
+ uploadContent(token, content, location) {
+ const sha = savedSha[location.id];
+ delete savedSha[location.id];
+ return githubHelper.uploadFile(
+ token,
+ location.owner,
+ location.repo,
+ location.branch,
+ location.path,
+ providerUtils.serializeContent(content),
+ sha,
+ )
+ .then(() => location);
+ },
+ publish(token, html, metadata, location) {
+ return this.downloadContent(token, location) // Get the last sha
+ .then(() => {
+ const sha = savedSha[location.id];
+ delete savedSha[location.id];
+ return githubHelper.uploadFile(
+ token,
+ location.owner,
+ location.repo,
+ location.branch,
+ location.path,
+ html,
+ sha,
+ );
+ })
+ .then(() => location);
+ },
+ makeLocation(token, owner, repo, branch, path) {
+ return {
+ providerId: this.id,
+ sub: token.sub,
+ owner,
+ repo,
+ branch,
+ path,
+ };
+ },
+});
diff --git a/src/services/providers/googleDriveAppDataProvider.js b/src/services/providers/googleDriveAppDataProvider.js
index 18851e9e..ebd52c6f 100644
--- a/src/services/providers/googleDriveAppDataProvider.js
+++ b/src/services/providers/googleDriveAppDataProvider.js
@@ -1,7 +1,12 @@
import store from '../../store';
import googleHelper from './helpers/googleHelper';
+import providerRegistry from './providerRegistry';
-export default {
+export default providerRegistry.register({
+ id: 'googleDriveAppData',
+ getToken() {
+ return store.getters['data/loginToken'];
+ },
getChanges(token) {
return googleHelper.getChanges(token)
.then((result) => {
@@ -37,7 +42,7 @@ export default {
}
},
saveItem(token, item, syncData, ifNotTooLate) {
- return googleHelper.saveAppDataFile(
+ return googleHelper.uploadAppDataFile(
token,
JSON.stringify(item), ['appDataFolder'],
null,
@@ -76,19 +81,19 @@ export default {
return item;
});
},
- uploadContent(token, item, syncLocation, ifNotTooLate) {
+ uploadContent(token, content, syncLocation, ifNotTooLate) {
const syncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
- if (syncData && syncData.hash === item.hash) {
+ if (syncData && syncData.hash === content.hash) {
return Promise.resolve();
}
- return googleHelper.saveAppDataFile(
+ return googleHelper.uploadAppDataFile(
token,
JSON.stringify({
- id: item.id,
- type: item.type,
- hash: item.hash,
+ id: content.id,
+ type: content.type,
+ hash: content.hash,
}), ['appDataFolder'],
- JSON.stringify(item),
+ JSON.stringify(content),
syncData && syncData.id,
ifNotTooLate,
)
@@ -97,10 +102,10 @@ export default {
[file.id]: {
// Build sync data
id: file.id,
- itemId: item.id,
- type: item.type,
- hash: item.hash,
+ itemId: content.id,
+ type: content.type,
+ hash: content.hash,
},
}));
},
-};
+});
diff --git a/src/services/providers/googleDriveProvider.js b/src/services/providers/googleDriveProvider.js
index c7fccb3b..8f599722 100644
--- a/src/services/providers/googleDriveProvider.js
+++ b/src/services/providers/googleDriveProvider.js
@@ -1,28 +1,42 @@
import store from '../../store';
import googleHelper from './helpers/googleHelper';
import providerUtils from './providerUtils';
+import providerRegistry from './providerRegistry';
import utils from '../utils';
const defaultFilename = 'Untitled';
-export default {
+export default providerRegistry.register({
+ id: 'googleDrive',
+ getToken(location) {
+ const token = store.getters['data/googleTokens'][location.sub];
+ return token && token.isDrive ? token : null;
+ },
+ getUrl(location) {
+ return `https://docs.google.com/file/d/${location.driveFileId}/edit`;
+ },
+ getDescription(location) {
+ const token = this.getToken(location);
+ return `${location.driveFileId} — ${token.name}`;
+ },
downloadContent(token, syncLocation) {
return googleHelper.downloadFile(token, syncLocation.driveFileId)
.then(content => providerUtils.parseContent(content));
},
- uploadContent(token, item, syncLocation, ifNotTooLate) {
+ uploadContent(token, content, syncLocation, ifNotTooLate) {
const file = store.state.file.itemMap[syncLocation.fileId];
const name = (file && file.name) || defaultFilename;
const parents = [];
if (syncLocation.driveParentId) {
parents.push(syncLocation.driveParentId);
}
- return googleHelper.saveFile(
+ return googleHelper.uploadFile(
token,
name,
parents,
- providerUtils.serializeContent(item),
- syncLocation && syncLocation.driveFileId,
+ providerUtils.serializeContent(content),
+ undefined,
+ syncLocation.driveFileId,
ifNotTooLate,
)
.then(driveFile => ({
@@ -30,6 +44,20 @@ export default {
driveFileId: driveFile.id,
}));
},
+ publish(token, html, metadata, publishLocation) {
+ return googleHelper.uploadFile(
+ token,
+ metadata.title,
+ [],
+ html,
+ publishLocation.templateId ? 'text/html' : undefined,
+ publishLocation.driveFileId,
+ )
+ .then(driveFile => ({
+ ...publishLocation,
+ driveFileId: driveFile.id,
+ }));
+ },
openFiles(token, files) {
const openOneFile = () => {
const file = files.pop();
@@ -39,20 +67,22 @@ export default {
let syncLocation;
// Try to find an existing sync location
store.getters['syncLocation/items'].some((existingSyncLocation) => {
- if (existingSyncLocation.driveFileId === file.id) {
+ if (existingSyncLocation.providerId === this.id &&
+ existingSyncLocation.driveFileId === file.id
+ ) {
syncLocation = existingSyncLocation;
}
return syncLocation;
});
if (syncLocation) {
// Sync location already exists, just open the file
- this.$store.commit('file/setCurrentId', syncLocation.fileId);
+ store.commit('file/setCurrentId', syncLocation.fileId);
return openOneFile();
}
// Sync location does not exist, download content from Google Drive and create the file
syncLocation = {
driveFileId: file.id,
- provider: 'googleDrive',
+ providerId: this.id,
sub: token.sub,
};
return this.downloadContent(token, syncLocation)
@@ -74,12 +104,26 @@ export default {
fileId: id,
});
store.commit('file/setCurrentId', id);
+ store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Google Drive.`);
}, () => {
- console.error(`Could not open file ${file.id}.`);
+ store.dispatch('notification/error', `Could not open file ${file.id}.`);
})
.then(() => openOneFile());
};
return Promise.resolve()
.then(() => openOneFile());
},
-};
+ makeLocation(token, fileId, folderId) {
+ const location = {
+ providerId: this.id,
+ sub: token.sub,
+ };
+ if (fileId) {
+ location.driveFileId = fileId;
+ }
+ if (folderId) {
+ location.driveParentId = folderId;
+ }
+ return location;
+ },
+});
diff --git a/src/services/providers/helpers/dropboxHelper.js b/src/services/providers/helpers/dropboxHelper.js
new file mode 100644
index 00000000..dd3a2a8c
--- /dev/null
+++ b/src/services/providers/helpers/dropboxHelper.js
@@ -0,0 +1,106 @@
+import utils from '../../utils';
+import store from '../../../store';
+
+let Dropbox;
+
+const getAppKey = (fullAccess) => {
+ if (fullAccess) {
+ return 'lq6mwopab8wskas';
+ }
+ return 'sw0hlixhr8q1xk0';
+};
+
+const request = (token, options, args) => utils.request({
+ ...options,
+ headers: {
+ ...options.headers,
+ 'Content-Type': 'application/octet-stream',
+ 'Dropbox-API-Arg': args && JSON.stringify(args),
+ Authorization: `Bearer ${token.accessToken}`,
+ },
+});
+
+export default {
+ startOauth2(fullAccess, sub = null, silent = false) {
+ return utils.startOauth2(
+ 'https://www.dropbox.com/oauth2/authorize', {
+ client_id: getAppKey(fullAccess),
+ response_type: 'token',
+ }, silent)
+ // Call the user info endpoint
+ .then(({ accessToken }) => request({ accessToken }, {
+ method: 'POST',
+ url: 'https://api.dropboxapi.com/2/users/get_current_account',
+ })
+ .then((res) => {
+ // Check the returned sub consistency
+ if (sub && res.body.account_id !== sub) {
+ throw new Error('Dropbox account ID not expected.');
+ }
+ // Build token object including scopes and sub
+ const token = {
+ accessToken,
+ name: res.body.name.display_name,
+ sub: res.body.account_id,
+ fullAccess,
+ };
+ // Add token to githubTokens
+ store.dispatch('data/setDropboxToken', token);
+ return token;
+ }));
+ },
+ loadClientScript() {
+ if (Dropbox) {
+ return Promise.resolve();
+ }
+ return utils.loadScript('https://www.dropbox.com/static/api/2/dropins.js')
+ .then(() => {
+ Dropbox = window.Dropbox;
+ });
+ },
+ addAccount(fullAccess = false) {
+ return this.startOauth2(fullAccess);
+ },
+ uploadFile(token, path, content, fileId) {
+ return request(token, {
+ method: 'POST',
+ url: 'https://content.dropboxapi.com/2/files/upload',
+ body: content,
+ }, {
+ path: fileId || path,
+ mode: 'overwrite',
+ })
+ .then(res => res.body);
+ },
+ downloadFile(token, path, fileId) {
+ return request(token, {
+ method: 'POST',
+ url: 'https://content.dropboxapi.com/2/files/download',
+ raw: true,
+ }, {
+ path: fileId || path,
+ })
+ .then(res => ({
+ id: JSON.parse(res.headers['dropbox-api-result']).id,
+ content: res.body,
+ }));
+ },
+ openChooser(token) {
+ return this.loadClientScript()
+ .then(() => new Promise((resolve) => {
+ Dropbox.appKey = getAppKey(token.fullAccess);
+ Dropbox.choose({
+ multiselect: true,
+ linkType: 'direct',
+ success: (files) => {
+ const paths = files.map((file) => {
+ const path = file.link.replace(/.*\/view\/[^/]*/, '');
+ return decodeURI(path);
+ });
+ resolve(paths);
+ },
+ cancel: () => resolve([]),
+ });
+ }));
+ },
+};
diff --git a/src/services/providers/helpers/githubHelper.js b/src/services/providers/helpers/githubHelper.js
new file mode 100644
index 00000000..35b682ff
--- /dev/null
+++ b/src/services/providers/helpers/githubHelper.js
@@ -0,0 +1,132 @@
+import utils from '../../utils';
+import store from '../../../store';
+
+let clientId = 'cbf0cf25cfd026be23e1';
+if (utils.origin === 'https://stackedit.io') {
+ clientId = '30c1491057c9ad4dbd56';
+}
+const getScopes = token => [token.repoFullAccess ? 'repo' : 'public_repo', 'gist'];
+
+const request = (token, options) => utils.request({
+ ...options,
+ headers: {
+ ...options.headers,
+ Authorization: `token ${token.accessToken}`,
+ },
+});
+
+const base64Encode = str => btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
+ (match, p1) => String.fromCharCode(`0x${p1}`),
+));
+const base64Decode = str => decodeURIComponent(atob(str).split('').map(
+ c => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`,
+).join(''));
+
+export default {
+ startOauth2(scopes, sub = null, silent = false) {
+ return utils.startOauth2(
+ 'https://github.com/login/oauth/authorize', {
+ client_id: clientId,
+ scope: scopes.join(' '),
+ }, silent)
+ // Exchange code with token
+ .then(data => utils.request({
+ method: 'GET',
+ url: 'oauth2/githubToken',
+ params: {
+ clientId,
+ code: data.code,
+ },
+ })
+ .then(res => res.body))
+ // Call the user info endpoint
+ .then(accessToken => utils.request({
+ method: 'GET',
+ url: 'https://api.github.com/user',
+ params: {
+ access_token: accessToken,
+ },
+ })
+ .then((res) => {
+ // Check the returned sub consistency
+ if (sub && res.body.id !== sub) {
+ throw new Error('GitHub account ID not expected.');
+ }
+ // Build token object including scopes and sub
+ const token = {
+ scopes,
+ accessToken,
+ name: res.body.name,
+ sub: res.body.id,
+ repoFullAccess: scopes.indexOf('repo') !== -1,
+ };
+ // Add token to githubTokens
+ store.dispatch('data/setGithubToken', token);
+ return token;
+ }));
+ },
+ addAccount(repoFullAccess = false) {
+ return this.startOauth2(getScopes({ repoFullAccess }));
+ },
+ uploadFile(token, owner, repo, branch, path, content, sha) {
+ return request(token, {
+ method: 'PUT',
+ url: `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodeURIComponent(path)}`,
+ body: {
+ message: 'Uploaded by https://stackedit.io/',
+ content: base64Encode(content),
+ sha,
+ branch,
+ },
+ });
+ },
+ downloadFile(token, owner, repo, branch, path) {
+ return request(token, {
+ url: `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodeURIComponent(path)}`,
+ params: { ref: branch },
+ })
+ .then(res => ({
+ sha: res.body.sha,
+ content: base64Decode(res.body.content),
+ }));
+ },
+ uploadGist(token, description, filename, content, isPublic, gistId) {
+ return request(token, gistId ? {
+ method: 'PATCH',
+ url: `https://api.github.com/gists/${gistId}`,
+ body: {
+ description,
+ files: {
+ [filename]: {
+ content,
+ },
+ },
+ },
+ } : {
+ method: 'POST',
+ url: 'https://api.github.com/gists',
+ body: {
+ description,
+ files: {
+ [filename]: {
+ content,
+ },
+ },
+ public: isPublic,
+ },
+ })
+ .then(res => res.body);
+ },
+ downloadGist(token, gistId, filename) {
+ return request(token, {
+ url: `https://api.github.com/gists/${gistId}`,
+ })
+ .then((res) => {
+ const result = res.body.files[filename];
+ if (!result) {
+ throw new Error('Gist file not found.');
+ }
+ return result.content;
+ });
+ },
+};
diff --git a/src/services/providers/helpers/googleHelper.js b/src/services/providers/helpers/googleHelper.js
index 9251c154..781449a1 100644
--- a/src/services/providers/helpers/googleHelper.js
+++ b/src/services/providers/helpers/googleHelper.js
@@ -12,7 +12,7 @@ const getDriveScopes = token => [token.driveFullAccess
? 'https://www.googleapis.com/auth/drive'
: 'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive.install'];
-// const bloggerScopes = ['https://www.googleapis.com/auth/blogger'];
+const bloggerScopes = ['https://www.googleapis.com/auth/blogger'];
const photosScopes = ['https://www.googleapis.com/auth/photos'];
const libraries = ['picker'];
@@ -25,9 +25,7 @@ const request = (token, options) => utils.request({
},
});
-function saveFile(refreshedToken, name, parents, media = null, fileId = null,
- ifNotTooLate = cb => res => cb(res),
-) {
+function uploadFile(refreshedToken, name, parents, media = null, mediaType = 'text/plain', 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(() => {
@@ -52,7 +50,7 @@ function saveFile(refreshedToken, name, parents, media = null, fileId = null,
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 += `Content-Type: ${mediaType}; charset=UTF-8\r\n\r\n`;
multipartRequestBody += media;
multipartRequestBody += closeDelimiter;
options.url = options.url.replace(
@@ -95,7 +93,7 @@ export default {
login_hint: sub,
prompt: silent ? 'none' : null,
}, silent)
- // Call the tokeninfo endpoint
+ // Call the token info endpoint
.then(data => utils.request({
method: 'POST',
url: 'https://www.googleapis.com/oauth2/v3/tokeninfo',
@@ -121,11 +119,12 @@ export default {
scopes.indexOf('https://www.googleapis.com/auth/drive.appdata') !== -1,
isDrive: scopes.indexOf('https://www.googleapis.com/auth/drive') !== -1 ||
scopes.indexOf('https://www.googleapis.com/auth/drive.file') !== -1,
+ isBlogger: scopes.indexOf('https://www.googleapis.com/auth/blogger') !== -1,
isPhotos: scopes.indexOf('https://www.googleapis.com/auth/photos') !== -1,
driveFullAccess: scopes.indexOf('https://www.googleapis.com/auth/drive') !== -1,
};
}))
- // Call the tokeninfo endpoint
+ // Call the user info endpoint
.then(token => request(token, {
method: 'GET',
url: 'https://www.googleapis.com/plus/v1/people/me',
@@ -139,6 +138,7 @@ export default {
// Save flags
token.isLogin = existingToken.isLogin || token.isLogin;
token.isDrive = existingToken.isDrive || token.isDrive;
+ token.isBlogger = existingToken.isBlogger || token.isBlogger;
token.isPhotos = existingToken.isPhotos || token.isPhotos;
token.driveFullAccess = existingToken.driveFullAccess || token.driveFullAccess;
// Save nextPageToken
@@ -168,7 +168,8 @@ export default {
// Try to get a new token in background
return this.startOauth2(mergedScopes, sub, true)
// If it fails try to popup a window
- .catch(() => this.startOauth2(mergedScopes, sub));
+ .catch(() => utils.checkOnline()
+ .then(() => this.startOauth2(mergedScopes, sub)));
});
},
loadClientScript() {
@@ -176,17 +177,17 @@ export default {
return Promise.resolve();
}
return utils.loadScript('https://apis.google.com/js/api.js')
- .then(() => Promise.all(libraries.map(
- library => new Promise((resolve, reject) => window.gapi.load(library, {
- callback: resolve,
- onerror: reject,
- timeout: 30000,
- ontimeout: reject,
- })))))
- .then(() => {
- gapi = window.gapi;
- google = window.google;
- });
+ .then(() => Promise.all(libraries.map(
+ library => new Promise((resolve, reject) => window.gapi.load(library, {
+ callback: resolve,
+ onerror: reject,
+ timeout: 30000,
+ ontimeout: reject,
+ })))))
+ .then(() => {
+ gapi = window.gapi;
+ google = window.google;
+ });
},
signin() {
return this.startOauth2(driveAppDataScopes);
@@ -194,6 +195,9 @@ export default {
addDriveAccount(fullAccess = false) {
return this.startOauth2(getDriveScopes({ driveFullAccess: fullAccess }));
},
+ addBloggerAccount() {
+ return this.startOauth2(bloggerScopes);
+ },
addPhotosAccount() {
return this.startOauth2(photosScopes);
},
@@ -224,13 +228,15 @@ export default {
return getPage(refreshedToken.nextPageToken);
});
},
- saveFile(token, name, parents, media, fileId, ifNotTooLate) {
+ uploadFile(token, name, parents, media, mediaType, fileId, ifNotTooLate) {
return this.refreshToken(getDriveScopes(token), token)
- .then(refreshedToken => saveFile(refreshedToken, name, parents, media, fileId, ifNotTooLate));
+ .then(refreshedToken => uploadFile(
+ refreshedToken, name, parents, media, mediaType, fileId, ifNotTooLate));
},
- saveAppDataFile(token, name, parents, media, fileId, ifNotTooLate) {
+ uploadAppDataFile(token, name, parents, media, fileId, ifNotTooLate) {
return this.refreshToken(driveAppDataScopes, token)
- .then(refreshedToken => saveFile(refreshedToken, name, parents, media, fileId, ifNotTooLate));
+ .then(refreshedToken => uploadFile(
+ refreshedToken, name, parents, media, undefined, fileId, ifNotTooLate));
},
downloadFile(token, id) {
return this.refreshToken(getDriveScopes(token), token)
@@ -248,6 +254,72 @@ export default {
url: `https://www.googleapis.com/drive/v3/files/${id}`,
})));
},
+ uploadBlogger(
+ token, blogUrl, blogId, postId, title, content, labels, isDraft, published, isPage,
+ ) {
+ return this.refreshToken(bloggerScopes, token)
+ .then(refreshedToken => Promise.resolve()
+ .then(() => {
+ if (blogId) {
+ return blogId;
+ }
+ return request(refreshedToken, {
+ url: 'https://www.googleapis.com/blogger/v3/blogs/byurl',
+ params: {
+ url: blogUrl,
+ },
+ }).then(res => res.body.id);
+ })
+ .then((resolvedBlogId) => {
+ const path = isPage ? 'pages' : 'posts';
+ const options = {
+ method: 'POST',
+ url: `https://www.googleapis.com/blogger/v3/blogs/${resolvedBlogId}/${path}/`,
+ body: {
+ kind: isPage ? 'blogger#page' : 'blogger#post',
+ blog: {
+ id: resolvedBlogId,
+ },
+ title,
+ content,
+ },
+ };
+ if (labels) {
+ options.body.labels = labels;
+ }
+ if (published) {
+ options.body.published = published.toISOString();
+ }
+ // If it's an update
+ if (postId) {
+ options.method = 'PUT';
+ options.url += postId;
+ options.body.id = postId;
+ }
+ return request(refreshedToken, options)
+ .then(res => res.body);
+ })
+ .then((post) => {
+ if (isPage) {
+ return post;
+ }
+ const options = {
+ method: 'POST',
+ url: `https://www.googleapis.com/blogger/v3/blogs/${post.blog.id}/posts/${post.id}/`,
+ params: {},
+ };
+ if (isDraft) {
+ options.url += 'revert';
+ } else {
+ options.url += 'publish';
+ if (published) {
+ options.params.publishDate = published.toISOString();
+ }
+ }
+ return request(refreshedToken, options)
+ .then(res => res.body);
+ }));
+ },
openPicker(token, type = 'doc') {
const scopes = type === 'img' ? photosScopes : getDriveScopes(token);
return this.loadClientScript()
diff --git a/src/services/providers/providerRegistry.js b/src/services/providers/providerRegistry.js
new file mode 100644
index 00000000..1e48ce70
--- /dev/null
+++ b/src/services/providers/providerRegistry.js
@@ -0,0 +1,7 @@
+export default {
+ providers: {},
+ register(provider) {
+ this.providers[provider.id] = provider;
+ return provider;
+ },
+};
diff --git a/src/services/publishSvc.js b/src/services/publishSvc.js
new file mode 100644
index 00000000..0afd6738
--- /dev/null
+++ b/src/services/publishSvc.js
@@ -0,0 +1,148 @@
+import localDbSvc from './localDbSvc';
+import store from '../store';
+import utils from './utils';
+import exportSvc from './exportSvc';
+import providerRegistry from './providers/providerRegistry';
+
+const hasCurrentFilePublishLocations = () => !!store.getters['publishLocation/current'].length;
+
+const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`)
+ // Item does not exist, create it
+ .catch(() => store.commit(`${type}/setItem`, {
+ id: `${fileId}/${type}`,
+ }));
+const loadContent = loader('content');
+
+const ensureArray = (value) => {
+ if (!value) {
+ return [];
+ }
+ if (!Array.isArray(value)) {
+ return value.toString().trim().split(/\s*,\s*/);
+ }
+ return value;
+};
+
+const ensureString = (value, defaultValue) => {
+ if (!value) {
+ return defaultValue;
+ }
+ return value.toString();
+};
+
+const ensureDate = (value, defaultValue) => {
+ if (!value) {
+ return defaultValue;
+ }
+ return new Date(value.toString());
+};
+
+function publish(publishLocation) {
+ const fileId = publishLocation.fileId;
+ const template = store.getters['data/allTemplates'][publishLocation.templateId];
+ return exportSvc.applyTemplate(fileId, template)
+ .then(html => localDbSvc.loadItem(`${fileId}/content`)
+ .then((content) => {
+ const file = store.state.file.itemMap[fileId];
+ const properties = utils.computeProperties(content.properties);
+ const provider = providerRegistry.providers[publishLocation.providerId];
+ const token = provider.getToken(publishLocation);
+ const metadata = {
+ title: ensureString(properties.title, file.name),
+ author: ensureString(properties.author),
+ tags: ensureArray(properties.tags),
+ categories: ensureArray(properties.categories),
+ excerpt: ensureString(properties.excerpt),
+ featuredImage: ensureString(properties.featuredImage),
+ status: ensureString(properties.status),
+ date: ensureDate(properties.date),
+ };
+ return provider.publish(token, html, metadata, publishLocation);
+ }));
+}
+
+function publishFile(fileId) {
+ let counter = 0;
+ return loadContent(fileId)
+ .then(() => {
+ const publishLocations = [
+ ...store.getters['publishLocation/groupedByFileId'][fileId] || [],
+ ];
+ const publishOneContentLocation = () => {
+ const publishLocation = publishLocations.shift();
+ if (!publishLocation) {
+ return null;
+ }
+ return store.dispatch('queue/doWithLocation', {
+ location: publishLocation,
+ promise: publish(publishLocation)
+ .then((publishLocationToStore) => {
+ // Replace publish location if modified
+ if (utils.serializeObject(publishLocation) !==
+ utils.serializeObject(publishLocationToStore)
+ ) {
+ store.commit('publishLocation/patchItem', publishLocationToStore);
+ }
+ counter += 1;
+ return publishOneContentLocation();
+ }, (err) => {
+ if (store.state.offline) {
+ throw err;
+ }
+ console.error(err); // eslint-disable-line no-console
+ store.dispatch('notification/error', err);
+ return publishOneContentLocation();
+ }),
+ });
+ };
+ return publishOneContentLocation();
+ })
+ .then(() => {
+ const file = store.state.file.itemMap[fileId];
+ store.dispatch('notification/info', `"${file.name}" was published to ${counter} location(s).`);
+ })
+ .then(
+ () => localDbSvc.unloadContents(),
+ err => localDbSvc.unloadContents()
+ .then(() => {
+ throw err;
+ }));
+}
+
+function requestPublish() {
+ store.dispatch('queue/enqueuePublishRequest', () => new Promise((resolve, reject) => {
+ let intervalId;
+ const attempt = () => {
+ // Only start publishing when these conditions are met
+ if (utils.isUserActive()) {
+ clearInterval(intervalId);
+ if (!hasCurrentFilePublishLocations()) {
+ // Cancel sync
+ reject('Publish not possible.');
+ return;
+ }
+ publishFile(store.getters['file/current'].id)
+ .then(resolve, reject);
+ }
+ };
+ intervalId = utils.setInterval(() => attempt(), 1000);
+ attempt();
+ }));
+}
+
+function createPublishLocation(publishLocation) {
+ publishLocation.id = utils.uid();
+ const currentFile = store.getters['file/current'];
+ publishLocation.fileId = currentFile.id;
+ store.dispatch('queue/enqueue',
+ () => publish(publishLocation)
+ .then((publishLocationToStore) => {
+ store.commit('publishLocation/setItem', publishLocationToStore);
+ store.dispatch('notification/info', `A new publication location was added to "${currentFile.name}".`);
+ }));
+}
+
+export default {
+ requestPublish,
+ createPublishLocation,
+};
diff --git a/src/services/syncSvc.js b/src/services/syncSvc.js
index de0dbf0e..38b85a17 100644
--- a/src/services/syncSvc.js
+++ b/src/services/syncSvc.js
@@ -3,11 +3,10 @@ import store from '../store';
import welcomeFile from '../data/welcomeFile.md';
import utils from './utils';
import diffUtils from './diffUtils';
-import userActivitySvc from './userActivitySvc';
-import googleDriveAppDataProvider from './providers/googleDriveAppDataProvider';
-import googleDriveProvider from './providers/googleDriveProvider';
+import providerRegistry from './providers/providerRegistry';
+import mainProvider from './providers/googleDriveAppDataProvider';
-const lastSyncActivityKey = 'lastSyncActivity';
+const lastSyncActivityKey = `${utils.workspaceId}/lastSyncActivity`;
let lastSyncActivity;
const getStoredLastSyncActivity = () => parseInt(localStorage[lastSyncActivityKey], 10) || 0;
const inactivityThreshold = 3 * 1000; // 3 sec
@@ -37,26 +36,6 @@ function setLastSyncActivity() {
localStorage[lastSyncActivityKey] = currentDate;
}
-function getSyncProvider(syncLocation) {
- switch (syncLocation.provider) {
- case 'googleDriveAppData':
- default:
- return googleDriveAppDataProvider;
- case 'googleDrive':
- return googleDriveProvider;
- }
-}
-
-function getSyncToken(syncLocation) {
- switch (syncLocation.provider) {
- case 'googleDriveAppData':
- default:
- return store.getters['data/loginToken'];
- case 'googleDrive':
- return store.getters['data/googleTokens'][syncLocation.sub];
- }
-}
-
function cleanSyncedContent(syncedContent) {
// Clean syncHistory from removed syncLocations
Object.keys(syncedContent.syncHistory).forEach((syncLocationId) => {
@@ -123,6 +102,37 @@ function applyChanges(changes) {
const LAST_SENT = 0;
const LAST_MERGED = 1;
+function createSyncLocation(syncLocation) {
+ syncLocation.id = utils.uid();
+ const currentFile = store.getters['file/current'];
+ const fileId = currentFile.id;
+ syncLocation.fileId = fileId;
+ // Use deepCopy to freeze item
+ const content = utils.deepCopy(store.getters['content/current']);
+ store.dispatch('queue/enqueue',
+ () => {
+ const provider = providerRegistry.providers[syncLocation.providerId];
+ const token = provider.getToken(syncLocation);
+ return provider.uploadContent(token, {
+ ...content,
+ history: [content.hash],
+ }, syncLocation)
+ .then(syncLocationToStore => loadSyncedContent(fileId)
+ .then(() => {
+ const newSyncedContent = utils.deepCopy(
+ store.state.syncedContent.itemMap[`${fileId}/syncedContent`]);
+ const newSyncHistoryItem = [];
+ newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem;
+ newSyncHistoryItem[LAST_SENT] = content.hash;
+ newSyncedContent.historyData[content.hash] = content;
+
+ store.commit('syncedContent/patchItem', newSyncedContent);
+ store.commit('syncLocation/setItem', syncLocationToStore);
+ store.dispatch('notification/info', `A new synchronized location was added to "${currentFile.name}".`);
+ }));
+ });
+}
+
function syncFile(fileId) {
return loadSyncedContent(fileId)
.then(() => loadContent(fileId))
@@ -131,121 +141,145 @@ function syncFile(fileId) {
const getSyncedContent = () => store.state.syncedContent.itemMap[`${fileId}/syncedContent`];
const getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId];
const downloadedLocations = {};
+ const errorLocations = {};
- const isLocationSynced = syncLocation =>
- getSyncHistoryItem(syncLocation.id)[LAST_SENT] === getContent().hash;
+ const isLocationSynced = (syncLocation) => {
+ const syncHistoryItem = getSyncHistoryItem(syncLocation.id);
+ return syncHistoryItem && syncHistoryItem[LAST_SENT] === getContent().hash;
+ };
const syncOneContentLocation = () => {
const syncLocations = [
...store.getters['syncLocation/groupedByFileId'][fileId] || [],
];
if (isDataSyncPossible()) {
- syncLocations.unshift({ id: 'main', provider: 'googleDriveAppData', fileId });
+ syncLocations.unshift({ id: 'main', providerId: mainProvider.id, 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;
+ if (!errorLocations[syncLocation.id] &&
+ (!downloadedLocations[syncLocation.id] || !isLocationSynced(syncLocation))
+ ) {
+ const provider = providerRegistry.providers[syncLocation.providerId];
+ const token = provider.getToken(syncLocation);
+ result = provider && token && store.dispatch('queue/doWithLocation', {
+ location: syncLocation,
+ promise: 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;
+ 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,
});
- if (!lastMergedContent && syncHistoryItem) {
- lastMergedContent = syncedContent.historyData[syncHistoryItem[LAST_MERGED]];
+
+ // 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;
}
- 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);
+ 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 last sent if it's in the server history,
- // 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');
+ // Store last sent if it's in the server history,
+ // 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];
}
- return cb(res);
- };
+ newSyncHistoryItem[LAST_SENT] = mergedContent.hash;
+ newSyncedContent.historyData[mergedContent.hash] = mergedContent;
- // Upload merged content
- return provider.uploadContent(token, {
- ...mergedContent,
- history: mergedContentHistory,
- }, syncLocation, ifNotTooLate);
- })
- .then(() => syncOneContentLocation());
+ // 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((syncLocationToStore) => {
+ // Replace sync location if modified
+ if (utils.serializeObject(syncLocation) !==
+ utils.serializeObject(syncLocationToStore)
+ ) {
+ store.commit('syncLocation/patchItem', syncLocationToStore);
+ }
+ });
+ })
+ .catch((err) => {
+ if (store.state.offline) {
+ throw err;
+ }
+ console.error(err); // eslint-disable-line no-console
+ store.dispatch('notification/error', err);
+ errorLocations[syncLocation.id] = true;
+ }),
+ })
+ .then(() => syncOneContentLocation());
}
return result;
});
@@ -271,11 +305,11 @@ function syncFile(fileId) {
function sync() {
const googleToken = store.getters['data/loginToken'];
- return googleDriveAppDataProvider.getChanges(googleToken)
+ return mainProvider.getChanges(googleToken)
.then((changes) => {
// Apply changes
applyChanges(changes);
- googleDriveAppDataProvider.setAppliedChanges(googleToken, changes);
+ mainProvider.setAppliedChanges(googleToken, changes);
// Prevent from sending items too long after changes have been retrieved
const syncStartTime = Date.now();
@@ -292,6 +326,7 @@ function sync() {
...store.state.file.itemMap,
...store.state.folder.itemMap,
...store.state.syncLocation.itemMap,
+ ...store.state.publishLocation.itemMap,
// Deal with contents later
};
const syncDataByItemId = store.getters['data/syncDataByItemId'];
@@ -300,7 +335,7 @@ function sync() {
const item = storeItemMap[id];
const existingSyncData = syncDataByItemId[id];
if (!existingSyncData || existingSyncData.hash !== item.hash) {
- result = googleDriveAppDataProvider.saveItem(
+ result = mainProvider.saveItem(
googleToken,
// Use deepCopy to freeze objects
utils.deepCopy(item),
@@ -323,7 +358,8 @@ function sync() {
...store.state.file.itemMap,
...store.state.folder.itemMap,
...store.state.syncLocation.itemMap,
- ...store.state.content.itemMap, // Deal with contents now
+ ...store.state.publishLocation.itemMap,
+ ...store.state.content.itemMap,
};
const syncData = store.getters['data/syncData'];
let result;
@@ -335,7 +371,7 @@ function sync() {
) {
// Use deepCopy to freeze objects
const syncDataToRemove = utils.deepCopy(existingSyncData);
- result = googleDriveAppDataProvider
+ result = mainProvider
.removeItem(googleToken, syncDataToRemove, ifNotTooLate)
.then(() => {
const syncDataCopy = { ...store.getters['data/syncData'] };
@@ -350,10 +386,7 @@ function sync() {
});
const getOneFileIdToSync = () => {
- const allContentIds = Object.keys({
- ...store.state.content.itemMap,
- ...store.getters['data/syncDataByType'].content,
- });
+ const allContentIds = Object.keys(localDbSvc.hashMap.content);
let fileId;
allContentIds.some((contentId) => {
// Get content hash from itemMap or from localDbSvc if not loaded
@@ -361,7 +394,7 @@ function sync() {
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) {
+ if (!syncData || hash !== syncData.hash) {
[fileId] = contentId.split('/');
}
return fileId;
@@ -401,7 +434,7 @@ function requestSync() {
let intervalId;
const attempt = () => {
// Only start syncing when these conditions are met
- if (userActivitySvc.isActive() && isSyncWindow()) {
+ if (utils.isUserActive() && isSyncWindow()) {
clearInterval(intervalId);
if (!isSyncPossible()) {
// Cancel sync
@@ -422,6 +455,9 @@ function requestSync() {
return sync();
}
if (hasCurrentFileSyncLocations()) {
+ // Only sync current file if data sync is unavailable.
+ // We also could sync files that are out-of-sync but it would
+ // require to load the syncedContent objects of all files.
return syncFile(store.getters['file/current'].id);
}
return null;
@@ -437,7 +473,7 @@ function requestSync() {
// Sync periodically
utils.setInterval(() => {
if (isSyncPossible() &&
- userActivitySvc.isActive() &&
+ utils.isUserActive() &&
isSyncWindow() &&
isAutoSyncReady()
) {
@@ -508,4 +544,5 @@ utils.setInterval(() => {
export default {
isSyncPossible,
requestSync,
+ createSyncLocation,
};
diff --git a/src/services/userActivitySvc.js b/src/services/userActivitySvc.js
deleted file mode 100644
index a0715226..00000000
--- a/src/services/userActivitySvc.js
+++ /dev/null
@@ -1,28 +0,0 @@
-const inactiveAfter = 2 * 60 * 1000; // 2 minutes
-let lastActivity;
-let lastFocus;
-const lastFocusKey = 'lastWindowFocus';
-
-function setLastActivity() {
- lastActivity = Date.now();
-}
-
-function setLastFocus() {
- lastFocus = Date.now();
- localStorage[lastFocusKey] = lastFocus;
- setLastActivity();
-}
-
-setLastFocus();
-window.addEventListener('focus', setLastFocus);
-window.document.addEventListener('mousedown', setLastActivity);
-window.document.addEventListener('keydown', setLastActivity);
-
-export default {
- isFocused() {
- return parseInt(localStorage[lastFocusKey], 10) === lastFocus;
- },
- isActive() {
- return lastActivity > Date.now() - inactiveAfter && this.isFocused();
- },
-};
diff --git a/src/services/utils.js b/src/services/utils.js
index 35880e6e..eab990cf 100644
--- a/src/services/utils.js
+++ b/src/services/utils.js
@@ -1,14 +1,35 @@
import yaml from 'js-yaml';
import defaultProperties from '../data/defaultFileProperties.yml';
-// For sortObject
-const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
+const workspaceId = 'main';
// For uid()
+const uidLength = 16;
const crypto = window.crypto || window.msCrypto;
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const radix = alphabet.length;
-const array = new Uint32Array(16);
+const array = new Uint32Array(uidLength);
+
+// For isUserActive
+const inactiveAfter = 2 * 60 * 1000; // 2 minutes
+let lastActivity;
+const setLastActivity = () => {
+ lastActivity = Date.now();
+};
+window.document.addEventListener('mousedown', setLastActivity);
+window.document.addEventListener('keydown', setLastActivity);
+window.document.addEventListener('touchstart', setLastActivity);
+
+// For isWindowFocused
+let lastFocus;
+const lastFocusKey = `${workspaceId}/lastWindowFocus`;
+const setLastFocus = () => {
+ lastFocus = Date.now();
+ localStorage[lastFocusKey] = lastFocus;
+ setLastActivity();
+};
+setLastFocus();
+window.addEventListener('focus', setLastFocus);
// For addQueryParams()
const urlParser = window.document.createElement('a');
@@ -17,8 +38,18 @@ const urlParser = window.document.createElement('a');
const scriptLoadingPromises = Object.create(null);
export default {
+ workspaceId,
origin: `${location.protocol}//${location.host}`,
- types: ['contentState', 'syncedContent', 'content', 'file', 'folder', 'syncLocation', 'data'],
+ types: [
+ 'contentState',
+ 'syncedContent',
+ 'content',
+ 'file',
+ 'folder',
+ 'syncLocation',
+ 'publishLocation',
+ 'data',
+ ],
deepCopy(obj) {
return obj == null ? obj : JSON.parse(JSON.stringify(obj));
},
@@ -34,15 +65,6 @@ export default {
}, {});
});
},
- sortObject(obj, sortFunc = key => key) {
- const result = {};
- const compare = (key1, key2) => collator.compare(
- sortFunc(key1, obj[key1]), sortFunc(key2, obj[key2]));
- Object.keys(obj).sort(compare).forEach((key) => {
- result[key] = obj[key];
- });
- return result;
- },
uid() {
crypto.getRandomValues(array);
return array.cl_map(value => alphabet[value % radix]).join('');
@@ -84,6 +106,12 @@ export default {
setInterval(func, interval) {
return setInterval(() => func(), this.randomize(interval));
},
+ isWindowFocused() {
+ return parseInt(localStorage[lastFocusKey], 10) === lastFocus;
+ },
+ isUserActive() {
+ return lastActivity > Date.now() - inactiveAfter && this.isWindowFocused();
+ },
addQueryParams(url = '', params = {}) {
const keys = Object.keys(params).filter(key => params[key] != null);
if (!keys.length) {
@@ -166,7 +194,7 @@ export default {
event.data.state === state
) {
oauth2Context.clean();
- if (event.data.accessToken) {
+ if (event.data.accessToken || event.data.code) {
resolve(event.data);
} else {
reject(event.data);
@@ -259,7 +287,7 @@ export default {
// Add query params to URL
const url = this.addQueryParams(config.url, config.params);
- xhr.open(config.method, url);
+ xhr.open(config.method || 'GET', url);
Object.keys(config.headers).forEach((key) => {
xhr.setRequestHeader(key, config.headers[key]);
});
@@ -281,4 +309,24 @@ export default {
return attempt();
},
+ checkOnline() {
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ let timeout;
+ const cleaner = (cb, res) => () => {
+ clearTimeout(timeout);
+ cb(res);
+ document.head.removeChild(script);
+ };
+ script.onload = cleaner(resolve, 'Online.');
+ script.onerror = cleaner(reject, 'Offline.');
+ script.src = `https://apis.google.com/js/api.js?${Date.now()}`;
+ try {
+ document.head.appendChild(script); // This can fail with bad network
+ timeout = setTimeout(cleaner(reject), 15000); // 15 sec
+ } catch (e) {
+ reject(e);
+ }
+ });
+ },
};
diff --git a/src/store/index.js b/src/store/index.js
index b0ce215d..b963b357 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -7,11 +7,13 @@ import syncedContent from './modules/syncedContent';
import content from './modules/content';
import file from './modules/file';
import folder from './modules/folder';
+import publishLocation from './modules/publishLocation';
import syncLocation from './modules/syncLocation';
import data from './modules/data';
import layout from './modules/layout';
import explorer from './modules/explorer';
import modal from './modules/modal';
+import notification from './modules/notification';
import queue from './modules/queue';
Vue.use(Vuex);
@@ -44,21 +46,41 @@ const store = new Vuex.Store({
content,
file,
folder,
+ publishLocation,
syncLocation,
data,
layout,
explorer,
modal,
+ notification,
queue,
},
strict: debug,
plugins: debug ? [createLogger()] : [],
});
+let isConnectionDown = false;
+let lastConnectionCheck = 0;
+
function checkOffline() {
- const isOffline = window.navigator.onLine === false;
+ const isBrowserOffline = window.navigator.onLine === false;
+ if (!isBrowserOffline && lastConnectionCheck + 30000 < Date.now() && utils.isUserActive()) {
+ lastConnectionCheck = Date.now();
+ utils.checkOnline()
+ .then(() => {
+ isConnectionDown = false;
+ }, () => {
+ isConnectionDown = true;
+ });
+ }
+ const isOffline = isBrowserOffline || isConnectionDown;
if (isOffline !== store.state.offline) {
store.commit('setOffline', isOffline);
+ if (isOffline) {
+ store.dispatch('notification/info', 'You are offline.');
+ } else {
+ store.dispatch('notification/info', 'You are back online!');
+ }
}
}
utils.setInterval(checkOffline, 1000);
diff --git a/src/store/modules/data.js b/src/store/modules/data.js
index 3c30a30b..b26572f2 100644
--- a/src/store/modules/data.js
+++ b/src/store/modules/data.js
@@ -1,3 +1,4 @@
+import Vue from 'vue';
import yaml from 'js-yaml';
import moduleTemplate from './moduleTemplate';
import utils from '../../services/utils';
@@ -5,6 +6,7 @@ import defaultSettings from '../../data/defaultSettings.yml';
import defaultLocalSettings from '../../data/defaultLocalSettings';
import plainHtmlTemplate from '../../data/plainHtmlTemplate.html';
import styledHtmlTemplate from '../../data/styledHtmlTemplate.html';
+import jekyllSiteTemplate from '../../data/jekyllSiteTemplate.html';
const itemTemplate = (id, data = {}) => ({ id, type: 'data', data, hash: 0 });
@@ -20,6 +22,23 @@ const empty = (id) => {
};
const module = moduleTemplate(empty, true);
+module.mutations.setItem = (state, value) => {
+ const emptyItem = empty(value.id);
+ let itemData = value.data || emptyItem.data;
+ if (typeof itemData === 'object') {
+ itemData = Object.assign(emptyItem.data, value.data);
+ }
+ const item = {
+ ...emptyItem,
+ ...value,
+ data: itemData,
+ };
+ if (!item.hash) {
+ item.hash = Date.now();
+ }
+ Vue.set(state.itemMap, item.id, item);
+};
+
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) => {
@@ -86,8 +105,10 @@ const makeAdditionalTemplate = (name, value, helpers = '\n') => ({
isAdditional: true,
});
const additionalTemplates = {
+ plainText: makeAdditionalTemplate('Plain text', '{{{files.0.content.text}}}'),
plainHtml: makeAdditionalTemplate('Plain HTML', plainHtmlTemplate),
styledHtml: makeAdditionalTemplate('Styled HTML', styledHtmlTemplate),
+ jekyllSite: makeAdditionalTemplate('Jekyll site', jekyllSiteTemplate),
};
module.getters.allTemplates = (state, getters) => ({
...getters.templates,
@@ -154,6 +175,8 @@ module.actions.setSyncData = setter('syncData');
// Tokens
module.getters.tokens = getter('tokens');
module.getters.googleTokens = (state, getters) => getters.tokens.google || {};
+module.getters.dropboxTokens = (state, getters) => getters.tokens.dropbox || {};
+module.getters.githubTokens = (state, getters) => getters.tokens.github || {};
module.getters.loginToken = (state, getters) => {
// Return the first google token that has the isLogin flag
const googleTokens = getters.googleTokens;
@@ -170,5 +193,21 @@ module.actions.setGoogleToken = ({ getters, dispatch }, token) => {
},
});
};
+module.actions.setDropboxToken = ({ getters, dispatch }, token) => {
+ dispatch('patchTokens', {
+ dropbox: {
+ ...getters.dropboxTokens,
+ [token.sub]: token,
+ },
+ });
+};
+module.actions.setGithubToken = ({ getters, dispatch }, token) => {
+ dispatch('patchTokens', {
+ github: {
+ ...getters.githubTokens,
+ [token.sub]: token,
+ },
+ });
+};
export default module;
diff --git a/src/store/modules/explorer.js b/src/store/modules/explorer.js
index 3b78e799..8dd7b0a9 100644
--- a/src/store/modules/explorer.js
+++ b/src/store/modules/explorer.js
@@ -18,8 +18,9 @@ const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
const compare = (node1, node2) => collator.compare(node1.item.name, node2.item.name);
class Node {
- constructor(item, isFolder, isRoot) {
+ constructor(item, locations = [], isFolder = false, isRoot = false) {
this.item = item;
+ this.locations = locations;
this.isFolder = isFolder;
this.isRoot = isRoot;
if (isFolder) {
@@ -69,12 +70,16 @@ export default {
nodeStructure: (state, getters, rootState, rootGetters) => {
const nodeMap = {};
rootGetters['folder/items'].forEach((item) => {
- nodeMap[item.id] = new Node(item, true);
+ nodeMap[item.id] = new Node(item, [], true);
});
rootGetters['file/items'].forEach((item) => {
- nodeMap[item.id] = new Node(item);
+ const locations = [
+ ...rootGetters['syncLocation/groupedByFileId'][item.id] || [],
+ ...rootGetters['publishLocation/groupedByFileId'][item.id] || [],
+ ];
+ nodeMap[item.id] = new Node(item, locations);
});
- const rootNode = new Node(emptyFolder(), true, true);
+ const rootNode = new Node(emptyFolder(), [], true, true);
Object.keys(nodeMap).forEach((id) => {
const node = nodeMap[id];
let parentNode = nodeMap[node.item.parentId];
@@ -121,7 +126,7 @@ export default {
setDragSourceId: setter('dragSourceId'),
setDragTargetId: setter('dragTargetId'),
setNewItem(state, item) {
- state.newChildNode = item ? new Node(item, item.type === 'folder') : nilFileNode;
+ state.newChildNode = item ? new Node(item, [], item.type === 'folder') : nilFileNode;
},
setNewItemName(state, name) {
state.newChildNode.item.name = name;
diff --git a/src/store/modules/layout.js b/src/store/modules/layout.js
index c7b945c0..a9dceb4a 100644
--- a/src/store/modules/layout.js
+++ b/src/store/modules/layout.js
@@ -2,8 +2,13 @@ const editorMinWidth = 320;
const minPadding = 20;
const previewButtonWidth = 55;
const editorTopPadding = 10;
-const navigationBarSpaceWidth = 30;
-const navigationBarLeftWidth = 570;
+const navigationBarEditButtonsWidth = 36 * 12; // 12 buttons
+const navigationBarLeftButtonWidth = 38 + 4 + 15;
+const navigationBarRightButtonWidth = 38 + 8;
+const navigationBarSpinnerWidth = 18 + 15 + 5; // 5 for left margin
+const navigationBarLocationWidth = 20;
+const navigationBarSyncPublishButtonsWidth = 36 + 10;
+const navigationBarTitleMargin = 8;
const maxTitleMaxWidth = 800;
const minTitleMaxWidth = 200;
@@ -15,7 +20,7 @@ const constants = {
statusBarHeight: 20,
};
-function computeStyles(state, computedSettings, localSettings, styles = {
+function computeStyles(state, localSettings, getters, styles = {
showNavigationBar: !localSettings.showEditor || localSettings.showNavigationBar,
showStatusBar: localSettings.showStatusBar,
showEditor: localSettings.showEditor,
@@ -49,9 +54,10 @@ function computeStyles(state, computedSettings, localSettings, styles = {
if (styles.showSidePreview && doublePanelWidth / 2 < editorMinWidth) {
styles.showSidePreview = false;
styles.showPreview = false;
- return computeStyles(state, computedSettings, localSettings, styles);
+ return computeStyles(state, localSettings, getters, styles);
}
+ const computedSettings = getters['data/computedSettings'];
styles.fontSize = 18;
styles.textWidth = 990;
if (doublePanelWidth < 1120) {
@@ -89,9 +95,23 @@ function computeStyles(state, computedSettings, localSettings, styles = {
Math.floor((styles.editorWidth - styles.textWidth) / 2), minPadding);
styles.editorPadding = `${editorTopPadding}px ${editorSidePadding}px ${bottomPadding}px`;
- styles.titleMaxWidth = styles.innerWidth - navigationBarSpaceWidth;
+ styles.titleMaxWidth = styles.innerWidth;
if (styles.showEditor) {
- styles.titleMaxWidth -= navigationBarLeftWidth;
+ const syncLocations = getters['syncLocation/current'];
+ const publishLocations = getters['publishLocation/current'];
+ const isSyncPossible = getters['data/loginToken'] || syncLocations.length;
+ styles.titleMaxWidth = styles.innerWidth -
+ navigationBarEditButtonsWidth -
+ navigationBarLeftButtonWidth -
+ navigationBarRightButtonWidth -
+ navigationBarSpinnerWidth -
+ (navigationBarLocationWidth * (syncLocations.length + publishLocations.length)) -
+ (isSyncPossible ? navigationBarSyncPublishButtonsWidth : 0) -
+ (publishLocations.length ? navigationBarSyncPublishButtonsWidth : 0) -
+ navigationBarTitleMargin;
+ if (styles.titleMaxWidth + navigationBarEditButtonsWidth < minTitleMaxWidth) {
+ styles.hideLocations = true;
+ }
}
styles.titleMaxWidth = Math.min(styles.titleMaxWidth, maxTitleMaxWidth);
styles.titleMaxWidth = Math.max(styles.titleMaxWidth, minTitleMaxWidth);
@@ -113,9 +133,8 @@ export default {
getters: {
constants: () => constants,
styles: (state, getters, rootState, rootGetters) => {
- const computedSettings = rootGetters['data/computedSettings'];
const localSettings = rootGetters['data/localSettings'];
- return computeStyles(state, computedSettings, localSettings);
+ return computeStyles(state, localSettings, rootGetters);
},
},
};
diff --git a/src/store/modules/modal.js b/src/store/modules/modal.js
index 477d7b7d..eb301a6a 100644
--- a/src/store/modules/modal.js
+++ b/src/store/modules/modal.js
@@ -1,34 +1,47 @@
export default {
namespaced: true,
state: {
- config: null,
+ stack: [],
+ hidden: false,
},
mutations: {
- setConfig: (state, value) => {
- state.config = value;
+ setStack: (state, value) => {
+ state.stack = value;
+ },
+ setHidden: (state, value) => {
+ state.hidden = value;
},
},
+ getters: {
+ config: state => !state.hidden && state.stack[0],
+ },
actions: {
- open({ commit }, param) {
+ open({ commit, state }, param) {
return new Promise((resolve, reject) => {
- let config = param;
- if (typeof config === 'string') {
- config = {
- type: config,
- };
- }
+ const config = typeof param === 'object' ? { ...param } : { type: param };
+ const clean = () => commit('setStack', state.stack.filter((otherConfig => otherConfig !== config)));
config.resolve = (result) => {
if (config.onResolve) {
config.onResolve(result);
}
- commit('setConfig');
+ clean();
resolve(result);
};
config.reject = (error) => {
- commit('setConfig');
+ clean();
reject(error);
};
- commit('setConfig', config);
+ commit('setStack', [config, ...state.stack]);
+ });
+ },
+ hideUntil({ commit, state }, promise) {
+ commit('setHidden', true);
+ return promise.then((res) => {
+ commit('setHidden', false);
+ return res;
+ }, (err) => {
+ commit('setHidden', false);
+ throw err;
});
},
notImplemented: ({ dispatch }) => dispatch('open', {
diff --git a/src/store/modules/notification.js b/src/store/modules/notification.js
new file mode 100644
index 00000000..671146a4
--- /dev/null
+++ b/src/store/modules/notification.js
@@ -0,0 +1,49 @@
+const itemTimeout = 5000;
+
+export default {
+ namespaced: true,
+ state: {
+ items: [],
+ },
+ mutations: {
+ setItems: (state, value) => {
+ state.items = value;
+ },
+ },
+ actions: {
+ info({ state, commit }, info) {
+ const item = {
+ type: 'info',
+ content: info,
+ };
+ commit('setItems', [item, ...state.items]);
+ setTimeout(() =>
+ commit('setItems', state.items.filter(otherItem => otherItem !== item)), itemTimeout);
+ },
+ error({ state, commit, rootState }, error) {
+ const item = {
+ type: 'error',
+ };
+ if (error) {
+ if (error.message) {
+ item.content = error.message;
+ } else if (error.status) {
+ const location = rootState.queue.currentLocation;
+ if (location.providerId) {
+ item.content = `HTTP error ${error.status} on ${location.providerId} location.`;
+ } else {
+ item.content = `HTTP error ${error.status}.`;
+ }
+ } else {
+ item.content = error.toString();
+ }
+ }
+ if (!item.content || item.content === '[object Object]') {
+ item.content = 'Unknown error.';
+ }
+ commit('setItems', [item, ...state.items]);
+ setTimeout(() =>
+ commit('setItems', state.items.filter(otherItem => otherItem !== item)), itemTimeout);
+ },
+ },
+};
diff --git a/src/store/modules/publishLocation.js b/src/store/modules/publishLocation.js
new file mode 100644
index 00000000..fb797692
--- /dev/null
+++ b/src/store/modules/publishLocation.js
@@ -0,0 +1,34 @@
+import moduleTemplate from './moduleTemplate';
+import empty from '../../data/emptyPublishLocation';
+import providerRegistry from '../../services/providers/providerRegistry';
+
+const module = moduleTemplate(empty);
+
+module.getters = {
+ ...module.getters,
+ groupedByFileId: (state, getters) => {
+ const result = {};
+ getters.items.forEach((item) => {
+ // Filter items that we can't use
+ if (providerRegistry.providers[item.providerId].getToken(item)) {
+ const list = result[item.fileId] || [];
+ list.push(item);
+ result[item.fileId] = list;
+ }
+ });
+ return result;
+ },
+ current: (state, getters, rootState, rootGetters) => {
+ const locations = getters.groupedByFileId[rootGetters['file/current'].id] || [];
+ return locations.map((location) => {
+ const provider = providerRegistry.providers[location.providerId];
+ return {
+ ...location,
+ description: provider.getDescription(location),
+ url: provider.getUrl(location),
+ };
+ });
+ },
+};
+
+export default module;
diff --git a/src/store/modules/queue.js b/src/store/modules/queue.js
index 1c4b3e24..d1255c1b 100644
--- a/src/store/modules/queue.js
+++ b/src/store/modules/queue.js
@@ -9,26 +9,41 @@ export default {
state: {
isEmpty: true,
isSyncRequested: false,
+ isPublishRequested: false,
+ currentLocation: {},
},
mutations: {
setIsEmpty: setter('isEmpty'),
setIsSyncRequested: setter('isSyncRequested'),
+ setIsPublishRequested: setter('isPublishRequested'),
+ setCurrentLocation: setter('currentLocation'),
},
actions: {
- enqueue({ state, commit }, cb) {
+ enqueue({ state, commit, dispatch }, cb) {
+ const checkOffline = () => {
+ if (state.offline) {
+ // Empty queue
+ queue = Promise.resolve();
+ commit('setIsEmpty', true);
+ throw new Error('offline');
+ }
+ };
if (state.isEmpty) {
commit('setIsEmpty', false);
}
const newQueue = queue
- .then(cb)
- .catch((err) => {
- console.error(err);
- })
- .then(() => {
- if (newQueue === queue) {
- commit('setIsEmpty', true);
- }
- });
+ .then(() => checkOffline())
+ .then(() => cb()
+ .catch((err) => {
+ console.error(err); // eslint-disable-line no-console
+ checkOffline();
+ dispatch('notification/error', err, { root: true });
+ })
+ .then(() => {
+ if (newQueue === queue) {
+ commit('setIsEmpty', true);
+ }
+ }));
queue = newQueue;
},
enqueueSyncRequest({ state, commit, dispatch }, cb) {
@@ -41,5 +56,26 @@ export default {
}));
}
},
+ enqueuePublishRequest({ state, commit, dispatch }, cb) {
+ if (!state.isSyncRequested) {
+ commit('setIsPublishRequested', true);
+ const unset = () => commit('setIsPublishRequested', false);
+ dispatch('enqueue', () => cb().then(unset, (err) => {
+ unset();
+ throw err;
+ }));
+ }
+ },
+ doWithLocation({ state, commit, dispatch }, { location, promise }) {
+ commit('setCurrentLocation', location);
+ return promise
+ .then((res) => {
+ commit('setCurrentLocation', {});
+ return res;
+ }, (err) => {
+ commit('setCurrentLocation', {});
+ throw err;
+ });
+ },
},
};
diff --git a/src/store/modules/syncLocation.js b/src/store/modules/syncLocation.js
index 7a0d823d..d151573d 100644
--- a/src/store/modules/syncLocation.js
+++ b/src/store/modules/syncLocation.js
@@ -1,5 +1,6 @@
import moduleTemplate from './moduleTemplate';
import empty from '../../data/emptySyncLocation';
+import providerRegistry from '../../services/providers/providerRegistry';
const module = moduleTemplate(empty);
@@ -8,14 +9,26 @@ module.getters = {
groupedByFileId: (state, getters) => {
const result = {};
getters.items.forEach((item) => {
- const list = result[item.fileId] || [];
- list.push(item);
- result[item.fileId] = list;
+ // Filter items that we can't use
+ if (providerRegistry.providers[item.providerId].getToken(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] || [],
+ current: (state, getters, rootState, rootGetters) => {
+ const locations = getters.groupedByFileId[rootGetters['file/current'].id] || [];
+ return locations.map((location) => {
+ const provider = providerRegistry.providers[location.providerId];
+ return {
+ ...location,
+ description: provider.getDescription(location),
+ url: provider.getUrl(location),
+ };
+ });
+ },
};
export default module;
diff --git a/static/oauth2/callback.html b/static/oauth2/callback.html
index 6501a287..c3c21a4a 100644
--- a/static/oauth2/callback.html
+++ b/static/oauth2/callback.html
@@ -4,6 +4,7 @@
diff --git a/yarn.lock b/yarn.lock
index 594f7960..b92deaa8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -55,7 +55,7 @@ ajv@^4.11.2, ajv@^4.7.0, ajv@^4.9.1:
co "^4.6.0"
json-stable-stringify "^1.0.1"
-ajv@^5.0.0:
+ajv@^5.0.0, ajv@^5.1.0:
version "5.2.2"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.2.tgz#47c68d69e86f5d953103b0074a9430dc63da5e39"
dependencies:
@@ -243,7 +243,11 @@ aws-sign2@~0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f"
-aws4@^1.2.1:
+aws-sign2@~0.7.0:
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
+
+aws4@^1.2.1, aws4@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"
@@ -889,6 +893,18 @@ boom@2.x.x:
dependencies:
hoek "2.x.x"
+boom@4.x.x:
+ version "4.3.1"
+ resolved "https://registry.yarnpkg.com/boom/-/boom-4.3.1.tgz#4f8a3005cb4a7e3889f749030fd25b96e01d2e31"
+ dependencies:
+ hoek "4.x.x"
+
+boom@5.x.x:
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/boom/-/boom-5.2.0.tgz#5dd9da6ee3a5f302077436290cb717d3f4a54e02"
+ dependencies:
+ hoek "4.x.x"
+
brace-expansion@^1.0.0:
version "1.1.8"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292"
@@ -1443,6 +1459,12 @@ cryptiles@2.x.x:
dependencies:
boom "2.x.x"
+cryptiles@3.x.x:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-3.1.2.tgz#a89fbb220f5ce25ec56e8c4aa8a4fd7b5b0d29fe"
+ dependencies:
+ boom "5.x.x"
+
crypto-browserify@^3.11.0:
version "3.11.0"
resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.11.0.tgz#3652a0906ab9b2a7e0c3ce66a408e957a2485522"
@@ -2222,7 +2244,7 @@ express@^4.14.1, express@^4.15.2:
utils-merge "1.0.0"
vary "~1.1.1"
-extend@^3.0.0, extend@~3.0.0:
+extend@^3.0.0, extend@~3.0.0, extend@~3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444"
@@ -2419,6 +2441,14 @@ form-data@~2.1.1:
combined-stream "^1.0.5"
mime-types "^2.1.12"
+form-data@~2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.1.tgz#6fb94fbd71885306d73d15cc497fe4cc4ecd44bf"
+ dependencies:
+ asynckit "^0.4.0"
+ combined-stream "^1.0.5"
+ mime-types "^2.1.12"
+
forwarded@~0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363"
@@ -2778,6 +2808,10 @@ har-schema@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-1.0.5.tgz#d263135f43307c02c602afc8fe95970c0151369e"
+har-schema@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
+
har-validator@~4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-4.2.1.tgz#33481d0f1bbff600dd203d75812a6a5fba002e2a"
@@ -2785,6 +2819,13 @@ har-validator@~4.2.1:
ajv "^4.9.1"
har-schema "^1.0.5"
+har-validator@~5.0.3:
+ version "5.0.3"
+ resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.0.3.tgz#ba402c266194f15956ef15e0fcf242993f6a7dfd"
+ dependencies:
+ ajv "^5.1.0"
+ har-schema "^2.0.0"
+
has-ansi@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
@@ -2836,6 +2877,15 @@ hawk@~3.1.3:
hoek "2.x.x"
sntp "1.x.x"
+hawk@~6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/hawk/-/hawk-6.0.2.tgz#af4d914eb065f9b5ce4d9d11c1cb2126eecc3038"
+ dependencies:
+ boom "4.x.x"
+ cryptiles "3.x.x"
+ hoek "4.x.x"
+ sntp "2.x.x"
+
he@1.1.x, he@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"
@@ -2852,6 +2902,10 @@ hoek@2.x.x:
version "2.16.3"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed"
+hoek@4.x.x:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d"
+
home-or-tmp@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8"
@@ -2958,6 +3012,14 @@ http-signature@~1.1.0:
jsprim "^1.2.2"
sshpk "^1.7.0"
+http-signature@~1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
+ dependencies:
+ assert-plus "^1.0.0"
+ jsprim "^1.2.2"
+ sshpk "^1.7.0"
+
https-browserify@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.1.tgz#3f91365cabe60b77ed0ebba24b454e3e09d95a82"
@@ -3871,12 +3933,22 @@ mime-db@~1.27.0:
version "1.27.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.27.0.tgz#820f572296bbd20ec25ed55e5b5de869e5436eb1"
+mime-db@~1.30.0:
+ version "1.30.0"
+ resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01"
+
mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.15, mime-types@~2.1.7:
version "2.1.15"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.15.tgz#a4ebf5064094569237b8cf70046776d09fc92aed"
dependencies:
mime-db "~1.27.0"
+mime-types@~2.1.17:
+ version "2.1.17"
+ resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a"
+ dependencies:
+ mime-db "~1.30.0"
+
mime@1.3.4:
version "1.3.4"
resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.4.tgz#115f9e3b6b3daf2959983cb38f149a2d40eb5d53"
@@ -4197,7 +4269,7 @@ number-is-nan@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
-oauth-sign@~0.8.1:
+oauth-sign@~0.8.1, oauth-sign@~0.8.2:
version "0.8.2"
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43"
@@ -4479,6 +4551,10 @@ performance-now@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5"
+performance-now@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
+
pify@^2.0.0, pify@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
@@ -4917,6 +4993,10 @@ qs@6.4.0, qs@~6.4.0:
version "6.4.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233"
+qs@~6.5.1:
+ version "6.5.1"
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8"
+
query-string@^4.1.0:
version "4.3.4"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb"
@@ -5202,6 +5282,33 @@ request@2, request@^2.79.0, request@^2.81.0:
tunnel-agent "^0.6.0"
uuid "^3.0.0"
+request@^2.82.0:
+ version "2.82.0"
+ resolved "https://registry.yarnpkg.com/request/-/request-2.82.0.tgz#2ba8a92cd7ac45660ea2b10a53ae67cd247516ea"
+ dependencies:
+ aws-sign2 "~0.7.0"
+ aws4 "^1.6.0"
+ caseless "~0.12.0"
+ combined-stream "~1.0.5"
+ extend "~3.0.1"
+ forever-agent "~0.6.1"
+ form-data "~2.3.1"
+ har-validator "~5.0.3"
+ hawk "~6.0.2"
+ http-signature "~1.2.0"
+ is-typedarray "~1.0.0"
+ isstream "~0.1.2"
+ json-stringify-safe "~5.0.1"
+ mime-types "~2.1.17"
+ oauth-sign "~0.8.2"
+ performance-now "^2.1.0"
+ qs "~6.5.1"
+ safe-buffer "^5.1.1"
+ stringstream "~0.0.5"
+ tough-cookie "~2.3.2"
+ tunnel-agent "^0.6.0"
+ uuid "^3.1.0"
+
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
@@ -5293,7 +5400,7 @@ safe-buffer@^5.0.1, safe-buffer@~5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7"
-safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
@@ -5437,6 +5544,12 @@ sntp@1.x.x:
dependencies:
hoek "2.x.x"
+sntp@2.x.x:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/sntp/-/sntp-2.0.2.tgz#5064110f0af85f7cfdb7d6b67a40028ce52b4b2b"
+ dependencies:
+ hoek "4.x.x"
+
sort-keys@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
@@ -5604,7 +5717,7 @@ string_decoder@~1.0.3:
dependencies:
safe-buffer "~5.1.0"
-stringstream@~0.0.4:
+stringstream@~0.0.4, stringstream@~0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878"
@@ -5883,6 +5996,12 @@ tough-cookie@~2.3.0:
dependencies:
punycode "^1.4.1"
+tough-cookie@~2.3.2:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.3.tgz#0b618a5565b6dea90bf3425d04d55edc475a7561"
+ dependencies:
+ punycode "^1.4.1"
+
trim-newlines@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
@@ -6051,6 +6170,10 @@ uuid@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.0.1.tgz#6544bba2dfda8c1cf17e629a3a305e2bb1fee6c1"
+uuid@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04"
+
v8flags@^2.0.2:
version "2.1.1"
resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.1.1.tgz#aab1a1fa30d45f88dd321148875ac02c0b55e5b4"