From d0ea2f785044bae7f375a23a826573ea2d3c35e8 Mon Sep 17 00:00:00 2001 From: Benoit Schweblin Date: Thu, 4 Jan 2018 20:19:10 +0000 Subject: [PATCH] Support for google drive actions --- .eslintrc.js | 4 +- build/dev-server.js | 3 + build/webpack.dev.conf.js | 4 +- build/webpack.prod.conf.js | 4 +- config/dev.env.js | 4 +- config/index.js | 3 +- config/prod.env.js | 4 +- index.js | 9 +- server/index.js | 5 +- server/user.js | 2 +- src/components/SideBar.vue | 2 +- src/components/Toc.vue | 3 +- src/components/common/app.scss | 4 +- src/components/gutters/NewComment.vue | 14 ++- src/components/menus/MainMenu.vue | 14 +-- src/components/menus/MoreMenu.vue | 20 +--- src/components/menus/common/MenuEntry.vue | 11 +- src/components/modals/ImageModal.vue | 4 +- .../modals/WorkspaceManagementModal.vue | 2 +- src/data/defaultWorkspaces.js | 2 +- src/icons/File.vue | 5 - src/icons/index.js | 2 - src/services/diffUtils.js | 12 +- src/services/localDbSvc.js | 59 +++++----- src/services/optional/keystrokes.js | 31 ++++-- .../providers/googleDriveAppDataProvider.js | 11 +- src/services/providers/googleDriveProvider.js | 90 ++++++++++++++- .../providers/googleDriveWorkspaceProvider.js | 104 ++++++++++++++---- .../providers/helpers/githubHelper.js | 5 +- .../providers/helpers/googleHelper.js | 18 ++- src/services/syncSvc.js | 58 ++++++---- src/services/utils.js | 20 +++- src/store/data.js | 6 +- src/store/index.js | 2 +- src/store/modal.js | 9 +- src/store/workspace.js | 4 +- 36 files changed, 388 insertions(+), 166 deletions(-) delete mode 100644 src/icons/File.vue diff --git a/.eslintrc.js b/.eslintrc.js index 3c6c4baf..2eb86f64 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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': { diff --git a/build/dev-server.js b/build/dev-server.js index fbae2240..0ca0c731 100644 --- a/build/dev-server.js +++ b/build/dev-server.js @@ -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') diff --git a/build/webpack.dev.conf.js b/build/webpack.dev.conf.js index 6ec2eef5..fbebcecd 100644 --- a/build/webpack.dev.conf.js +++ b/build/webpack.dev.conf.js @@ -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(), diff --git a/build/webpack.prod.conf.js b/build/webpack.prod.conf.js index 7764829a..f72e9df5 100644 --- a/build/webpack.prod.conf.js +++ b/build/webpack.prod.conf.js @@ -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: { diff --git a/config/dev.env.js b/config/dev.env.js index efead7c8..6b882aa6 100644 --- a/config/dev.env.js +++ b/config/dev.env.js @@ -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"' }) diff --git a/config/index.js b/config/index.js index 196da1fa..b5211e47 100644 --- a/config/index.js +++ b/config/index.js @@ -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 } } diff --git a/config/prod.env.js b/config/prod.env.js index 773d263d..ee5d146f 100644 --- a/config/prod.env.js +++ b/config/prod.env.js @@ -1,3 +1,5 @@ module.exports = { - NODE_ENV: '"production"' + NODE_ENV: '"production"', + GOOGLE_CLIENT_ID: '"241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com"', + GITHUB_CLIENT_ID: '"30c1491057c9ad4dbd56"' } diff --git a/index.js b/index.js index 53c564e6..67479080 100644 --- a/index.js +++ b/index.js @@ -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'); diff --git a/server/index.js b/server/index.js index f9d10fd5..7e599f3f 100644 --- a/server/index.js +++ b/server/index.js @@ -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') { diff --git a/server/user.js b/server/user.js index 018e9af9..bbe89766 100644 --- a/server/user.js +++ b/server/user.js @@ -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) => { diff --git a/src/components/SideBar.vue b/src/components/SideBar.vue index 5d513850..ec46a8e2 100644 --- a/src/components/SideBar.vue +++ b/src/components/SideBar.vue @@ -158,7 +158,7 @@ export default { .side-bar__info { padding: 10px; - margin: 0 -10px; + margin: -10px -10px 0; background-color: $info-bg; p { diff --git a/src/components/Toc.vue b/src/components/Toc.vue index 308fb4d4..0b5d0941 100644 --- a/src/components/Toc.vue +++ b/src/components/Toc.vue @@ -88,6 +88,7 @@ export default { diff --git a/src/components/common/app.scss b/src/components/common/app.scss index 58d1768b..4d842e29 100644 --- a/src/components/common/app.scss +++ b/src/components/common/app.scss @@ -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, diff --git a/src/components/gutters/NewComment.vue b/src/components/gutters/NewComment.vue index fa8dc5f6..2f367105 100644 --- a/src/components/gutters/NewComment.vue +++ b/src/components/gutters/NewComment.vue @@ -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; diff --git a/src/components/menus/MainMenu.vue b/src/components/menus/MainMenu.vue index c8b6116d..6610900a 100644 --- a/src/components/menus/MainMenu.vue +++ b/src/components/menus/MainMenu.vue @@ -1,19 +1,14 @@ @@ -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'); }, diff --git a/src/components/menus/common/MenuEntry.vue b/src/components/menus/common/MenuEntry.vue index c8a12b9a..f668235d 100644 --- a/src/components/menus/common/MenuEntry.vue +++ b/src/components/menus/common/MenuEntry.vue @@ -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 { diff --git a/src/components/modals/ImageModal.vue b/src/components/modals/ImageModal.vue index 44fef562..fcf1a720 100644 --- a/src/components/modals/ImageModal.vue +++ b/src/components/modals/ImageModal.vue @@ -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)); }, diff --git a/src/components/modals/WorkspaceManagementModal.vue b/src/components/modals/WorkspaceManagementModal.vue index e28e315a..40d2632a 100644 --- a/src/components/modals/WorkspaceManagementModal.vue +++ b/src/components/modals/WorkspaceManagementModal.vue @@ -18,7 +18,7 @@ - diff --git a/src/data/defaultWorkspaces.js b/src/data/defaultWorkspaces.js index 2da55efe..bc66bfa9 100644 --- a/src/data/defaultWorkspaces.js +++ b/src/data/defaultWorkspaces.js @@ -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 }, }); diff --git a/src/icons/File.vue b/src/icons/File.vue deleted file mode 100644 index 5ed84566..00000000 --- a/src/icons/File.vue +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/src/icons/index.js b/src/icons/index.js index 6461baba..9e48c2e4 100644 --- a/src/icons/index.js +++ b/src/icons/index.js @@ -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); diff --git a/src/services/diffUtils.js b/src/services/diffUtils.js index 01db000f..bac2a0f3 100644 --- a/src/services/diffUtils.js +++ b/src/services/diffUtils.js @@ -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, diff --git a/src/services/localDbSvc.js b/src/services/localDbSvc.js index d8dfb5c5..0da1aa1f 100644 --- a/src/services/localDbSvc.js +++ b/src/services/localDbSvc.js @@ -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, }); }); diff --git a/src/services/optional/keystrokes.js b/src/services/optional/keystrokes.js index 3cb2387d..7d85a810 100644 --- a/src/services/optional/keystrokes.js +++ b/src/services/optional/keystrokes.js @@ -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]); diff --git a/src/services/providers/googleDriveAppDataProvider.js b/src/services/providers/googleDriveAppDataProvider.js index ab7f1138..1d73eea8 100644 --- a/src/services/providers/googleDriveAppDataProvider.js +++ b/src/services/providers/googleDriveAppDataProvider.js @@ -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']; diff --git a/src/services/providers/googleDriveProvider.js b/src/services/providers/googleDriveProvider.js index 18985e11..219de9f0 100644 --- a/src/services/providers/googleDriveProvider.js +++ b/src/services/providers/googleDriveProvider.js @@ -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; } diff --git a/src/services/providers/googleDriveWorkspaceProvider.js b/src/services/providers/googleDriveWorkspaceProvider.js index 50861c03..aebfe6f0 100644 --- a/src/services/providers/googleDriveWorkspaceProvider.js +++ b/src/services/providers/googleDriveWorkspaceProvider.js @@ -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; }); }, diff --git a/src/services/providers/helpers/githubHelper.js b/src/services/providers/helpers/githubHelper.js index 399d182b..cc2bf6c6 100644 --- a/src/services/providers/helpers/githubHelper.js +++ b/src/services/providers/helpers/githubHelper.js @@ -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({ diff --git a/src/services/providers/helpers/googleHelper.js b/src/services/providers/helpers/googleHelper.js index d2948ab7..3d1e9e8a 100644 --- a/src/services/providers/helpers/googleHelper.js +++ b/src/services/providers/helpers/googleHelper.js @@ -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); diff --git a/src/services/syncSvc.js b/src/services/syncSvc.js index 6814dd2a..4175cd6c 100644 --- a/src/services/syncSvc.js +++ b/src/services/syncSvc.js @@ -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(); } diff --git a/src/services/utils.js b/src/services/utils.js index b0b6e200..c79732fe 100644 --- a/src/services/utils.js +++ b/src/services/utils.js @@ -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}`))); diff --git a/src/store/data.js b/src/store/data.js index d16c70a4..3b470925 100644 --- a/src/store/data.js +++ b/src/store/data.js @@ -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', diff --git a/src/store/index.js b/src/store/index.js index 1a445c97..1416d8c9 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -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`, diff --git a/src/store/modal.js b/src/store/modal.js index 32d6c1da..24cf0c68 100644 --- a/src/store/modal.js +++ b/src/store/modal.js @@ -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: '

You have to sign in with Google to access this workspace.

', - resolveText: 'Ok, sign in', + content: '

StackEdit needs full Google Drive access to open this workspace.

', + resolveText: 'Ok, grant', rejectText: 'Cancel', onResolve, }), @@ -107,14 +108,14 @@ export default { }), signInForComment: ({ dispatch }, { onResolve }) => dispatch('open', { content: `

You have to sign in with Google to start commenting.

- `, + `, resolveText: 'Ok, sign in', rejectText: 'Cancel', onResolve, }), signInForHistory: ({ dispatch }, { onResolve }) => dispatch('open', { content: `

You have to sign in with Google to enable revision history.

- `, + `, resolveText: 'Ok, sign in', rejectText: 'Cancel', onResolve, diff --git a/src/store/workspace.js b/src/store/workspace.js index 5e497e0d..a2f56718 100644 --- a/src/store/workspace.js +++ b/src/store/workspace.js @@ -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`,