Support for google drive actions

This commit is contained in:
Benoit Schweblin 2018-01-04 20:19:10 +00:00
parent 2f4781bc5d
commit d0ea2f7850
36 changed files with 388 additions and 166 deletions

View File

@ -16,7 +16,9 @@ module.exports = {
],
globals: {
"NODE_ENV": false,
"VERSION": false
"VERSION": false,
"GOOGLE_CLIENT_ID": false,
"GITHUB_CLIENT_ID": false
},
// check if imports actually resolve
'settings': {

View File

@ -4,6 +4,9 @@ var config = require('../config')
if (!process.env.NODE_ENV) {
process.env.NODE_ENV = JSON.parse(config.dev.env.NODE_ENV)
}
if (!process.env.GOOGLE_CLIENT_ID) {
process.env.GOOGLE_CLIENT_ID = JSON.parse(config.dev.env.GOOGLE_CLIENT_ID)
}
var opn = require('opn')
var path = require('path')

View File

@ -19,7 +19,9 @@ module.exports = merge(baseWebpackConfig, {
devtool: 'source-map',
plugins: [
new webpack.DefinePlugin({
NODE_ENV: config.dev.env.NODE_ENV
NODE_ENV: config.dev.env.NODE_ENV,
GOOGLE_CLIENT_ID: config.dev.env.GOOGLE_CLIENT_ID,
GITHUB_CLIENT_ID: config.dev.env.GITHUB_CLIENT_ID
}),
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage
new webpack.HotModuleReplacementPlugin(),

View File

@ -28,7 +28,9 @@ var webpackConfig = merge(baseWebpackConfig, {
plugins: [
// http://vuejs.github.io/vue-loader/en/workflow/production.html
new webpack.DefinePlugin({
NODE_ENV: env.NODE_ENV
NODE_ENV: env.NODE_ENV,
GOOGLE_CLIENT_ID: env.GOOGLE_CLIENT_ID,
GITHUB_CLIENT_ID: env.GITHUB_CLIENT_ID
}),
new webpack.optimize.UglifyJsPlugin({
compress: {

View File

@ -2,5 +2,7 @@ var merge = require('webpack-merge')
var prodEnv = require('./prod.env')
module.exports = merge(prodEnv, {
NODE_ENV: '"development"'
NODE_ENV: '"development"',
GOOGLE_CLIENT_ID: '"241271498917-c3loeet001r90q6u79q484bsh5clg4fr.apps.googleusercontent.com"',
GITHUB_CLIENT_ID: '"cbf0cf25cfd026be23e1"'
})

View File

@ -33,6 +33,7 @@ module.exports = {
// (https://github.com/webpack/css-loader#sourcemaps)
// In our experience, they generally work as expected,
// just be aware of this issue when enabling this option.
cssSourceMap: false
// cssSourceMap: false
cssSourceMap: true
}
}

View File

@ -1,3 +1,5 @@
module.exports = {
NODE_ENV: '"production"'
NODE_ENV: '"production"',
GOOGLE_CLIENT_ID: '"241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com"',
GITHUB_CLIENT_ID: '"30c1491057c9ad4dbd56"'
}

View File

@ -1,4 +1,11 @@
process.env.NODE_ENV = 'production';
const env = require('./config/prod.env');
if (!process.env.NODE_ENV) {
process.env.NODE_ENV = JSON.parse(env.NODE_ENV);
}
if (!process.env.GOOGLE_CLIENT_ID) {
process.env.GOOGLE_CLIENT_ID = JSON.parse(env.GOOGLE_CLIENT_ID);
}
const http = require('http');
const https = require('https');

View File

@ -44,8 +44,11 @@ module.exports = (app, serveV4) => {
/* eslint-enable global-require, import/no-unresolved */
}
// Serve callback.html in /app
// Serve callback.html
app.get('/oauth2/callback', (req, res) => res.sendFile(resolvePath('static/oauth2/callback.html')));
// Google Drive action receiver
app.get('/googleDriveAction', (req, res) =>
res.redirect(`./app#providerId=googleDrive&state=${encodeURIComponent(req.query.state)}`));
// Serve static resources
if (process.env.NODE_ENV === 'production') {

View File

@ -5,7 +5,7 @@ const verifier = require('google-id-token-verifier');
const BUCKET_NAME = process.env.USER_BUCKET_NAME || 'stackedit-users';
const PAYPAL_URI = process.env.PAYPAL_URI || 'https://www.paypal.com/cgi-bin/webscr';
const PAYPAL_RECEIVER_EMAIL = process.env.PAYPAL_RECEIVER_EMAIL || 'stackedit.project@gmail.com';
const GOOGLE_CLIENT_ID = '241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com';
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const s3Client = new AWS.S3();
const cb = (resolve, reject) => (err, res) => {

View File

@ -158,7 +158,7 @@ export default {
.side-bar__info {
padding: 10px;
margin: 0 -10px;
margin: -10px -10px 0;
background-color: $info-bg;
p {

View File

@ -88,6 +88,7 @@ export default {
<style lang="scss">
.toc__inner {
position: relative;
color: rgba(0, 0, 0, 0.75);
cursor: pointer;
font-size: 9px;
@ -147,7 +148,7 @@ export default {
left: 0;
width: 100%;
height: 35px;
background-color: rgba(0, 0, 0, 0.05);
background-color: rgba(255, 255, 255, 0.2);
pointer-events: none;
}
</style>

View File

@ -230,9 +230,9 @@ textarea {
color: rgba(0, 0, 0, 0.33);
position: absolute;
left: 0;
padding: 1px;
padding: 2px 3px 2px 0;
width: 20px;
height: 20px;
height: 21px;
line-height: 1;
&:active,

View File

@ -101,16 +101,18 @@ export default {
start, end,
}));
const isSticky = this.$el.parentNode.classList.contains('sticky-comment');
const isVisible = () => isSticky || this.$store.state.discussion.stickyComment === null;
this.$watch(
() => this.$store.state.discussion.currentDiscussionId,
() => this.$nextTick(() => {
if (this.$store.state.discussion.newCommentFocus) {
if (isVisible() && this.$store.state.discussion.newCommentFocus) {
clEditor.focus();
}
}),
{ immediate: true });
const isSticky = this.$el.parentNode.classList.contains('sticky-comment');
if (isSticky) {
let scrollerMirrorElt;
const getScrollerMirrorElt = () => {
@ -128,10 +130,10 @@ export default {
} else {
// Maintain the state with the sticky comment
this.$watch(
() => this.$store.state.discussion.stickyComment === null,
(isVisible) => {
clEditor.toggleEditable(isVisible);
if (isVisible) {
() => isVisible(),
(visible) => {
clEditor.toggleEditable(visible);
if (visible) {
const text = this.$store.state.discussion.newCommentText;
clEditor.setContent(text);
const selection = this.$store.state.discussion.newCommentSelection;

View File

@ -1,19 +1,14 @@
<template>
<div class="side-bar__panel side-bar__panel--menu">
<div v-if="!loginToken">
<div class="menu-info-entries" v-if="!loginToken">
<div class="menu-entry menu-entry--info flex flex--row flex--align-center">
<div class="menu-entry__icon menu-entry__icon--disabled">
<icon-sync-off></icon-sync-off>
</div>
<span><b>{{currentWorkspace.name}}</b> not synced.</span>
</div>
<menu-entry @click.native="signin">
<icon-login slot="icon"></icon-login>
<div>Sign in with Google</div>
<span>Back up and sync all your files, folders and settings.</span>
</menu-entry>
</div>
<div v-else>
<div class="menu-info-entries" v-else>
<div class="menu-entry menu-entry--info flex flex--row flex--align-center">
<div class="menu-entry__icon menu-entry__icon--image">
<user-image :user-id="loginToken.sub"></user-image>
@ -27,6 +22,11 @@
<span><b>{{currentWorkspace.name}}</b> synced.</span>
</div>
</div>
<menu-entry v-if="!loginToken" @click.native="signin">
<icon-login slot="icon"></icon-login>
<div>Sign in with Google</div>
<span>Back up and sync your main workspace.</span>
</menu-entry>
<menu-entry @click.native="setPanel('workspaces')">
<icon-database slot="icon"></icon-database>
<div>Workspaces</div>

View File

@ -30,18 +30,14 @@
</div>
</label>
<hr>
<menu-entry @click.native="about">
<icon-help-circle slot="icon"></icon-help-circle>
<span>About StackEdit</span>
</menu-entry>
<menu-entry @click.native="welcomeFile">
<icon-file slot="icon"></icon-file>
<span>Welcome file</span>
</menu-entry>
<menu-entry href="editor" target="_blank">
<icon-open-in-new slot="icon"></icon-open-in-new>
<span>StackEdit 4 (deprecated)</span>
</menu-entry>
<menu-entry @click.native="about">
<icon-help-circle slot="icon"></icon-help-circle>
<span>About StackEdit</span>
</menu-entry>
</div>
</template>
@ -49,7 +45,6 @@
import MenuEntry from './common/MenuEntry';
import backupSvc from '../../services/backupSvc';
import utils from '../../services/utils';
import welcomeFile from '../../data/welcomeFile.md';
export default {
components: {
@ -104,13 +99,6 @@ export default {
location.reload();
});
},
welcomeFile() {
return this.$store.dispatch('createFile', {
name: 'Welcome file',
text: welcomeFile,
})
.then(createdFile => this.$store.commit('file/setCurrentId', createdFile.id));
},
about() {
return this.$store.dispatch('modal/open', 'about');
},

View File

@ -42,10 +42,15 @@
}
}
.menu-info-entries {
padding: 10px;
margin: -10px -10px 10px;
background-color: rgba(255, 255, 255, 0.2);
}
.menu-entry--info {
padding-top: 0;
padding-bottom: 0;
margin: 10px 0;
padding-top: 3px;
padding-bottom: 3px;
}
.menu-entry__icon {

View File

@ -36,8 +36,8 @@ export default modalTemplate({
}),
computed: {
googlePhotosTokens() {
const googleToken = this.$store.getters['data/googleTokens'];
return Object.entries(googleToken)
const googleTokens = this.$store.getters['data/googleTokens'];
return Object.entries(googleTokens)
.filter(([, token]) => token.isPhotos)
.sort(([, token1], [, token2]) => token1.name.localeCompare(token2.name));
},

View File

@ -18,7 +18,7 @@
<button class="workspace-entry__button button" @click="edit(id)">
<icon-pen></icon-pen>
</button>
<button class="workspace-entry__button button" v-if="workspace !== currentWorkspace && workspace !== mainWorkspace" @click="remove(id)">
<button class="workspace-entry__button button" v-if="id !== currentWorkspace.id && id !== mainWorkspace.id" @click="remove(id)">
<icon-delete></icon-delete>
</button>
</div>

View File

@ -1,6 +1,6 @@
export default () => ({
main: {
name: 'Main workspace',
// The rest will be filled by the data/workspaces getter
// The rest will be filled by the data/sanitizedWorkspaces getter
},
});

View File

@ -1,5 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">
<path d="M13,9V3.5L18.5,9M6,2C4.89,2 4,2.89 4,4V20C4,21.1 4.9,22 6,22H18C19.1,22 20,21.1 20,20V8L14,2H6Z" />
</svg>
</template>

View File

@ -40,7 +40,6 @@ import Information from './Information';
import Alert from './Alert';
import SignalOff from './SignalOff';
import Folder from './Folder';
import File from './File';
import ScrollSync from './ScrollSync';
import Printer from './Printer';
import Undo from './Undo';
@ -91,7 +90,6 @@ Vue.component('iconInformation', Information);
Vue.component('iconAlert', Alert);
Vue.component('iconSignalOff', SignalOff);
Vue.component('iconFolder', Folder);
Vue.component('iconFile', File);
Vue.component('iconScrollSync', ScrollSync);
Vue.component('iconPrinter', Printer);
Vue.component('iconUndo', Undo);

View File

@ -158,12 +158,18 @@ function mergeContent(serverContent, clientContent, lastMergedContent = {}) {
const serverText = makePatchableText(serverContent, markerKeys, markerIdxMap);
const clientText = makePatchableText(clientContent, markerKeys, markerIdxMap);
const isServerTextChanges = lastMergedText !== serverText;
const isClientTextChanges = lastMergedText !== clientText;
const isTextSynchronized = serverText === clientText;
let text = clientText;
if (!isTextSynchronized && isServerTextChanges) {
text = serverText;
if (isClientTextChanges) {
text = mergeText(serverText, clientText, lastMergedText);
}
}
const result = {
text: isTextSynchronized || !isServerTextChanges
? clientText
: mergeText(serverText, clientText, lastMergedText),
text,
properties: mergeValues(
serverContent.properties,
clientContent.properties,

View File

@ -6,6 +6,7 @@ import welcomeFile from '../data/welcomeFile.md';
const dbVersion = 1;
const dbStoreName = 'objects';
const exportWorkspace = utils.queryParams.exportWorkspace;
const resetApp = utils.queryParams.reset;
const deleteMarkerMaxAge = 1000;
const checkSponsorshipAfter = (5 * 60 * 1000) + (30 * 1000); // tokenExpirationMargin + 30 sec
@ -105,7 +106,7 @@ const localDbSvc = {
return Promise.resolve()
.then(() => {
// Reset the app if reset flag was passed
if (utils.queryParams.reset) {
if (resetApp) {
return Promise.all(
Object.keys(store.getters['data/workspaces'])
.map(workspaceId => localDbSvc.removeWorkspace(workspaceId)),
@ -115,7 +116,7 @@ const localDbSvc = {
localStorage.removeItem(`data/${id}`);
}))
.then(() => {
location.replace(utils.resolveUrl('app'));
location.reload();
throw new Error('reload');
});
}
@ -177,33 +178,29 @@ const localDbSvc = {
// Sync local DB periodically
utils.setInterval(() => localDbSvc.sync(), 1000);
const ifNoId = cb => (obj) => {
if (obj.id) {
return obj;
}
return cb();
};
// watch current file changing
store.watch(
() => store.getters['file/current'].id,
() => Promise.resolve(store.getters['file/current'])
() => {
// See if currentFile is real, ie it has an ID
const currentFile = 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(() => store.dispatch('createFile', {
name: 'Welcome file',
text: welcomeFile,
})))
.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;
if (!currentFile.id) {
const recentFile = store.getters['file/lastOpened'];
// Set it as the current file
if (recentFile.id) {
store.commit('file/setCurrentId', recentFile.id);
} else {
// If still no ID, create a new file
store.dispatch('createFile', {
name: 'Welcome file',
text: welcomeFile,
})
// Set it as the current file
.then(newFile => store.commit('file/setCurrentId', newFile.id));
}
return Promise.resolve()
} else {
Promise.resolve()
// Load contentState from DB
.then(() => localDbSvc.loadContentState(currentFile.id))
// Load syncedContent from DB
@ -226,13 +223,13 @@ const localDbSvc = {
store.commit('file/setCurrentId', lastOpenedFile.id);
throw err;
},
);
})
.catch((err) => {
console.error(err); // eslint-disable-line no-console
store.dispatch('notification/error', err);
}),
{
)
.catch((err) => {
console.error(err); // eslint-disable-line no-console
store.dispatch('notification/error', err);
});
}
}, {
immediate: true,
});
});

View File

@ -99,11 +99,14 @@ function enterKeyHandler(evt, state) {
}
evt.preventDefault();
const lf = state.before.lastIndexOf('\n') + 1;
const previousLine = state.before.slice(lf);
const indentMatch = previousLine.match(indentRegexp) || [''];
// Get the last line before the selection
const lastLf = state.before.lastIndexOf('\n') + 1;
const lastLine = state.before.slice(lastLf);
// See if the line is indented
const indentMatch = lastLine.match(indentRegexp) || [''];
if (clearNewline && !state.selection && state.before.length === lastSelection) {
state.before = state.before.substring(0, lf);
state.before = state.before.substring(0, lastLf);
state.selection = '';
clearNewline = false;
fixNumberedList(state, indentMatch[1]);
@ -135,13 +138,14 @@ function tabKeyHandler(evt, state) {
evt.preventDefault();
const isInverse = evt.shiftKey;
const lf = state.before.lastIndexOf('\n') + 1;
const previousLine = state.before.slice(lf) + state.selection + state.after;
const indentMatch = previousLine.match(indentRegexp);
const lastLf = state.before.lastIndexOf('\n') + 1;
const lastLine = state.before.slice(lastLf);
const currentLine = lastLine + state.selection + state.after;
const indentMatch = currentLine.match(indentRegexp);
if (isInverse) {
const previousChar = state.before.slice(-1);
if (/\s/.test(state.before.charAt(lf))) {
state.before = strSplice(state.before, lf, 1);
if (/\s/.test(state.before.charAt(lastLf))) {
state.before = strSplice(state.before, lastLf, 1);
if (indentMatch) {
fixNumberedList(state, indentMatch[1]);
if (indentMatch[1]) {
@ -154,8 +158,13 @@ function tabKeyHandler(evt, state) {
if (previousChar) {
state.selection = state.selection.slice(1);
}
} else if (state.selection || indentMatch) {
state.before = strSplice(state.before, lf, 0, '\t');
} else if (
// If selection is not empty
state.selection
// Or we are in an indented paragraph and the cursor is over the indentation characters
|| (indentMatch && indentMatch[0].length >= lastLine.length)
) {
state.before = strSplice(state.before, lastLf, 0, '\t');
state.selection = state.selection.replace(/\n(?=.)/g, '\n\t');
if (indentMatch) {
fixNumberedList(state, indentMatch[1]);

View File

@ -1,6 +1,7 @@
import store from '../../store';
import googleHelper from './helpers/googleHelper';
import providerRegistry from './providerRegistry';
import utils from '../utils';
export default providerRegistry.register({
id: 'googleDriveAppData',
@ -8,8 +9,14 @@ export default providerRegistry.register({
return store.getters['workspace/syncToken'];
},
initWorkspace() {
// Nothing to do since the main workspace isn't necessarily synchronized
return Promise.resolve(store.getters['data/workspaces'].main);
// Nothing much to do since the main workspace isn't necessarily synchronized
return Promise.resolve()
.then(() => {
// Remove the URL hash
utils.setQueryParams();
// Return the main workspace
return store.getters['data/workspaces'].main;
});
},
getChanges() {
const syncToken = store.getters['workspace/syncToken'];

View File

@ -17,6 +17,94 @@ export default providerRegistry.register({
const token = this.getToken(location);
return `${location.driveFileId}${token.name}`;
},
initAction() {
const state = googleHelper.driveState || {};
return state.userId && Promise.resolve()
.then(() => {
// Try to find the token corresponding to the user ID
const token = store.getters['data/googleTokens'][state.userId];
// If not found or not enough permission, popup an OAuth2 window
return token && token.isDrive ? token : store.dispatch('modal/open', {
type: 'googleDriveAccount',
onResolve: () => googleHelper.addDriveAccount(
!store.getters['data/localSettings'].googleDriveRestrictedAccess,
state.userId,
),
});
})
.then((token) => {
switch (state.action) {
case 'create':
default:
// See if folder is part of a workspace we can open
return googleHelper.getFile(token, state.folderId)
.then((folder) => {
folder.appProperties = folder.appProperties || {};
googleHelper.driveActionFolder = folder;
if (folder.appProperties.folderId) {
// Change current URL to workspace URL
utils.setQueryParams({
providerId: 'googleDriveWorkspace',
folderId: folder.appProperties.folderId,
sub: state.userId,
});
}
}, (err) => {
if (!err || err.status !== 404) {
throw err;
}
// We received a 404 error meaning we have no permission to read the folder
googleHelper.driveActionFolder = { id: state.folderId };
});
case 'open': {
const getOneFile = (ids) => {
const id = ids.shift();
return id && googleHelper.getFile(token, id)
.then((file) => {
file.appProperties = file.appProperties || {};
googleHelper.driveActionFiles.push(file);
return getOneFile(ids);
});
};
return getOneFile(state.ids || [])
.then(() => {
// Check if first file is part of a workspace
const firstFile = googleHelper.driveActionFiles[0];
if (firstFile && firstFile.appProperties && firstFile.appProperties.folderId) {
// Change current URL to workspace URL
utils.setQueryParams({
providerId: 'googleDriveWorkspace',
folderId: firstFile.appProperties.folderId,
sub: state.userId,
});
}
});
}
}
});
},
performAction() {
const state = googleHelper.driveState || {};
const token = store.getters['data/googleTokens'][state.userId];
return token && Promise.resolve()
.then(() => {
switch (state.action) {
case 'create':
default:
return store.dispatch('createFile')
.then((file) => {
store.commit('file/setCurrentId', file.id);
// Return a new syncLocation
return this.makeLocation(token, null, googleHelper.driveActionFolder.id);
});
case 'open':
return store.dispatch('queue/enqueue',
() => this.openFiles(token, googleHelper.driveActionFiles));
}
});
},
downloadContent(token, syncLocation) {
return googleHelper.downloadFile(token, syncLocation.driveFileId)
.then(content => providerUtils.parseContent(content, syncLocation));
@ -60,7 +148,7 @@ export default providerRegistry.register({
},
openFiles(token, driveFiles) {
const openOneFile = () => {
const driveFile = driveFiles.pop();
const driveFile = driveFiles.shift();
if (!driveFile) {
return null;
}

View File

@ -4,18 +4,24 @@ import providerRegistry from './providerRegistry';
import providerUtils from './providerUtils';
import utils from '../utils';
let fileIdToOpen;
export default providerRegistry.register({
id: 'googleDriveWorkspace',
getToken() {
return store.getters['workspace/syncToken'];
},
initWorkspace() {
const makeWorkspaceId = folderId => folderId && Math.abs(utils.hash(utils.serializeObject({
const makeWorkspaceIdParams = folderId => ({
providerId: this.id,
folderId,
}))).toString(36);
});
const getWorkspace = folderId => store.getters['data/workspaces'][makeWorkspaceId(folderId)];
const makeWorkspaceId = folderId => folderId && utils.makeWorkspaceId(
makeWorkspaceIdParams(folderId));
const getWorkspace = folderId =>
store.getters['data/sanitizedWorkspaces'][makeWorkspaceId(folderId)];
const initFolder = (token, folder) => Promise.resolve({
folderId: folder.id,
@ -78,12 +84,6 @@ export default providerRegistry.register({
.then(() => properties);
})
.then((properties) => {
// Fix the current url hash
const hash = `#providerId=${this.id}&folderId=${folder.id}`;
if (location.hash !== hash) {
location.hash = hash;
}
// Update workspace in the store
const workspaceId = makeWorkspaceId(folder.id);
store.dispatch('data/patchWorkspaces', {
@ -92,7 +92,7 @@ export default providerRegistry.register({
sub: token.sub,
name: folder.name,
providerId: this.id,
url: utils.resolveUrl(hash),
url: location.href,
folderId: folder.id,
dataFolderId: properties.dataFolderId,
trashFolderId: properties.trashFolderId,
@ -103,9 +103,9 @@ export default providerRegistry.register({
return getWorkspace(folder.id);
});
const workspace = getWorkspace(utils.queryParams.folderId);
return Promise.resolve()
.then(() => {
const workspace = getWorkspace(utils.queryParams.folderId);
// See if we already have a token
const googleTokens = store.getters['data/googleTokens'];
// Token sub is in the workspace or in the url if workspace is about to be created
@ -115,7 +115,7 @@ export default providerRegistry.register({
}
// If no token has been found, popup an authorize window and get one
return store.dispatch('modal/workspaceGoogleRedirection', {
onResolve: () => googleHelper.addDriveAccount(true),
onResolve: () => googleHelper.addDriveAccount(true, utils.queryParams.sub),
});
})
.then(token => Promise.resolve()
@ -139,12 +139,70 @@ export default providerRegistry.register({
folder.appProperties = folder.appProperties || {};
const folderIdProperty = folder.appProperties.folderId;
if (folderIdProperty && folderIdProperty !== folderId) {
throw new Error(`Google Drive folder ${folderId} is part of another workspace.`);
throw new Error(`Folder ${folderId} is part of another workspace.`);
}
return initFolder(token, folder);
}, () => {
throw new Error(`Folder ${folderId} is not accessible. Make sure you have the right permissions.`);
})));
}))
.then((workspace) => {
// Fix the URL hash
utils.setQueryParams(makeWorkspaceIdParams(workspace.folderId));
return workspace;
}));
},
performAction() {
return Promise.resolve()
.then(() => {
const state = googleHelper.driveState || {};
const token = this.getToken();
switch (token && state.action) {
case 'create':
return Promise.resolve()
.then(() => {
const driveFolder = googleHelper.driveActionFolder;
let syncData = store.getters['data/syncData'][driveFolder.id];
if (!syncData && driveFolder.appProperties.id) {
// Create folder if not already synced
store.commit('folder/setItem', {
id: driveFolder.appProperties.id,
name: driveFolder.name,
});
const item = store.state.folder.itemMap[driveFolder.appProperties.id];
syncData = {
id: driveFolder.id,
itemId: item.id,
type: item.type,
hash: item.hash,
};
store.dispatch('data/patchSyncData', {
[syncData.id]: syncData,
});
}
return store.dispatch('createFile', {
parentId: syncData && syncData.itemId,
})
.then((file) => {
store.commit('file/setCurrentId', file.id);
// File will be created on next workspace sync
});
});
case 'open':
return Promise.resolve()
.then(() => {
// open first file only
const firstFile = googleHelper.driveActionFiles[0];
const syncData = store.getters['data/syncData'][firstFile.id];
if (!syncData) {
fileIdToOpen = firstFile.id;
} else {
store.commit('file/setCurrentId', syncData.itemId);
}
});
default:
return null;
}
});
},
getChanges() {
const workspace = store.getters['workspace/currentWorkspace'];
@ -178,8 +236,8 @@ export default providerRegistry.register({
let contentChange;
if (change.file) {
// Ignore changes in files that are not in the workspace
const properties = change.file.appProperties;
if (!properties || properties.folderId !== workspace.folderId
const appProperties = change.file.appProperties;
if (!appProperties || appProperties.folderId !== workspace.folderId
) {
return;
}
@ -198,7 +256,7 @@ export default providerRegistry.register({
? 'folder'
: 'file';
const item = {
id: properties.id,
id: appProperties.id,
type,
name: change.file.name,
parentId: null,
@ -222,14 +280,14 @@ export default providerRegistry.register({
// create a fake change as a file content change
contentChange = {
item: {
id: `${properties.id}/content`,
id: `${appProperties.id}/content`,
type: 'content',
// Need a truthy value to force saving sync data
hash: 1,
},
syncData: {
id: `${change.fileId}/content`,
itemId: `${properties.id}/content`,
itemId: `${appProperties.id}/content`,
type: 'content',
// Need a truthy value to force downloading the content
hash: 1,
@ -341,6 +399,14 @@ export default providerRegistry.register({
},
});
}
// Open the file requested by action if it was to synced yet
if (fileIdToOpen && fileIdToOpen === syncData.id) {
fileIdToOpen = null;
// Open the file once downloaded content has been stored
setTimeout(() => {
store.commit('file/setCurrentId', syncData.itemId);
}, 10);
}
return item;
});
},

View File

@ -2,10 +2,7 @@ import utils from '../../utils';
import networkSvc from '../../networkSvc';
import store from '../../../store';
let clientId = 'cbf0cf25cfd026be23e1';
if (utils.origin === 'https://stackedit.io') {
clientId = '30c1491057c9ad4dbd56';
}
const clientId = GITHUB_CLIENT_ID;
const getScopes = token => [token.repoFullAccess ? 'repo' : 'public_repo', 'gist'];
const request = (token, options) => networkSvc.request({

View File

@ -2,7 +2,7 @@ import utils from '../../utils';
import networkSvc from '../../networkSvc';
import store from '../../../store';
const clientId = '241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com';
const clientId = GOOGLE_CLIENT_ID;
const apiKey = 'AIzaSyC_M4RA9pY6XmM9pmFxlT59UPMO7aHr9kk';
const appsDomain = null;
const tokenExpirationMargin = 5 * 60 * 1000; // 5 min (Google tokens expire after 1h)
@ -29,8 +29,20 @@ const checkIdToken = (idToken) => {
}
};
let driveState;
if (utils.queryParams.providerId === 'googleDrive') {
try {
driveState = JSON.parse(utils.queryParams.state);
} catch (e) {
// Ignore
}
}
export default {
folderMimeType: 'application/vnd.google-apps.folder',
driveState,
driveActionFolder: null,
driveActionFiles: [],
request(token, options) {
return networkSvc.request({
...options,
@ -336,8 +348,8 @@ export default {
signin() {
return this.startOauth2(driveAppDataScopes);
},
addDriveAccount(fullAccess = false) {
return this.startOauth2(getDriveScopes({ driveFullAccess: fullAccess }));
addDriveAccount(fullAccess = false, sub = null) {
return this.startOauth2(getDriveScopes({ driveFullAccess: fullAccess }), sub);
},
addBloggerAccount() {
return this.startOauth2(bloggerScopes);

View File

@ -11,7 +11,8 @@ const inactivityThreshold = 3 * 1000; // 3 sec
const restartSyncAfter = 30 * 1000; // 30 sec
const minAutoSyncEvery = 60 * 1000; // 60 sec
let syncProvider;
let actionProvider;
let workspaceProvider;
/**
* Use a lock in the local storage to prevent multiple windows concurrency.
@ -221,7 +222,7 @@ function syncFile(fileId, syncContext = new SyncContext()) {
...store.getters['syncLocation/groupedByFileId'][fileId] || [],
];
if (isWorkspaceSyncPossible()) {
syncLocations.unshift({ id: 'main', providerId: syncProvider.id, fileId });
syncLocations.unshift({ id: 'main', providerId: workspaceProvider.id, fileId });
}
let result;
syncLocations.some((syncLocation) => {
@ -355,7 +356,7 @@ function syncFile(fileId, syncContext = new SyncContext()) {
}
// If content was just created, restart sync to create the file as well
if (provider === syncProvider &&
if (provider === workspaceProvider &&
!store.getters['data/syncDataByItemId'][fileId]
) {
syncContext.restart = true;
@ -409,7 +410,7 @@ function syncDataItem(dataId) {
return null;
}
return syncProvider.downloadData(dataId)
return workspaceProvider.downloadData(dataId)
.then((serverItem = null) => {
const dataSyncData = store.getters['data/dataSyncData'][dataId];
let mergedItem = (() => {
@ -455,7 +456,7 @@ function syncDataItem(dataId) {
if (serverItem && serverItem.hash === mergedItem.hash) {
return null;
}
return syncProvider.uploadData(mergedItem, dataId);
return workspaceProvider.uploadData(mergedItem, dataId);
})
.then(() => {
store.dispatch('data/patchDataSyncData', {
@ -485,11 +486,11 @@ function syncWorkspace() {
throw new Error('Synchronization failed due to token inconsistency.');
}
})
.then(() => syncProvider.getChanges())
.then(() => workspaceProvider.getChanges())
.then((changes) => {
// Apply changes
applyChanges(changes);
syncProvider.setAppliedChanges(changes);
workspaceProvider.setAppliedChanges(changes);
// Prevent from sending items too long after changes have been retrieved
const syncStartTime = Date.now();
@ -519,7 +520,7 @@ function syncWorkspace() {
// Add file if content has been added
&& (item.type !== 'file' || syncDataByItemId[`${id}/content`])
) {
promise = syncProvider.saveSimpleItem(
promise = workspaceProvider.saveSimpleItem(
// Use deepCopy to freeze objects
utils.deepCopy(item),
utils.deepCopy(existingSyncData),
@ -555,7 +556,7 @@ function syncWorkspace() {
) {
// Use deepCopy to freeze objects
const syncDataToRemove = utils.deepCopy(existingSyncData);
promise = syncProvider
promise = workspaceProvider
.removeItem(syncDataToRemove, ifNotTooLate)
.then(() => {
const syncDataCopy = { ...store.getters['data/syncData'] };
@ -707,25 +708,38 @@ function requestSync() {
export default {
init() {
// Load workspaces and tokens from localStorage
localDbSvc.syncLocalStorage();
return Promise.resolve()
.then(() => {
// Load workspaces and tokens from localStorage
localDbSvc.syncLocalStorage();
// Try to find a suitable workspace sync provider
syncProvider = providerRegistry.providers[utils.queryParams.providerId];
if (!syncProvider || !syncProvider.initWorkspace) {
syncProvider = googleDriveAppDataProvider;
}
return syncProvider.initWorkspace()
// Try to find a suitable action provider
actionProvider = providerRegistry.providers[utils.queryParams.providerId];
return actionProvider && actionProvider.initAction && actionProvider.initAction();
})
.then(() => {
// Try to find a suitable workspace sync provider
workspaceProvider = providerRegistry.providers[utils.queryParams.providerId];
if (!workspaceProvider || !workspaceProvider.initWorkspace) {
workspaceProvider = googleDriveAppDataProvider;
}
return workspaceProvider.initWorkspace();
})
.then(workspace => store.dispatch('workspace/setCurrentWorkspaceId', workspace.id))
.then(() => localDbSvc.init())
.then(() => {
// Try to find a suitable action provider
actionProvider = providerRegistry.providers[utils.queryParams.providerId] || actionProvider;
return actionProvider && actionProvider.performAction && actionProvider.performAction()
.then(newSyncLocation => newSyncLocation && this.createSyncLocation(newSyncLocation));
})
.then(() => {
// Sync periodically
utils.setInterval(() => {
if (isSyncPossible() &&
networkSvc.isUserActive() &&
isSyncWindow() &&
isAutoSyncReady()
if (isSyncPossible()
&& networkSvc.isUserActive()
&& isSyncWindow()
&& isAutoSyncReady()
) {
requestSync();
}

View File

@ -24,13 +24,22 @@ const parseQueryParams = (params) => {
};
// For utils.addQueryParams()
const urlParser = window.document.createElement('a');
const urlParser = document.createElement('a');
export default {
origin,
queryParams: parseQueryParams(location.hash.slice(1)),
oauth2RedirectUri: `${origin}/oauth2/callback`,
cleanTrashAfter: 7 * 24 * 60 * 60 * 1000, // 7 days
origin,
oauth2RedirectUri: `${origin}/oauth2/callback`,
queryParams: parseQueryParams(location.hash.slice(1)),
setQueryParams(params = {}) {
this.queryParams = params;
const serializedParams = Object.entries(this.queryParams).map(([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&');
const hash = serializedParams && `#${serializedParams}`;
if (location.hash !== hash) {
location.hash = hash;
}
},
types: [
'contentState',
'syncedContent',
@ -96,6 +105,9 @@ export default {
})),
};
},
makeWorkspaceId(params) {
return Math.abs(this.hash(this.serializeObject(params))).toString(36);
},
encodeBase64(str) {
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
(match, p1) => String.fromCharCode(`0x${p1}`)));

View File

@ -117,11 +117,11 @@ export default {
},
},
getters: {
workspaces: (state, getters, rootState, rootGetters) => {
const workspaces = (state.lsItemMap.workspaces || {}).data || empty('workspaces').data;
workspaces: getter('workspaces'),
sanitizedWorkspaces: (state, getters, rootState, rootGetters) => {
const sanitizedWorkspaces = {};
const mainWorkspaceToken = rootGetters['workspace/mainWorkspaceToken'];
Object.entries(workspaces).forEach(([id, workspace]) => {
Object.entries(getters.workspaces).forEach(([id, workspace]) => {
const sanitizedWorkspace = {
id,
providerId: mainWorkspaceToken && 'googleDriveAppData',

View File

@ -89,7 +89,7 @@ const store = new Vuex.Store({
}
return Promise.resolve();
},
createFile({ state, getters, commit }, desc) {
createFile({ state, getters, commit }, desc = {}) {
const id = utils.uid();
commit('content/setItem', {
id: `${id}/content`,

View File

@ -23,6 +23,7 @@ export default {
config.resolve = (result) => {
clean();
if (config.onResolve) {
// Call onResolve immediately (mostly to prevent browsers from blocking popup windows)
config.onResolve(result)
.then(res => resolve(res));
} else {
@ -92,8 +93,8 @@ export default {
onResolve,
}),
workspaceGoogleRedirection: ({ dispatch }, { onResolve }) => dispatch('open', {
content: '<p>You have to sign in with Google to access this workspace.</p>',
resolveText: 'Ok, sign in',
content: '<p>StackEdit needs full Google Drive access to open this workspace.</p>',
resolveText: 'Ok, grant',
rejectText: 'Cancel',
onResolve,
}),
@ -107,14 +108,14 @@ export default {
}),
signInForComment: ({ dispatch }, { onResolve }) => dispatch('open', {
content: `<p>You have to sign in with Google to start commenting.</p>
<div class="modal__info"><b>Note:</b> This will sync all your files and settings.</div>`,
<div class="modal__info"><b>Note:</b> This will sync your main workspace.</div>`,
resolveText: 'Ok, sign in',
rejectText: 'Cancel',
onResolve,
}),
signInForHistory: ({ dispatch }, { onResolve }) => dispatch('open', {
content: `<p>You have to sign in with Google to enable revision history.</p>
<div class="modal__info"><b>Note:</b> This will sync all your files and settings.</div>`,
<div class="modal__info"><b>Note:</b> This will sync your main workspace.</div>`,
resolveText: 'Ok, sign in',
rejectText: 'Cancel',
onResolve,

View File

@ -14,11 +14,11 @@ export default {
},
getters: {
mainWorkspace: (state, getters, rootState, rootGetters) => {
const workspaces = rootGetters['data/workspaces'];
const workspaces = rootGetters['data/sanitizedWorkspaces'];
return workspaces.main;
},
currentWorkspace: (state, getters, rootState, rootGetters) => {
const workspaces = rootGetters['data/workspaces'];
const workspaces = rootGetters['data/sanitizedWorkspaces'];
return workspaces[state.currentWorkspaceId] || getters.mainWorkspace;
},
lastSyncActivityKey: (state, getters) => `${getters.currentWorkspace.id}/lastSyncActivity`,