From 2e832fd766790966ea59ed64b6545c642f822815 Mon Sep 17 00:00:00 2001 From: Benoit Schweblin Date: Wed, 19 Sep 2018 09:59:22 +0100 Subject: [PATCH] GitLab provider (part 1) --- src/assets/iconGitlab.svg | 12 + src/components/App.vue | 5 +- src/components/ContextMenu.vue | 3 +- src/components/Editor.vue | 5 +- src/components/Explorer.vue | 9 +- src/components/ExplorerNode.vue | 43 +-- src/components/FindReplace.vue | 4 +- src/components/Layout.vue | 3 +- src/components/Modal.vue | 80 +++-- src/components/NavigationBar.vue | 14 +- src/components/Preview.vue | 5 +- src/components/SideBar.vue | 7 +- src/components/Tour.vue | 5 +- src/components/UserImage.vue | 10 +- src/components/UserName.vue | 10 +- src/components/gutters/Comment.vue | 11 +- src/components/gutters/CommentList.vue | 9 +- src/components/gutters/CurrentDiscussion.vue | 7 +- .../gutters/EditorNewDiscussionButton.vue | 3 +- src/components/gutters/NewComment.vue | 50 ++-- .../gutters/PreviewNewDiscussionButton.vue | 5 +- src/components/menus/ExportMenu.vue | 9 +- src/components/menus/HistoryMenu.vue | 19 +- src/components/menus/ImportMenu.vue | 6 +- src/components/menus/MainMenu.vue | 26 +- src/components/menus/MoreMenu.vue | 17 +- src/components/menus/PublishMenu.vue | 196 +++++++------ src/components/menus/SyncMenu.vue | 112 ++++--- src/components/menus/WorkspacesMenu.vue | 27 +- src/components/modals/FilePropertiesModal.vue | 9 +- src/components/modals/HtmlExportModal.vue | 5 +- src/components/modals/ImageModal.vue | 5 +- src/components/modals/PandocExportModal.vue | 15 +- src/components/modals/PdfExportModal.vue | 13 +- .../modals/PublishManagementModal.vue | 5 +- src/components/modals/SettingsModal.vue | 3 +- src/components/modals/SponsorModal.vue | 3 +- src/components/modals/SyncManagementModal.vue | 5 +- src/components/modals/TemplatesModal.vue | 3 +- .../modals/WorkspaceManagementModal.vue | 5 +- src/components/modals/common/ModalInner.vue | 36 --- .../providers/CouchdbCredentialsModal.vue | 3 +- .../modals/providers/GithubOpenModal.vue | 28 +- .../modals/providers/GithubPublishModal.vue | 31 +- .../modals/providers/GithubSaveModal.vue | 12 +- .../modals/providers/GithubWorkspaceModal.vue | 12 +- .../modals/providers/GitlabAccountModal.vue | 65 +++++ .../modals/providers/GitlabOpenModal.vue | 69 +++++ .../modals/providers/GitlabPublishModal.vue | 85 ++++++ .../modals/providers/GitlabSaveModal.vue | 72 +++++ .../modals/providers/GitlabWorkspaceModal.vue | 67 +++++ .../providers/GoogleDrivePublishModal.vue | 5 +- .../modals/providers/GoogleDriveSaveModal.vue | 5 +- .../providers/GoogleDriveWorkspaceModal.vue | 5 +- .../modals/providers/ZendeskAccountModal.vue | 6 +- src/data/constants.js | 5 - src/data/defaults/defaultLocalSettings.js | 5 + src/data/defaults/defaultSettings.yml | 4 +- src/data/faq.md | 6 +- src/icons/Provider.vue | 6 + src/services/gitWorkspaceSvc.js | 235 +++++++++++++++ src/services/providers/dropboxProvider.js | 2 +- src/services/providers/gistProvider.js | 2 +- src/services/providers/githubProvider.js | 2 +- .../providers/githubWorkspaceProvider.js | 255 +--------------- src/services/providers/gitlabProvider.js | 178 +++++++++++ .../providers/gitlabWorkspaceProvider.js | 276 ++++++++++++++++++ .../providers/googleDriveAppDataProvider.js | 2 +- src/services/providers/googleDriveProvider.js | 2 +- .../providers/googleDriveWorkspaceProvider.js | 2 +- .../providers/helpers/couchdbHelper.js | 3 +- .../providers/helpers/dropboxHelper.js | 58 ++-- .../providers/helpers/githubHelper.js | 58 ++-- .../providers/helpers/gitlabHelper.js | 207 +++++++++++++ .../providers/helpers/googleHelper.js | 75 +++-- src/services/providers/wordpressProvider.js | 8 +- src/services/providers/zendeskProvider.js | 10 +- src/services/publishSvc.js | 3 +- src/services/syncSvc.js | 1 + src/services/userSvc.js | 113 +++---- src/services/utils.js | 4 + src/store/data.js | 20 +- src/store/workspace.js | 34 +-- src/styles/app.scss | 5 + src/styles/variables.scss | 4 +- 85 files changed, 2059 insertions(+), 810 deletions(-) create mode 100644 src/assets/iconGitlab.svg create mode 100644 src/components/modals/providers/GitlabAccountModal.vue create mode 100644 src/components/modals/providers/GitlabOpenModal.vue create mode 100644 src/components/modals/providers/GitlabPublishModal.vue create mode 100644 src/components/modals/providers/GitlabSaveModal.vue create mode 100644 src/components/modals/providers/GitlabWorkspaceModal.vue create mode 100644 src/services/gitWorkspaceSvc.js create mode 100644 src/services/providers/gitlabProvider.js create mode 100644 src/services/providers/gitlabWorkspaceProvider.js create mode 100644 src/services/providers/helpers/gitlabHelper.js diff --git a/src/assets/iconGitlab.svg b/src/assets/iconGitlab.svg new file mode 100644 index 00000000..c87fa3b7 --- /dev/null +++ b/src/assets/iconGitlab.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/components/App.vue b/src/components/App.vue index 033eed02..b5619371 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -21,6 +21,7 @@ import syncSvc from '../services/syncSvc'; import networkSvc from '../services/networkSvc'; import sponsorSvc from '../services/sponsorSvc'; import tempFileSvc from '../services/tempFileSvc'; +import store from '../store'; import './common/vueGlobals'; const themeClasses = { @@ -41,7 +42,7 @@ export default { }), computed: { classes() { - const result = themeClasses[this.$store.getters['data/computedSettings'].colorTheme]; + const result = themeClasses[store.getters['data/computedSettings'].colorTheme]; return Array.isArray(result) ? result : themeClasses.light; }, }, @@ -57,7 +58,7 @@ export default { window.location.reload(); } else if (err && err.message !== 'RELOAD') { console.error(err); // eslint-disable-line no-console - this.$store.dispatch('notification/error', err); + store.dispatch('notification/error', err); } } }, diff --git a/src/components/ContextMenu.vue b/src/components/ContextMenu.vue index 4e770c7b..236ca5d3 100644 --- a/src/components/ContextMenu.vue +++ b/src/components/ContextMenu.vue @@ -12,6 +12,7 @@ diff --git a/src/components/UserName.vue b/src/components/UserName.vue index 895e74be..c87bdafe 100644 --- a/src/components/UserName.vue +++ b/src/components/UserName.vue @@ -4,15 +4,21 @@ diff --git a/src/components/gutters/Comment.vue b/src/components/gutters/Comment.vue index 1651ac10..ce91cf4e 100644 --- a/src/components/gutters/Comment.vue +++ b/src/components/gutters/Comment.vue @@ -27,6 +27,7 @@ import UserImage from '../UserImage'; import UserName from '../UserName'; import editorSvc from '../../services/editorSvc'; import htmlSanitizer from '../../libs/htmlSanitizer'; +import store from '../../store'; export default { components: { @@ -36,8 +37,8 @@ export default { props: ['comment'], computed: { showReply() { - return this.comment === this.$store.getters['discussion/currentDiscussionLastComment'] && - !this.$store.state.discussion.isCommenting; + return this.comment === store.getters['discussion/currentDiscussionLastComment'] && + !store.state.discussion.isCommenting; }, text() { return htmlSanitizer.sanitizeHtml(editorSvc.converter.render(this.comment.text)); @@ -49,8 +50,8 @@ export default { ]), async removeComment() { try { - await this.$store.dispatch('modal/open', 'commentDeletion'); - this.$store.dispatch('discussion/cleanCurrentFile', { filterComment: this.comment }); + await store.dispatch('modal/open', 'commentDeletion'); + store.dispatch('discussion/cleanCurrentFile', { filterComment: this.comment }); } catch (e) { // Cancel } @@ -59,7 +60,7 @@ export default { mounted() { const isSticky = this.$el.parentNode.classList.contains('sticky-comment'); if (isSticky) { - const commentId = this.$store.getters['discussion/currentDiscussionLastCommentId']; + const commentId = store.getters['discussion/currentDiscussionLastCommentId']; const scrollerElt = this.$el.querySelector('.comment__text-inner'); let scrollerMirrorElt; diff --git a/src/components/gutters/CommentList.vue b/src/components/gutters/CommentList.vue index 2f3ec2f5..15708a63 100644 --- a/src/components/gutters/CommentList.vue +++ b/src/components/gutters/CommentList.vue @@ -13,6 +13,7 @@ import { mapState, mapGetters, mapMutations } from 'vuex'; import Comment from './Comment'; import NewComment from './NewComment'; import editorSvc from '../../services/editorSvc'; +import store from '../../store'; import utils from '../../services/utils'; export default { @@ -63,7 +64,7 @@ export default { 'setCurrentDiscussionId', ]), updateTops() { - const layoutSettings = this.$store.getters['data/layoutSettings']; + const layoutSettings = store.getters['data/layoutSettings']; const minTop = -2; let minCommentTop = minTop; const getTop = (discussion, commentElt1, commentElt2, isCurrent) => { @@ -126,15 +127,15 @@ export default { { immediate: true }, ); - const layoutSettings = this.$store.getters['data/layoutSettings']; + const layoutSettings = store.getters['data/layoutSettings']; this.scrollerElt = layoutSettings.showEditor ? editorSvc.editorElt.parentNode : editorSvc.previewElt.parentNode; this.updateSticky = () => { const commitIfDifferent = (value) => { - if (this.$store.state.discussion.stickyComment !== value) { - this.$store.commit('discussion/setStickyComment', value); + if (store.state.discussion.stickyComment !== value) { + store.commit('discussion/setStickyComment', value); } }; let height = 0; diff --git a/src/components/gutters/CurrentDiscussion.vue b/src/components/gutters/CurrentDiscussion.vue index 523c4c6d..aaed6937 100644 --- a/src/components/gutters/CurrentDiscussion.vue +++ b/src/components/gutters/CurrentDiscussion.vue @@ -33,6 +33,7 @@ import editorSvc from '../../services/editorSvc'; import animationSvc from '../../services/animationSvc'; import markdownConversionSvc from '../../services/markdownConversionSvc'; import StickyComment from './StickyComment'; +import store from '../../store'; export default { components: { @@ -72,7 +73,7 @@ export default { ]), goToDiscussion(discussionId = this.currentDiscussionId) { this.setCurrentDiscussionId(discussionId); - const layoutSettings = this.$store.getters['data/layoutSettings']; + const layoutSettings = store.getters['data/layoutSettings']; const discussion = this.currentFileDiscussions[discussionId]; const coordinates = layoutSettings.showEditor ? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end) @@ -98,8 +99,8 @@ export default { }, async removeDiscussion() { try { - await this.$store.dispatch('modal/open', 'discussionDeletion'); - this.$store.dispatch('discussion/cleanCurrentFile', { + await store.dispatch('modal/open', 'discussionDeletion'); + store.dispatch('discussion/cleanCurrentFile', { filterDiscussion: this.currentDiscussion, }); } catch (e) { diff --git a/src/components/gutters/EditorNewDiscussionButton.vue b/src/components/gutters/EditorNewDiscussionButton.vue index da2db17b..1819f5bd 100644 --- a/src/components/gutters/EditorNewDiscussionButton.vue +++ b/src/components/gutters/EditorNewDiscussionButton.vue @@ -7,6 +7,7 @@ diff --git a/src/components/menus/WorkspacesMenu.vue b/src/components/menus/WorkspacesMenu.vue index ad252cce..9ce7c570 100644 --- a/src/components/menus/WorkspacesMenu.vue +++ b/src/components/menus/WorkspacesMenu.vue @@ -17,6 +17,11 @@
GitHub workspace
Add a workspace synced with a GitHub repository. + + +
GitLab workspace
+ Add a workspace synced with a GitLab project. +
Google Drive workspace
@@ -33,6 +38,8 @@ import { mapGetters } from 'vuex'; import MenuEntry from './common/MenuEntry'; import googleHelper from '../../services/providers/helpers/googleHelper'; +import gitlabHelper from '../../services/providers/helpers/gitlabHelper'; +import store from '../../store'; export default { components: { @@ -50,7 +57,7 @@ export default { methods: { async addCouchdbWorkspace() { try { - this.$store.dispatch('modal/open', { + store.dispatch('modal/open', { type: 'couchdbWorkspace', }); } catch (e) { @@ -59,17 +66,29 @@ export default { }, async addGithubWorkspace() { try { - this.$store.dispatch('modal/open', { + store.dispatch('modal/open', { type: 'githubWorkspace', }); } catch (e) { // Cancel } }, + async addGitlabWorkspace() { + try { + const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' }); + const token = await gitlabHelper.addAccount(serverUrl, applicationId); + store.dispatch('modal/open', { + type: 'gitlabWorkspace', + token, + }); + } catch (e) { + // Cancel + } + }, async addGoogleDriveWorkspace() { try { const token = await googleHelper.addDriveAccount(true); - this.$store.dispatch('modal/open', { + store.dispatch('modal/open', { type: 'googleDriveWorkspace', token, }); @@ -78,7 +97,7 @@ export default { } }, manageWorkspaces() { - this.$store.dispatch('modal/open', 'workspaceManagement'); + store.dispatch('modal/open', 'workspaceManagement'); }, }, }; diff --git a/src/components/modals/FilePropertiesModal.vue b/src/components/modals/FilePropertiesModal.vue index 941a1f44..af4b86c9 100644 --- a/src/components/modals/FilePropertiesModal.vue +++ b/src/components/modals/FilePropertiesModal.vue @@ -92,6 +92,7 @@ import FormEntry from './common/FormEntry'; import CodeEditor from '../CodeEditor'; import utils from '../../services/utils'; import presets from '../../data/presets'; +import store from '../../store'; const simpleProperties = { title: '', @@ -125,17 +126,17 @@ export default { presets: () => Object.keys(presets).sort(), tab: { get() { - return this.$store.getters['data/localSettings'].filePropertiesTab; + return store.getters['data/localSettings'].filePropertiesTab; }, set(value) { - this.$store.dispatch('data/patchLocalSettings', { + store.dispatch('data/patchLocalSettings', { filePropertiesTab: value, }); }, }, }, created() { - const content = this.$store.getters['content/current']; + const content = store.getters['content/current']; this.contentId = content.id; this.setYamlProperties(content.properties); if (this.tab !== 'yaml') { @@ -214,7 +215,7 @@ export default { if (this.error) { this.setYamlTab(); } else { - this.$store.commit('content/patchItem', { + store.commit('content/patchItem', { id: this.contentId, properties: utils.sanitizeText(this.yamlProperties), }); diff --git a/src/components/modals/HtmlExportModal.vue b/src/components/modals/HtmlExportModal.vue index 60ab0c7d..0196e794 100644 --- a/src/components/modals/HtmlExportModal.vue +++ b/src/components/modals/HtmlExportModal.vue @@ -25,6 +25,7 @@ import { mapActions } from 'vuex'; import exportSvc from '../../services/exportSvc'; import modalTemplate from './common/modalTemplate'; +import store from '../../store'; export default modalTemplate({ data: () => ({ @@ -38,7 +39,7 @@ export default modalTemplate({ this.$watch('selectedTemplate', (selectedTemplate) => { clearTimeout(timeoutId); timeoutId = setTimeout(async () => { - const currentFile = this.$store.getters['file/current']; + const currentFile = store.getters['file/current']; const html = await exportSvc.applyTemplate( currentFile.id, this.allTemplatesById[selectedTemplate], @@ -55,7 +56,7 @@ export default modalTemplate({ ]), resolve() { const { config } = this; - const currentFile = this.$store.getters['file/current']; + const currentFile = store.getters['file/current']; config.resolve(); exportSvc.exportToDisk(currentFile.id, 'html', this.allTemplatesById[this.selectedTemplate]); }, diff --git a/src/components/modals/ImageModal.vue b/src/components/modals/ImageModal.vue index 7011d7f4..cef7dbeb 100644 --- a/src/components/modals/ImageModal.vue +++ b/src/components/modals/ImageModal.vue @@ -26,6 +26,7 @@ import modalTemplate from './common/modalTemplate'; import MenuEntry from '../menus/common/MenuEntry'; import googleHelper from '../../services/providers/helpers/googleHelper'; +import store from '../../store'; export default modalTemplate({ components: { @@ -36,7 +37,7 @@ export default modalTemplate({ }), computed: { googlePhotosTokens() { - const googleTokensBySub = this.$store.getters['data/googleTokensBySub']; + const googleTokensBySub = store.getters['data/googleTokensBySub']; return Object.values(googleTokensBySub) .filter(token => token.isPhotos) .sort((token1, token2) => token1.name.localeCompare(token2.name)); @@ -65,7 +66,7 @@ export default modalTemplate({ this.config.reject(); const res = await googleHelper.openPicker(token, 'img'); if (res[0]) { - this.$store.dispatch('modal/open', { + store.dispatch('modal/open', { type: 'googlePhoto', url: res[0].url, callback, diff --git a/src/components/modals/PandocExportModal.vue b/src/components/modals/PandocExportModal.vue index 089f1e90..c58266c5 100644 --- a/src/components/modals/PandocExportModal.vue +++ b/src/components/modals/PandocExportModal.vue @@ -32,6 +32,7 @@ import networkSvc from '../../services/networkSvc'; import editorSvc from '../../services/editorSvc'; import googleHelper from '../../services/providers/helpers/googleHelper'; import modalTemplate from './common/modalTemplate'; +import store from '../../store'; export default modalTemplate({ computedLocalSettings: { @@ -40,13 +41,13 @@ export default modalTemplate({ methods: { async resolve() { this.config.resolve(); - const currentFile = this.$store.getters['file/current']; - const currentContent = this.$store.getters['content/current']; + const currentFile = store.getters['file/current']; + const currentContent = store.getters['content/current']; const { selectedFormat } = this; - this.$store.dispatch('queue/enqueue', async () => { + store.dispatch('queue/enqueue', async () => { const [sponsorToken, token] = await Promise.all([ Promise.resolve().then(() => { - const tokenToRefresh = this.$store.getters['workspace/sponsorToken']; + const tokenToRefresh = store.getters['workspace/sponsorToken']; return tokenToRefresh && googleHelper.refreshToken(tokenToRefresh); }), sponsorSvc.getToken(), @@ -60,7 +61,7 @@ export default modalTemplate({ token, idToken: sponsorToken && sponsorToken.idToken, format: selectedFormat, - options: JSON.stringify(this.$store.getters['data/computedSettings'].pandoc), + options: JSON.stringify(store.getters['data/computedSettings'].pandoc), metadata: JSON.stringify(currentContent.properties), }, body: JSON.stringify(editorSvc.getPandocAst()), @@ -70,10 +71,10 @@ export default modalTemplate({ FileSaver.saveAs(body, `${currentFile.name}.${selectedFormat}`); } catch (err) { if (err.status === 401) { - this.$store.dispatch('modal/open', 'sponsorOnly'); + store.dispatch('modal/open', 'sponsorOnly'); } else { console.error(err); // eslint-disable-line no-console - this.$store.dispatch('notification/error', err); + store.dispatch('notification/error', err); } } }); diff --git a/src/components/modals/PdfExportModal.vue b/src/components/modals/PdfExportModal.vue index 1e11d139..cadc4d44 100644 --- a/src/components/modals/PdfExportModal.vue +++ b/src/components/modals/PdfExportModal.vue @@ -27,6 +27,7 @@ import sponsorSvc from '../../services/sponsorSvc'; import networkSvc from '../../services/networkSvc'; import googleHelper from '../../services/providers/helpers/googleHelper'; import modalTemplate from './common/modalTemplate'; +import store from '../../store'; export default modalTemplate({ computedLocalSettings: { @@ -35,11 +36,11 @@ export default modalTemplate({ methods: { async resolve() { this.config.resolve(); - const currentFile = this.$store.getters['file/current']; - this.$store.dispatch('queue/enqueue', async () => { + const currentFile = store.getters['file/current']; + store.dispatch('queue/enqueue', async () => { const [sponsorToken, token, html] = await Promise.all([ Promise.resolve().then(() => { - const tokenToRefresh = this.$store.getters['workspace/sponsorToken']; + const tokenToRefresh = store.getters['workspace/sponsorToken']; return tokenToRefresh && googleHelper.refreshToken(tokenToRefresh); }), sponsorSvc.getToken(), @@ -57,7 +58,7 @@ export default modalTemplate({ params: { token, idToken: sponsorToken && sponsorToken.idToken, - options: JSON.stringify(this.$store.getters['data/computedSettings'].wkhtmltopdf), + options: JSON.stringify(store.getters['data/computedSettings'].wkhtmltopdf), }, body: html, blob: true, @@ -66,10 +67,10 @@ export default modalTemplate({ FileSaver.saveAs(body, `${currentFile.name}.pdf`); } catch (err) { if (err.status === 401) { - this.$store.dispatch('modal/open', 'sponsorOnly'); + store.dispatch('modal/open', 'sponsorOnly'); } else { console.error(err); // eslint-disable-line no-console - this.$store.dispatch('notification/error', err); + store.dispatch('notification/error', err); } } }); diff --git a/src/components/modals/PublishManagementModal.vue b/src/components/modals/PublishManagementModal.vue index 8cd2ff94..f06c1647 100644 --- a/src/components/modals/PublishManagementModal.vue +++ b/src/components/modals/PublishManagementModal.vue @@ -49,6 +49,7 @@ @@ -64,15 +39,4 @@ export default { color: rgba(0, 0, 0, 0.67); } } - -.modal__sponsor-button { - display: inline-block; - color: darken($error-color, 10%); - background-color: transparentize($error-color, 0.85); - border-radius: $border-radius-base; - font-size: 0.9em; - padding: 0.75em 1.5em; - margin-bottom: 1.2em; - line-height: 1.55; -} diff --git a/src/components/modals/providers/CouchdbCredentialsModal.vue b/src/components/modals/providers/CouchdbCredentialsModal.vue index c9bf7270..60463d19 100644 --- a/src/components/modals/providers/CouchdbCredentialsModal.vue +++ b/src/components/modals/providers/CouchdbCredentialsModal.vue @@ -21,6 +21,7 @@ diff --git a/src/components/modals/providers/GitlabOpenModal.vue b/src/components/modals/providers/GitlabOpenModal.vue new file mode 100644 index 00000000..8098fc58 --- /dev/null +++ b/src/components/modals/providers/GitlabOpenModal.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/components/modals/providers/GitlabPublishModal.vue b/src/components/modals/providers/GitlabPublishModal.vue new file mode 100644 index 00000000..84d79e3c --- /dev/null +++ b/src/components/modals/providers/GitlabPublishModal.vue @@ -0,0 +1,85 @@ + + + diff --git a/src/components/modals/providers/GitlabSaveModal.vue b/src/components/modals/providers/GitlabSaveModal.vue new file mode 100644 index 00000000..179d9bdc --- /dev/null +++ b/src/components/modals/providers/GitlabSaveModal.vue @@ -0,0 +1,72 @@ + + + diff --git a/src/components/modals/providers/GitlabWorkspaceModal.vue b/src/components/modals/providers/GitlabWorkspaceModal.vue new file mode 100644 index 00000000..d91211dd --- /dev/null +++ b/src/components/modals/providers/GitlabWorkspaceModal.vue @@ -0,0 +1,67 @@ + + + diff --git a/src/components/modals/providers/GoogleDrivePublishModal.vue b/src/components/modals/providers/GoogleDrivePublishModal.vue index 47da833d..77010283 100644 --- a/src/components/modals/providers/GoogleDrivePublishModal.vue +++ b/src/components/modals/providers/GoogleDrivePublishModal.vue @@ -57,6 +57,7 @@ import googleHelper from '../../../services/providers/helpers/googleHelper'; import googleDriveProvider from '../../../services/providers/googleDriveProvider'; import modalTemplate from '../common/modalTemplate'; +import store from '../../../store'; export default modalTemplate({ data: () => ({ @@ -69,12 +70,12 @@ export default modalTemplate({ }, methods: { openFolder() { - return this.$store.dispatch( + return store.dispatch( 'modal/hideUntil', googleHelper.openPicker(this.config.token, 'folder') .then((folders) => { if (folders[0]) { - this.$store.dispatch('data/patchLocalSettings', { + store.dispatch('data/patchLocalSettings', { googleDriveFolderId: folders[0].id, }); } diff --git a/src/components/modals/providers/GoogleDriveSaveModal.vue b/src/components/modals/providers/GoogleDriveSaveModal.vue index 07a9d622..d25ba6e2 100644 --- a/src/components/modals/providers/GoogleDriveSaveModal.vue +++ b/src/components/modals/providers/GoogleDriveSaveModal.vue @@ -32,6 +32,7 @@ import googleHelper from '../../../services/providers/helpers/googleHelper'; import googleDriveProvider from '../../../services/providers/googleDriveProvider'; import modalTemplate from '../common/modalTemplate'; +import store from '../../../store'; export default modalTemplate({ data: () => ({ @@ -42,12 +43,12 @@ export default modalTemplate({ }, methods: { openFolder() { - return this.$store.dispatch( + return store.dispatch( 'modal/hideUntil', googleHelper.openPicker(this.config.token, 'folder') .then((folders) => { if (folders[0]) { - this.$store.dispatch('data/patchLocalSettings', { + store.dispatch('data/patchLocalSettings', { googleDriveFolderId: folders[0].id, }); } diff --git a/src/components/modals/providers/GoogleDriveWorkspaceModal.vue b/src/components/modals/providers/GoogleDriveWorkspaceModal.vue index fd173f2a..02dd3fa8 100644 --- a/src/components/modals/providers/GoogleDriveWorkspaceModal.vue +++ b/src/components/modals/providers/GoogleDriveWorkspaceModal.vue @@ -26,6 +26,7 @@ import googleHelper from '../../../services/providers/helpers/googleHelper'; import modalTemplate from '../common/modalTemplate'; import utils from '../../../services/utils'; +import store from '../../../store'; export default modalTemplate({ computedLocalSettings: { @@ -33,12 +34,12 @@ export default modalTemplate({ }, methods: { openFolder() { - return this.$store.dispatch( + return store.dispatch( 'modal/hideUntil', googleHelper.openPicker(this.config.token, 'folder') .then((folders) => { if (folders[0]) { - this.$store.dispatch('data/patchLocalSettings', { + store.dispatch('data/patchLocalSettings', { googleDriveWorkspaceFolderId: folders[0].id, }); } diff --git a/src/components/modals/providers/ZendeskAccountModal.vue b/src/components/modals/providers/ZendeskAccountModal.vue index a85e8919..80bd054d 100644 --- a/src/components/modals/providers/ZendeskAccountModal.vue +++ b/src/components/modals/providers/ZendeskAccountModal.vue @@ -14,8 +14,10 @@ +
+ More info
diff --git a/src/data/constants.js b/src/data/constants.js index 6982a991..90c4c138 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -20,11 +20,6 @@ export default { 'layoutSettings', 'tokens', ], - userIdPrefixes: { - db: 'dropbox', - gh: 'github', - go: 'google', - }, textMaxLength: 250000, defaultName: 'Untitled', }; diff --git a/src/data/defaults/defaultLocalSettings.js b/src/data/defaults/defaultLocalSettings.js index 7ca075f3..68dd43c5 100644 --- a/src/data/defaults/defaultLocalSettings.js +++ b/src/data/defaults/defaultLocalSettings.js @@ -19,6 +19,11 @@ export default () => ({ githubPublishTemplate: 'jekyllSite', gistIsPublic: false, gistPublishTemplate: 'plainText', + gitlabServerUrl: '', + gitlabApplicationId: '', + gitlabProjectUrl: '', + gitlabWorkspaceProjectUrl: '', + gitlabPublishTemplate: 'plainText', wordpressDomain: '', wordpressPublishTemplate: 'plainHtml', zendeskSiteUrl: '', diff --git a/src/data/defaults/defaultSettings.yml b/src/data/defaults/defaultSettings.yml index ccd0d1b0..bcb4a508 100644 --- a/src/data/defaults/defaultSettings.yml +++ b/src/data/defaults/defaultSettings.yml @@ -77,8 +77,8 @@ turndown: linkStyle: inlined linkReferenceStyle: full -# GitHub commit messages -github: +# GitHub/GitLab commit messages +git: createFileMessage: '{{path}} created from https://stackedit.io/' updateFileMessage: '{{path}} updated from https://stackedit.io/' deleteFileMessage: '{{path}} deleted from https://stackedit.io/' diff --git a/src/data/faq.md b/src/data/faq.md index b92ba9ad..0586fdc2 100644 --- a/src/data/faq.md +++ b/src/data/faq.md @@ -1,9 +1,9 @@ **Where is my data stored?** -If your workspace is not synced, your files are only stored inside your browser using the [IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) and are not stored anywhere else. +If your workspace is not synced, your files are stored inside your browser and nowhere else. -We recommend syncing your workspace to make sure files won't be lost in case your browser data is cleared. Self-hosted CouchDB backend is best suited for privacy. +We recommend syncing your workspace to make sure files won't be lost in case your browser data is cleared. Self-hosted CouchDB or GitLab backends are well suited for privacy. **Can StackEdit access my data without telling me?** -StackEdit is a frontend application. The access tokens issued by Google, Dropbox, GitHub... are stored in your browser and are not sent to any backend or 3^rd^ parties so your data won't be accessed by anyone. +StackEdit is a browser-based application. The access tokens issued by Google, Dropbox, GitHub... are stored in your browser and are not sent to any kind of backend or 3^rd^ party so your data won't be accessed by anyone. diff --git a/src/icons/Provider.vue b/src/icons/Provider.vue index 5ba1dce4..526741f5 100644 --- a/src/icons/Provider.vue +++ b/src/icons/Provider.vue @@ -20,6 +20,8 @@ export default { return 'github'; case 'gist': return 'github'; + case 'gitlabWorkspace': + return 'gitlab'; case 'bloggerPage': return 'blogger'; case 'couchdbWorkspace': @@ -57,6 +59,10 @@ export default { background-image: url(../assets/iconGithub.svg); } +.icon-provider--gitlab { + background-image: url(../assets/iconGitlab.svg); +} + .icon-provider--dropbox { background-image: url(../assets/iconDropbox.svg); } diff --git a/src/services/gitWorkspaceSvc.js b/src/services/gitWorkspaceSvc.js new file mode 100644 index 00000000..98024e5c --- /dev/null +++ b/src/services/gitWorkspaceSvc.js @@ -0,0 +1,235 @@ +import store from '../store'; +import utils from '../services/utils'; + +const endsWith = (str, suffix) => str.slice(-suffix.length) === suffix; + +export default { + shaByPath: Object.create(null), + makeChanges(tree) { + const workspacePath = store.getters['workspace/currentWorkspace'].path || ''; + + // Store all blobs sha + this.shaByPath = Object.create(null); + // Store interesting paths + const treeFolderMap = Object.create(null); + const treeFileMap = Object.create(null); + const treeDataMap = Object.create(null); + const treeSyncLocationMap = Object.create(null); + const treePublishLocationMap = Object.create(null); + + tree.filter(({ type, path }) => type === 'blob' && path.indexOf(workspacePath) === 0) + .forEach((blobEntry) => { + // Make path relative + const path = blobEntry.path.slice(workspacePath.length); + // Collect blob sha + this.shaByPath[path] = blobEntry.sha; + if (path.indexOf('.stackedit-data/') === 0) { + treeDataMap[path] = true; + } else { + // Collect parents path + let parentPath = ''; + path.split('/').slice(0, -1).forEach((folderName) => { + const folderPath = `${parentPath}${folderName}/`; + treeFolderMap[folderPath] = parentPath; + parentPath = folderPath; + }); + // Collect file path + if (endsWith(path, '.md')) { + treeFileMap[path] = parentPath; + } else if (endsWith(path, '.sync')) { + treeSyncLocationMap[path] = true; + } else if (endsWith(path, '.publish')) { + treePublishLocationMap[path] = true; + } + } + }); + + // Collect changes + const changes = []; + const idsByPath = {}; + const syncDataByPath = store.getters['data/syncDataById']; + const { itemIdsByGitPath } = store.getters; + const getIdFromPath = (path, isFile) => { + let itemId = idsByPath[path]; + if (!itemId) { + const existingItemId = itemIdsByGitPath[path]; + if (existingItemId + // Reuse a file ID only if it has already been synced + && (!isFile || syncDataByPath[path] + // Content may have already been synced + || syncDataByPath[`/${path}`]) + ) { + itemId = existingItemId; + } else { + // Otherwise, make a new ID for a new item + itemId = utils.uid(); + } + // If it's a file path, add the content path as well + if (isFile) { + idsByPath[`/${path}`] = `${itemId}/content`; + } + idsByPath[path] = itemId; + } + return itemId; + }; + + // Folder creations/updates + // Assume map entries are sorted from top to bottom + Object.entries(treeFolderMap).forEach(([path, parentPath]) => { + if (path === '.stackedit-trash/') { + idsByPath[path] = 'trash'; + } else { + const item = utils.addItemHash({ + id: getIdFromPath(path), + type: 'folder', + name: path.slice(parentPath.length, -1), + parentId: idsByPath[parentPath] || null, + }); + + const folderSyncData = syncDataByPath[path]; + if (!folderSyncData || folderSyncData.hash !== item.hash) { + changes.push({ + syncDataId: path, + item, + syncData: { + id: path, + type: item.type, + hash: item.hash, + }, + }); + } + } + }); + + // File/content creations/updates + Object.entries(treeFileMap).forEach(([path, parentPath]) => { + const fileId = getIdFromPath(path, true); + const contentPath = `/${path}`; + const contentId = idsByPath[contentPath]; + + // File creations/updates + const item = utils.addItemHash({ + id: fileId, + type: 'file', + name: path.slice(parentPath.length, -'.md'.length), + parentId: idsByPath[parentPath] || null, + }); + + const fileSyncData = syncDataByPath[path]; + if (!fileSyncData || fileSyncData.hash !== item.hash) { + changes.push({ + syncDataId: path, + item, + syncData: { + id: path, + type: item.type, + hash: item.hash, + }, + }); + } + + // Content creations/updates + const contentSyncData = syncDataByPath[contentPath]; + if (!contentSyncData || contentSyncData.sha !== this.shaByPath[path]) { + const type = 'content'; + // Use `/` as a prefix to get a unique syncData id + changes.push({ + syncDataId: contentPath, + item: { + id: contentId, + type, + // Need a truthy value to force downloading the content + hash: 1, + }, + syncData: { + id: contentPath, + type, + // Need a truthy value to force downloading the content + hash: 1, + }, + }); + } + }); + + // Data creations/updates + const syncDataByItemId = store.getters['data/syncDataByItemId']; + Object.keys(treeDataMap).forEach((path) => { + // Only template data are stored + const [, id] = path.match(/^\.stackedit-data\/(templates)\.json$/) || []; + if (id) { + idsByPath[path] = id; + const syncData = syncDataByItemId[id]; + if (!syncData || syncData.sha !== this.shaByPath[path]) { + const type = 'data'; + changes.push({ + syncDataId: path, + item: { + id, + type, + // Need a truthy value to force saving sync data + hash: 1, + }, + syncData: { + id: path, + type, + // Need a truthy value to force downloading the content + hash: 1, + }, + }); + } + } + }); + + // Location creations/updates + [{ + type: 'syncLocation', + map: treeSyncLocationMap, + pathMatcher: /^([\s\S]+)\.([\w-]+)\.sync$/, + }, { + type: 'publishLocation', + map: treePublishLocationMap, + pathMatcher: /^([\s\S]+)\.([\w-]+)\.publish$/, + }] + .forEach(({ type, map, pathMatcher }) => Object.keys(map).forEach((path) => { + const [, filePath, data] = path.match(pathMatcher) || []; + if (filePath) { + // If there is a corresponding md file in the tree + const fileId = idsByPath[`${filePath}.md`]; + if (fileId) { + // Reuse existing ID or create a new one + const id = itemIdsByGitPath[path] || utils.uid(); + idsByPath[path] = id; + + const item = utils.addItemHash({ + ...JSON.parse(utils.decodeBase64(data)), + id, + type, + fileId, + }); + + const locationSyncData = syncDataByPath[path]; + if (!locationSyncData || locationSyncData.hash !== item.hash) { + changes.push({ + syncDataId: path, + item, + syncData: { + id: path, + type: item.type, + hash: item.hash, + }, + }); + } + } + } + })); + + // Deletions + Object.keys(syncDataByPath).forEach((path) => { + if (!idsByPath[path]) { + changes.push({ syncDataId: path }); + } + }); + + return changes; + }, +}; diff --git a/src/services/providers/dropboxProvider.js b/src/services/providers/dropboxProvider.js index 3748cb37..8e8016ed 100644 --- a/src/services/providers/dropboxProvider.js +++ b/src/services/providers/dropboxProvider.js @@ -127,7 +127,7 @@ export default new Provider({ const entries = await dropboxHelper.listRevisions(token, syncLocation.dropboxFileId); return entries.map(entry => ({ id: entry.rev, - sub: `db:${(entry.sharing_info || {}).modified_by || token.sub}`, + sub: `${dropboxHelper.subPrefix}:${(entry.sharing_info || {}).modified_by || token.sub}`, created: new Date(entry.server_modified).getTime(), })); }, diff --git a/src/services/providers/gistProvider.js b/src/services/providers/gistProvider.js index 90b9f364..26a12d88 100644 --- a/src/services/providers/gistProvider.js +++ b/src/services/providers/gistProvider.js @@ -65,7 +65,7 @@ export default new Provider({ }); return entries.map((entry) => { - const sub = `gh:${entry.user.id}`; + const sub = `${githubHelper.subPrefix}:${entry.user.id}`; userSvc.addInfo({ id: sub, name: entry.user.login, imageUrl: entry.user.avatar_url }); return { sub, diff --git a/src/services/providers/githubProvider.js b/src/services/providers/githubProvider.js index 1e59ca46..fe0e9bef 100644 --- a/src/services/providers/githubProvider.js +++ b/src/services/providers/githubProvider.js @@ -134,7 +134,7 @@ export default new Provider({ } else if (committer && committer.login) { user = committer; } - const sub = `gh:${user.id}`; + const sub = `${githubHelper.subPrefix}:${user.id}`; userSvc.addInfo({ id: sub, name: user.login, imageUrl: user.avatar_url }); const date = (commit.author && commit.author.date) || (commit.committer && commit.committer.date); diff --git a/src/services/providers/githubWorkspaceProvider.js b/src/services/providers/githubWorkspaceProvider.js index 0eeef84b..749160f7 100644 --- a/src/services/providers/githubWorkspaceProvider.js +++ b/src/services/providers/githubWorkspaceProvider.js @@ -3,19 +3,11 @@ import githubHelper from './helpers/githubHelper'; import Provider from './common/Provider'; import utils from '../utils'; import userSvc from '../userSvc'; +import gitWorkspaceSvc from '../gitWorkspaceSvc'; const getAbsolutePath = ({ id }) => `${store.getters['workspace/currentWorkspace'].path || ''}${id}`; -let treeShaMap; -let treeFolderMap; -let treeFileMap; -let treeDataMap; -let treeSyncLocationMap; -let treePublishLocationMap; - -const endsWith = (str, suffix) => str.slice(-suffix.length) === suffix; - export default new Provider({ id: 'githubWorkspace', name: 'GitHub', @@ -90,238 +82,13 @@ export default new Provider({ return store.getters['workspace/workspacesById'][workspaceId]; }, getChanges() { - const syncToken = store.getters['workspace/syncToken']; return githubHelper.getTree({ ...store.getters['workspace/currentWorkspace'], - token: syncToken, + token: this.getToken(), }); }, prepareChanges(tree) { - const workspacePath = store.getters['workspace/currentWorkspace'].path || ''; - - // Store all blobs sha - treeShaMap = Object.create(null); - // Store interesting paths - treeFolderMap = Object.create(null); - treeFileMap = Object.create(null); - treeDataMap = Object.create(null); - treeSyncLocationMap = Object.create(null); - treePublishLocationMap = Object.create(null); - - tree.filter(({ type, path }) => type === 'blob' && path.indexOf(workspacePath) === 0) - .forEach((blobEntry) => { - // Make path relative - const path = blobEntry.path.slice(workspacePath.length); - // Collect blob sha - treeShaMap[path] = blobEntry.sha; - if (path.indexOf('.stackedit-data/') === 0) { - treeDataMap[path] = true; - } else { - // Collect parents path - let parentPath = ''; - path.split('/').slice(0, -1).forEach((folderName) => { - const folderPath = `${parentPath}${folderName}/`; - treeFolderMap[folderPath] = parentPath; - parentPath = folderPath; - }); - // Collect file path - if (endsWith(path, '.md')) { - treeFileMap[path] = parentPath; - } else if (endsWith(path, '.sync')) { - treeSyncLocationMap[path] = true; - } else if (endsWith(path, '.publish')) { - treePublishLocationMap[path] = true; - } - } - }); - - // Collect changes - const changes = []; - const idsByPath = {}; - const syncDataByPath = store.getters['data/syncDataById']; - const { itemIdsByGitPath } = store.getters; - const getIdFromPath = (path, isFile) => { - let itemId = idsByPath[path]; - if (!itemId) { - const existingItemId = itemIdsByGitPath[path]; - if (existingItemId - // Reuse a file ID only if it has already been synced - && (!isFile || syncDataByPath[path] - // Content may have already been synced - || syncDataByPath[`/${path}`]) - ) { - itemId = existingItemId; - } else { - // Otherwise, make a new ID for a new item - itemId = utils.uid(); - } - // If it's a file path, add the content path as well - if (isFile) { - idsByPath[`/${path}`] = `${itemId}/content`; - } - idsByPath[path] = itemId; - } - return itemId; - }; - - // Folder creations/updates - // Assume map entries are sorted from top to bottom - Object.entries(treeFolderMap).forEach(([path, parentPath]) => { - if (path === '.stackedit-trash/') { - idsByPath[path] = 'trash'; - } else { - const item = utils.addItemHash({ - id: getIdFromPath(path), - type: 'folder', - name: path.slice(parentPath.length, -1), - parentId: idsByPath[parentPath] || null, - }); - - const folderSyncData = syncDataByPath[path]; - if (!folderSyncData || folderSyncData.hash !== item.hash) { - changes.push({ - syncDataId: path, - item, - syncData: { - id: path, - type: item.type, - hash: item.hash, - }, - }); - } - } - }); - - // File/content creations/updates - Object.entries(treeFileMap).forEach(([path, parentPath]) => { - const fileId = getIdFromPath(path, true); - const contentPath = `/${path}`; - const contentId = idsByPath[contentPath]; - - // File creations/updates - const item = utils.addItemHash({ - id: fileId, - type: 'file', - name: path.slice(parentPath.length, -'.md'.length), - parentId: idsByPath[parentPath] || null, - }); - - const fileSyncData = syncDataByPath[path]; - if (!fileSyncData || fileSyncData.hash !== item.hash) { - changes.push({ - syncDataId: path, - item, - syncData: { - id: path, - type: item.type, - hash: item.hash, - }, - }); - } - - // Content creations/updates - const contentSyncData = syncDataByPath[contentPath]; - if (!contentSyncData || contentSyncData.sha !== treeShaMap[path]) { - const type = 'content'; - // Use `/` as a prefix to get a unique syncData id - changes.push({ - syncDataId: contentPath, - item: { - id: contentId, - type, - // Need a truthy value to force downloading the content - hash: 1, - }, - syncData: { - id: contentPath, - type, - // Need a truthy value to force downloading the content - hash: 1, - }, - }); - } - }); - - // Data creations/updates - const syncDataByItemId = store.getters['data/syncDataByItemId']; - Object.keys(treeDataMap).forEach((path) => { - // Only template data are stored - const [, id] = path.match(/^\.stackedit-data\/(templates)\.json$/) || []; - if (id) { - idsByPath[path] = id; - const syncData = syncDataByItemId[id]; - if (!syncData || syncData.sha !== treeShaMap[path]) { - const type = 'data'; - changes.push({ - syncDataId: path, - item: { - id, - type, - // Need a truthy value to force saving sync data - hash: 1, - }, - syncData: { - id: path, - type, - // Need a truthy value to force downloading the content - hash: 1, - }, - }); - } - } - }); - - // Location creations/updates - [{ - type: 'syncLocation', - map: treeSyncLocationMap, - pathMatcher: /^([\s\S]+)\.([\w-]+)\.sync$/, - }, { - type: 'publishLocation', - map: treePublishLocationMap, - pathMatcher: /^([\s\S]+)\.([\w-]+)\.publish$/, - }] - .forEach(({ type, map, pathMatcher }) => Object.keys(map).forEach((path) => { - const [, filePath, data] = path.match(pathMatcher) || []; - if (filePath) { - // If there is a corresponding md file in the tree - const fileId = idsByPath[`${filePath}.md`]; - if (fileId) { - // Reuse existing ID or create a new one - const id = itemIdsByGitPath[path] || utils.uid(); - idsByPath[path] = id; - - const item = utils.addItemHash({ - ...JSON.parse(utils.decodeBase64(data)), - id, - type, - fileId, - }); - - const locationSyncData = syncDataByPath[path]; - if (!locationSyncData || locationSyncData.hash !== item.hash) { - changes.push({ - syncDataId: path, - item, - syncData: { - id: path, - type: item.type, - hash: item.hash, - }, - }); - } - } - } - })); - - // Deletions - Object.keys(syncDataByPath).forEach((path) => { - if (!idsByPath[path]) { - changes.push({ syncDataId: path }); - } - }); - - return changes; + return gitWorkspaceSvc.makeChanges(tree); }, async saveWorkspaceItem({ item }) { const syncData = { @@ -342,20 +109,20 @@ export default new Provider({ token: syncToken, path: getAbsolutePath(syncData), content: '', - sha: treeShaMap[syncData.id], + sha: gitWorkspaceSvc.shaByPath[syncData.id], }); // Return sync data to save return { syncData }; }, async removeWorkspaceItem({ syncData }) { - if (treeShaMap[syncData.id]) { + if (gitWorkspaceSvc.shaByPath[syncData.id]) { const syncToken = store.getters['workspace/syncToken']; await githubHelper.removeFile({ ...store.getters['workspace/currentWorkspace'], token: syncToken, path: getAbsolutePath(syncData), - sha: treeShaMap[syncData.id], + sha: gitWorkspaceSvc.shaByPath[syncData.id], }); } }, @@ -370,7 +137,7 @@ export default new Provider({ token, path: getAbsolutePath(fileSyncData), }); - treeShaMap[fileSyncData.id] = sha; + gitWorkspaceSvc.shaByPath[fileSyncData.id] = sha; const content = Provider.parseContent(data, contentId); return { content, @@ -391,7 +158,7 @@ export default new Provider({ token, path: getAbsolutePath(syncData), }); - treeShaMap[syncData.id] = sha; + gitWorkspaceSvc.shaByPath[syncData.id] = sha; const item = JSON.parse(data); return { item, @@ -410,7 +177,7 @@ export default new Provider({ token, path: absolutePath, content: Provider.serializeContent(content), - sha: treeShaMap[path], + sha: gitWorkspaceSvc.shaByPath[path], }); // Return new sync data @@ -440,7 +207,7 @@ export default new Provider({ token, path: getAbsolutePath(syncData), content: JSON.stringify(item), - sha: treeShaMap[path], + sha: gitWorkspaceSvc.shaByPath[path], }); return { @@ -472,7 +239,7 @@ export default new Provider({ } else if (committer && committer.login) { user = committer; } - const sub = `gh:${user.id}`; + const sub = `${githubHelper.subPrefix}:${user.id}`; userSvc.addInfo({ id: sub, name: user.login, imageUrl: user.avatar_url }); const date = (commit.author && commit.author.date) || (commit.committer && commit.committer.date); diff --git a/src/services/providers/gitlabProvider.js b/src/services/providers/gitlabProvider.js new file mode 100644 index 00000000..b0805cfa --- /dev/null +++ b/src/services/providers/gitlabProvider.js @@ -0,0 +1,178 @@ +import store from '../../store'; +import gitlabHelper from './helpers/gitlabHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import workspaceSvc from '../workspaceSvc'; +import userSvc from '../userSvc'; + +const savedSha = {}; + +export default new Provider({ + id: 'gitlab', + name: 'GitLab', + getToken({ sub }) { + return store.getters['data/gitlabTokensBySub'][sub]; + }, + getLocationUrl({ + sub, + projectPath, + branch, + path, + }) { + const token = this.getToken({ sub }); + return `${token.serverUrl}/${projectPath}/blob/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; + }, + getLocationDescription({ path }) { + return path; + }, + async downloadContent(token, syncLocation) { + const { sha, data } = await gitlabHelper.downloadFile({ + ...syncLocation, + token, + }); + savedSha[syncLocation.id] = sha; + return Provider.parseContent(data, `${syncLocation.fileId}/content`); + }, + async uploadContent(token, content, syncLocation) { + const updatedSyncLocation = { + ...syncLocation, + projectId: await gitlabHelper.getProjectId(token, syncLocation), + }; + if (!savedSha[updatedSyncLocation.id]) { + try { + // Get the last sha + await this.downloadContent(token, updatedSyncLocation); + } catch (e) { + // Ignore error + } + } + const sha = savedSha[updatedSyncLocation.id]; + delete savedSha[updatedSyncLocation.id]; + await gitlabHelper.uploadFile({ + ...updatedSyncLocation, + token, + content: Provider.serializeContent(content), + sha, + }); + return updatedSyncLocation; + }, + async publish(token, html, metadata, publishLocation) { + const updatedPublishLocation = { + ...publishLocation, + projectId: await gitlabHelper.getProjectId(token, publishLocation), + }; + try { + // Get the last sha + await this.downloadContent(token, updatedPublishLocation); + } catch (e) { + // Ignore error + } + const sha = savedSha[updatedPublishLocation.id]; + delete savedSha[updatedPublishLocation.id]; + await gitlabHelper.uploadFile({ + ...updatedPublishLocation, + token, + content: html, + sha, + }); + return updatedPublishLocation; + }, + async openFile(token, syncLocation) { + const updatedSyncLocation = { + ...syncLocation, + projectId: await gitlabHelper.getProjectId(token, syncLocation), + }; + + // Check if the file exists and open it + if (!Provider.openFileWithLocation(updatedSyncLocation)) { + // Download content from GitLab + let content; + try { + content = await this.downloadContent(token, updatedSyncLocation); + } catch (e) { + store.dispatch('notification/error', `Could not open file ${updatedSyncLocation.path}.`); + return; + } + + // Create the file + let name = updatedSyncLocation.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); + } + const item = await workspaceSvc.createFile({ + name, + parentId: store.getters['file/current'].parentId, + text: content.text, + properties: content.properties, + discussions: content.discussions, + comments: content.comments, + }, true); + store.commit('file/setCurrentId', item.id); + workspaceSvc.addSyncLocation({ + ...updatedSyncLocation, + fileId: item.id, + }); + store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from GitLab.`); + } + }, + makeLocation(token, projectPath, branch, path) { + return { + providerId: this.id, + sub: token.sub, + projectPath, + branch, + path, + }; + }, + async listFileRevisions({ token, syncLocation }) { + const entries = await gitlabHelper.getCommits({ + ...syncLocation, + token, + }); + + return entries.map(({ + author, + committer, + commit, + sha, + }) => { + let user; + if (author && author.login) { + user = author; + } else if (committer && committer.login) { + user = committer; + } + const sub = `${gitlabHelper.subPrefix}:${user.id}`; + userSvc.addInfo({ id: sub, name: user.login, imageUrl: user.avatar_url }); + const date = (commit.author && commit.author.date) + || (commit.committer && commit.committer.date); + return { + id: sha, + sub, + created: date ? new Date(date).getTime() : 1, + }; + }); + }, + async loadFileRevision() { + // Revision are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + syncLocation, + revisionId, + }) { + const { data } = await gitlabHelper.downloadFile({ + ...syncLocation, + token, + branch: revisionId, + }); + return Provider.parseContent(data, contentId); + }, +}); diff --git a/src/services/providers/gitlabWorkspaceProvider.js b/src/services/providers/gitlabWorkspaceProvider.js new file mode 100644 index 00000000..8439cca8 --- /dev/null +++ b/src/services/providers/gitlabWorkspaceProvider.js @@ -0,0 +1,276 @@ +import store from '../../store'; +import gitlabHelper from './helpers/gitlabHelper'; +import Provider from './common/Provider'; +import utils from '../utils'; +import userSvc from '../userSvc'; +import gitWorkspaceSvc from '../gitWorkspaceSvc'; + +const getAbsolutePath = ({ id }) => + `${store.getters['workspace/currentWorkspace'].path || ''}${id}`; + +export default new Provider({ + id: 'gitlabWorkspace', + name: 'GitLab', + getToken() { + return store.getters['workspace/syncToken']; + }, + getWorkspaceParams({ + serverUrl, + projectPath, + branch, + path, + }) { + return { + providerId: this.id, + serverUrl, + projectPath, + branch, + path, + }; + }, + getWorkspaceLocationUrl({ + serverUrl, + projectPath, + branch, + path, + }) { + return `${serverUrl}/${projectPath}/blob/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`; + }, + getSyncDataUrl({ id }) { + const { projectPath, branch } = store.getters['workspace/currentWorkspace']; + const { serverUrl } = this.getToken(); + return `${serverUrl}/${projectPath}/blob/${encodeURIComponent(branch)}/${utils.encodeUrlPath(getAbsolutePath({ id }))}`; + }, + getSyncDataDescription({ id }) { + return getAbsolutePath({ id }); + }, + async initWorkspace() { + const { projectPath, branch } = utils.queryParams; + const workspaceParams = this.getWorkspaceParams({ projectPath, branch }); + const path = (utils.queryParams.path || '') + .replace(/^\/*/, '') // Remove leading `/` + .replace(/\/*$/, '/'); // Add trailing `/` + if (path !== '/') { + workspaceParams.path = path; + } + const workspaceId = utils.makeWorkspaceId(workspaceParams); + const workspace = store.getters['workspace/workspacesById'][workspaceId]; + + // See if we already have a token + let token; + if (workspace) { + // Token sub is in the workspace + token = store.getters['data/gitlabTokensBySub'][workspace.sub]; + } + if (!token) { + const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' }); + token = await gitlabHelper.addAccount(serverUrl, applicationId); + } + + if (!workspace) { + const pathEntries = (path || '').split('/'); + const projectPathEntries = (projectPath || '').split('/'); + const name = pathEntries[pathEntries.length - 2] // path ends with `/` + || projectPathEntries[projectPathEntries.length - 1]; + store.dispatch('workspace/patchWorkspacesById', { + [workspaceId]: { + ...workspaceParams, + id: workspaceId, + sub: token.sub, + name, + }, + }); + } + + return store.getters['workspace/workspacesById'][workspaceId]; + }, + getChanges() { + return gitlabHelper.getTree({ + ...store.getters['workspace/currentWorkspace'], + token: this.getToken(), + }); + }, + prepareChanges(tree) { + return gitWorkspaceSvc.makeChanges(tree.map(entry => ({ + ...entry, + sha: entry.id, + }))); + }, + async saveWorkspaceItem({ item }) { + const syncData = { + id: store.getters.gitPathsByItemId[item.id], + type: item.type, + hash: item.hash, + }; + + // Files and folders are not in git, only contents + if (item.type === 'file' || item.type === 'folder') { + return { syncData }; + } + + // locations are stored as paths, so we upload an empty file + const syncToken = store.getters['workspace/syncToken']; + await gitlabHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token: syncToken, + path: getAbsolutePath(syncData), + content: '', + sha: gitWorkspaceSvc.shaByPath[syncData.id], + }); + + // Return sync data to save + return { syncData }; + }, + async removeWorkspaceItem({ syncData }) { + if (gitWorkspaceSvc.shaByPath[syncData.id]) { + const syncToken = store.getters['workspace/syncToken']; + await gitlabHelper.removeFile({ + ...store.getters['workspace/currentWorkspace'], + token: syncToken, + path: getAbsolutePath(syncData), + sha: gitWorkspaceSvc.shaByPath[syncData.id], + }); + } + }, + async downloadWorkspaceContent({ + token, + contentId, + contentSyncData, + fileSyncData, + }) { + const { sha, data } = await gitlabHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(fileSyncData), + }); + gitWorkspaceSvc.shaByPath[fileSyncData.id] = sha; + const content = Provider.parseContent(data, contentId); + return { + content, + contentSyncData: { + ...contentSyncData, + hash: content.hash, + sha, + }, + }; + }, + async downloadWorkspaceData({ token, syncData }) { + if (!syncData) { + return {}; + } + + const { sha, data } = await gitlabHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(syncData), + }); + gitWorkspaceSvc.shaByPath[syncData.id] = sha; + const item = JSON.parse(data); + return { + item, + syncData: { + ...syncData, + hash: item.hash, + sha, + }, + }; + }, + async uploadWorkspaceContent({ token, content, file }) { + const path = store.getters.gitPathsByItemId[file.id]; + const absolutePath = `${store.getters['workspace/currentWorkspace'].path || ''}${path}`; + const res = await gitlabHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: absolutePath, + content: Provider.serializeContent(content), + sha: gitWorkspaceSvc.shaByPath[path], + }); + + // Return new sync data + return { + contentSyncData: { + id: store.getters.gitPathsByItemId[content.id], + type: content.type, + hash: content.hash, + sha: res.content.sha, + }, + fileSyncData: { + id: path, + type: 'file', + hash: file.hash, + }, + }; + }, + async uploadWorkspaceData({ token, item }) { + const path = store.getters.gitPathsByItemId[item.id]; + const syncData = { + id: path, + type: item.type, + hash: item.hash, + }; + const res = await gitlabHelper.uploadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + path: getAbsolutePath(syncData), + content: JSON.stringify(item), + sha: gitWorkspaceSvc.shaByPath[path], + }); + + return { + syncData: { + ...syncData, + sha: res.content.sha, + }, + }; + }, + async listFileRevisions({ token, fileSyncData }) { + const { projectId, branch } = store.getters['workspace/currentWorkspace']; + const entries = await gitlabHelper.getCommits({ + token, + projectId, + sha: branch, + path: getAbsolutePath(fileSyncData), + }); + + return entries.map(({ + author, + committer, + commit, + sha, + }) => { + let user; + if (author && author.login) { + user = author; + } else if (committer && committer.login) { + user = committer; + } + const sub = `${gitlabHelper.subPrefix}:${user.id}`; + userSvc.addInfo({ id: sub, name: user.login, imageUrl: user.avatar_url }); + const date = (commit.author && commit.author.date) + || (commit.committer && commit.committer.date); + return { + id: sha, + sub, + created: date ? new Date(date).getTime() : 1, + }; + }); + }, + async loadFileRevision() { + // Revisions are already loaded + return false; + }, + async getFileRevisionContent({ + token, + contentId, + fileSyncData, + revisionId, + }) { + const { data } = await gitlabHelper.downloadFile({ + ...store.getters['workspace/currentWorkspace'], + token, + branch: revisionId, + path: getAbsolutePath(fileSyncData), + }); + return Provider.parseContent(data, contentId); + }, +}); diff --git a/src/services/providers/googleDriveAppDataProvider.js b/src/services/providers/googleDriveAppDataProvider.js index 4e0264e8..7aeceee7 100644 --- a/src/services/providers/googleDriveAppDataProvider.js +++ b/src/services/providers/googleDriveAppDataProvider.js @@ -171,7 +171,7 @@ export default new Provider({ const revisions = await googleHelper.getAppDataFileRevisions(token, contentSyncData.id); return revisions.map(revision => ({ id: revision.id, - sub: `go:${revision.lastModifyingUser.permissionId}`, + sub: `${googleHelper.subPrefix}:${revision.lastModifyingUser.permissionId}`, created: new Date(revision.modifiedTime).getTime(), })); }, diff --git a/src/services/providers/googleDriveProvider.js b/src/services/providers/googleDriveProvider.js index bd033023..31ff99a5 100644 --- a/src/services/providers/googleDriveProvider.js +++ b/src/services/providers/googleDriveProvider.js @@ -195,7 +195,7 @@ export default new Provider({ const revisions = await googleHelper.getFileRevisions(token, syncLocation.driveFileId); return revisions.map(revision => ({ id: revision.id, - sub: `go:${revision.lastModifyingUser.permissionId}`, + sub: `${googleHelper.subPrefix}:${revision.lastModifyingUser.permissionId}`, created: new Date(revision.modifiedTime).getTime(), })); }, diff --git a/src/services/providers/googleDriveWorkspaceProvider.js b/src/services/providers/googleDriveWorkspaceProvider.js index 3363b7c2..d4d8ac8d 100644 --- a/src/services/providers/googleDriveWorkspaceProvider.js +++ b/src/services/providers/googleDriveWorkspaceProvider.js @@ -512,7 +512,7 @@ export default new Provider({ const revisions = await googleHelper.getFileRevisions(token, fileSyncData.id); return revisions.map(revision => ({ id: revision.id, - sub: `go:${revision.lastModifyingUser.permissionId}`, + sub: `${googleHelper.subPrefix}:${revision.lastModifyingUser.permissionId}`, created: new Date(revision.modifiedTime).getTime(), })); }, diff --git a/src/services/providers/helpers/couchdbHelper.js b/src/services/providers/helpers/couchdbHelper.js index 16b319a5..84eca0e7 100644 --- a/src/services/providers/helpers/couchdbHelper.js +++ b/src/services/providers/helpers/couchdbHelper.js @@ -1,6 +1,7 @@ import networkSvc from '../../networkSvc'; import utils from '../../utils'; import store from '../../../store'; +import userSvc from '../../userSvc'; const request = async (token, options = {}) => { const baseUrl = `${token.dbUrl}/`; @@ -117,7 +118,7 @@ export default { method: 'POST', body: { item, time: Date.now() }, }; - const userId = store.getters['workspace/userId']; + const userId = userSvc.getCurrentUserId(); if (userId) { options.body.sub = userId; } diff --git a/src/services/providers/helpers/dropboxHelper.js b/src/services/providers/helpers/dropboxHelper.js index 14e161ab..2021fdb6 100644 --- a/src/services/providers/helpers/dropboxHelper.js +++ b/src/services/providers/helpers/dropboxHelper.js @@ -1,4 +1,5 @@ import networkSvc from '../../networkSvc'; +import userSvc from '../../userSvc'; import store from '../../../store'; const getAppKey = (fullAccess) => { @@ -22,10 +23,40 @@ const request = ({ accessToken }, options, args) => networkSvc.request({ }, }); +/** + * https://www.dropbox.com/developers/documentation/http/documentation#users-get_account + */ +const subPrefix = 'db'; +userSvc.setInfoResolver('dropbox', subPrefix, async (sub) => { + const dropboxToken = Object.values(store.getters['data/dropboxTokensBySub'])[0]; + try { + const { body } = await request(dropboxToken, { + method: 'POST', + url: 'https://api.dropboxapi.com/2/users/get_account', + body: { + account_id: sub, + }, + }); + + return { + id: `${subPrefix}:${body.account_id}`, + name: body.name.display_name, + imageUrl: body.profile_photo_url || '', + }; + } catch (err) { + if (!dropboxToken || err.status !== 404) { + throw new Error('RETRY'); + } + throw err; + } +}); + export default { + subPrefix, /** * https://www.dropbox.com/developers/documentation/http/documentation#oauth2-authorize + * https://www.dropbox.com/developers/documentation/http/documentation#users-get_current_account */ async startOauth2(fullAccess, sub = null, silent = false) { const { accessToken } = await networkSvc.startOauth2( @@ -42,6 +73,11 @@ export default { method: 'POST', url: 'https://api.dropboxapi.com/2/users/get_current_account', }); + userSvc.addInfo({ + id: `${subPrefix}:${body.account_id}`, + name: body.name.display_name, + imageUrl: body.profile_photo_url || '', + }); // Check the returned sub consistency if (sub && `${body.account_id}` !== sub) { @@ -64,28 +100,6 @@ export default { return this.startOauth2(fullAccess); }, - /** - * https://www.dropbox.com/developers/documentation/http/documentation#users-get_account - */ - async getAccount(token, userId) { - const { body } = await request(token, { - method: 'POST', - url: 'https://api.dropboxapi.com/2/users/get_account', - body: { - account_id: userId, - }, - }); - - // Add user info to the store - store.commit('userInfo/addItem', { - id: `db:${body.account_id}`, - name: body.name.display_name, - imageUrl: body.profile_photo_url || '', - }); - - return body; - }, - /** * https://www.dropbox.com/developers/documentation/http/documentation#files-upload */ diff --git a/src/services/providers/helpers/githubHelper.js b/src/services/providers/helpers/githubHelper.js index 1cb0a8d8..7bbbac10 100644 --- a/src/services/providers/helpers/githubHelper.js +++ b/src/services/providers/helpers/githubHelper.js @@ -1,6 +1,7 @@ import utils from '../../utils'; import networkSvc from '../../networkSvc'; import store from '../../../store'; +import userSvc from '../../userSvc'; const clientId = GITHUB_CLIENT_ID; const getScopes = token => [token.repoFullAccess ? 'repo' : 'public_repo', 'gist']; @@ -24,11 +25,39 @@ const repoRequest = (token, owner, repo, options) => request(token, { .then(res => res.body); const getCommitMessage = (name, path) => { - const message = store.getters['data/computedSettings'].github[name]; + const message = store.getters['data/computedSettings'].git[name]; return message.replace(/{{path}}/g, path); }; +/** + * Getting a user from its userId is not feasible with API v3. + * Using an undocumented endpoint... + */ +const subPrefix = 'gh'; +userSvc.setInfoResolver('github', subPrefix, async (sub) => { + try { + const user = (await networkSvc.request({ + url: `https://api.github.com/user/${sub}`, + params: { + t: Date.now(), // Prevent from caching + }, + })).body; + + return { + id: `${subPrefix}:${user.id}`, + name: user.login, + imageUrl: user.avatar_url || '', + }; + } catch (err) { + if (err.status !== 404) { + throw new Error('RETRY'); + } + throw err; + } +}); + export default { + subPrefix, /** * https://developer.github.com/apps/building-oauth-apps/authorization-options-for-oauth-apps/ @@ -61,6 +90,11 @@ export default { access_token: accessToken, }, })).body; + userSvc.addInfo({ + id: `${subPrefix}:${user.id}`, + name: user.login, + imageUrl: user.avatar_url || '', + }); // Check the returned sub consistency if (sub && `${user.id}` !== sub) { @@ -84,28 +118,6 @@ export default { return this.startOauth2(getScopes({ repoFullAccess })); }, - /** - * Getting a user from its userId is not feasible with API v3. - * Using an undocumented endpoint... - */ - async getUser(userId) { - const user = (await networkSvc.request({ - url: `https://api.github.com/user/${userId}`, - params: { - t: Date.now(), // Prevent from caching - }, - })).body; - - // Add user info to the store - store.commit('userInfo/addItem', { - id: `gh:${user.id}`, - name: user.login, - imageUrl: user.avatar_url || '', - }); - - return user; - }, - /** * https://developer.github.com/v3/repos/commits/#get-a-single-commit * https://developer.github.com/v3/git/trees/#get-a-tree diff --git a/src/services/providers/helpers/gitlabHelper.js b/src/services/providers/helpers/gitlabHelper.js new file mode 100644 index 00000000..5ebd14ea --- /dev/null +++ b/src/services/providers/helpers/gitlabHelper.js @@ -0,0 +1,207 @@ +import utils from '../../utils'; +import networkSvc from '../../networkSvc'; +import store from '../../../store'; +import userSvc from '../../userSvc'; + +const request = ({ accessToken, serverUrl }, options) => networkSvc.request({ + ...options, + url: `${serverUrl}/api/v4/${options.url}`, + headers: { + ...options.headers || {}, + Authorization: `Bearer ${accessToken}`, + }, +}) + .then(res => res.body); + +const getCommitMessage = (name, path) => { + const message = store.getters['data/computedSettings'].git[name]; + return message.replace(/{{path}}/g, path); +}; + +/** + * https://docs.gitlab.com/ee/api/users.html#for-user + */ +const subPrefix = 'gl'; +userSvc.setInfoResolver('gitlab', subPrefix, async (sub) => { + try { + const [, serverUrl, id] = sub.match(/^(.+)\/([^/]+)$/); + const user = (await networkSvc.request({ + url: `${serverUrl}/api/v4/users/${id}`, + })).body; + const uniqueSub = `${serverUrl}/${user.id}`; + + return { + id: `${subPrefix}:${uniqueSub}`, + name: user.username, + imageUrl: user.avatar_url || '', + }; + } catch (err) { + if (err.status !== 404) { + throw new Error('RETRY'); + } + throw err; + } +}); + +export default { + subPrefix, + + /** + * https://docs.gitlab.com/ee/api/oauth2.html + */ + async startOauth2(serverUrl, applicationId, sub = null, silent = false) { + const { accessToken } = await networkSvc.startOauth2( + `${serverUrl}/oauth/authorize`, + { + client_id: applicationId, + response_type: 'token', + scope: 'api', + }, + silent, + ); + + // Call the user info endpoint + const user = await request({ accessToken, serverUrl }, { + url: 'user', + }); + const uniqueSub = `${serverUrl}/${user.id}`; + userSvc.addInfo({ + id: `${subPrefix}:${uniqueSub}`, + name: user.username, + imageUrl: user.avatar_url || '', + }); + + // Check the returned sub consistency + if (sub && uniqueSub !== sub) { + throw new Error('GitLab account ID not expected.'); + } + + // Build token object including scopes and sub + const token = { + accessToken, + name: user.username, + serverUrl, + sub: uniqueSub, + }; + + // Add token to gitlab tokens + store.dispatch('data/addGitlabToken', token); + return token; + }, + addAccount(serverUrl, applicationId) { + return this.startOauth2(serverUrl, applicationId); + }, + + /** + * https://docs.gitlab.com/ee/api/projects.html#get-single-project + */ + async getProjectId(token, { projectPath, projectId }) { + if (projectId) { + return projectId; + } + + const project = await request(token, { + url: `projects/${encodeURIComponent(projectPath)}`, + }); + return project.id; + }, + + /** + * https://docs.gitlab.com/ee/api/repositories.html#list-repository-tree + */ + async getTree({ + token, + projectId, + branch, + }) { + return request(token, { + url: `projects/${encodeURIComponent(projectId)}/repository/tree`, + params: { + ref: branch, + recursive: true, + }, + }); + }, + + /** + * https://docs.gitlab.com/ee/api/commits.html#list-repository-commits + */ + async getCommits({ + token, + projectId, + branch, + path, + }) { + return request(token, { + url: `projects/${encodeURIComponent(projectId)}/repository/tree`, + params: { + ref_name: branch, + path, + }, + }); + }, + + /** + * https://docs.gitlab.com/ee/api/repository_files.html#create-new-file-in-repository + * https://docs.gitlab.com/ee/api/repository_files.html#update-existing-file-in-repository + */ + async uploadFile({ + token, + projectId, + branch, + path, + content, + sha, + }) { + return request(token, { + method: sha ? 'PUT' : 'POST', + url: `projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(path)}`, + body: { + commit_message: getCommitMessage(sha ? 'updateFileMessage' : 'createFileMessage', path), + content, + last_commit_id: sha, + branch, + }, + }); + }, + + /** + * https://docs.gitlab.com/ee/api/repository_files.html#delete-existing-file-in-repository + */ + async removeFile({ + token, + projectId, + branch, + path, + sha, + }) { + return request(token, { + method: 'DELETE', + url: `projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(path)}`, + body: { + commit_message: getCommitMessage('deleteFileMessage', path), + last_commit_id: sha, + branch, + }, + }); + }, + + /** + * https://docs.gitlab.com/ee/api/repository_files.html#get-file-from-repository + */ + async downloadFile({ + token, + projectId, + branch, + path, + }) { + const res = await request(token, { + url: `projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(path)}`, + params: { ref: branch }, + }); + return { + sha: res.last_commit_id, + data: utils.decodeBase64(res.content), + }; + }, +}; diff --git a/src/services/providers/helpers/googleHelper.js b/src/services/providers/helpers/googleHelper.js index b92c7524..997eaa99 100644 --- a/src/services/providers/helpers/googleHelper.js +++ b/src/services/providers/helpers/googleHelper.js @@ -1,6 +1,7 @@ import utils from '../../utils'; import networkSvc from '../../networkSvc'; import store from '../../../store'; +import userSvc from '../../userSvc'; const clientId = GOOGLE_CLIENT_ID; const apiKey = 'AIzaSyC_M4RA9pY6XmM9pmFxlT59UPMO7aHr9kk'; @@ -34,7 +35,45 @@ if (utils.queryParams.providerId === 'googleDrive') { } } +/** + * https://developers.google.com/+/web/api/rest/latest/people/get + */ +const getUser = async (sub, token) => { + const { body } = await networkSvc.request(token + ? { + method: 'GET', + url: `https://www.googleapis.com/plus/v1/people/${sub}`, + headers: { + Authorization: `Bearer ${token.accessToken}`, + }, + } + : { + method: 'GET', + url: `https://www.googleapis.com/plus/v1/people/${sub}?key=${apiKey}`, + }, true); + return body; +}; + +const subPrefix = 'go'; +userSvc.setInfoResolver('google', subPrefix, async (sub) => { + try { + const googleToken = Object.values(store.getters['data/googleTokensBySub'])[0]; + const body = await getUser(sub, googleToken); + return { + id: `${subPrefix}:${body.id}`, + name: body.displayName, + imageUrl: (body.image.url || '').replace(/\bsz?=\d+$/, 'sz=40'), + }; + } catch (err) { + if (err.status !== 404) { + throw new Error('RETRY'); + } + throw err; + } +}); + export default { + subPrefix, folderMimeType: 'application/vnd.google-apps.folder', driveState, driveActionFolder: null, @@ -119,16 +158,14 @@ export default { driveFullAccess: scopes.indexOf('https://www.googleapis.com/auth/drive') !== -1, }; - try { - // Call the user info endpoint - token.name = (await this.getUser(token.sub)).displayName; - } catch (err) { - if (err.status === 404) { - store.dispatch('notification/info', 'Please activate Google Plus to change your account name and photo.'); - } else { - throw err; - } - } + // Call the user info endpoint + const user = await getUser('me', token); + token.name = user.displayName; + userSvc.addInfo({ + id: `${subPrefix}:${user.id}`, + name: user.displayName, + imageUrl: (user.image.url || '').replace(/\bsz?=\d+$/, 'sz=40'), + }); if (existingToken) { // We probably retrieved a new token with restricted scopes. @@ -413,7 +450,7 @@ export default { }); revisions.forEach((revision) => { store.commit('userInfo/addItem', { - id: `go:${revision.lastModifyingUser.permissionId}`, + id: `${subPrefix}:${revision.lastModifyingUser.permissionId}`, name: revision.lastModifyingUser.displayName, imageUrl: revision.lastModifyingUser.photoLink, }); @@ -454,22 +491,6 @@ export default { return this.$downloadFileRevision(refreshedToken, fileId, revisionId); }, - /** - * https://developers.google.com/+/web/api/rest/latest/people/get - */ - async getUser(userId) { - const { body } = await networkSvc.request({ - method: 'GET', - url: `https://www.googleapis.com/plus/v1/people/${userId}?key=${apiKey}`, - }, true); - store.commit('userInfo/addItem', { - id: `go:${body.id}`, - name: body.displayName, - imageUrl: (body.image.url || '').replace(/\bsz?=\d+$/, 'sz=40'), - }); - return body; - }, - /** * https://developers.google.com/drive/v3/reference/changes/list */ diff --git a/src/services/providers/wordpressProvider.js b/src/services/providers/wordpressProvider.js index c469d7f3..a244e856 100644 --- a/src/services/providers/wordpressProvider.js +++ b/src/services/providers/wordpressProvider.js @@ -5,11 +5,11 @@ import Provider from './common/Provider'; export default new Provider({ id: 'wordpress', name: 'WordPress', - getToken(location) { - return store.getters['data/wordpressTokensBySub'][location.sub]; + getToken({ sub }) { + return store.getters['data/wordpressTokensBySub'][sub]; }, - getLocationUrl(location) { - return `https://wordpress.com/post/${location.siteId}/${location.postId}`; + getLocationUrl({ siteId, postId }) { + return `https://wordpress.com/post/${siteId}/${postId}`; }, getLocationDescription({ postId }) { return postId; diff --git a/src/services/providers/zendeskProvider.js b/src/services/providers/zendeskProvider.js index 690d88fe..27b60666 100644 --- a/src/services/providers/zendeskProvider.js +++ b/src/services/providers/zendeskProvider.js @@ -5,12 +5,12 @@ import Provider from './common/Provider'; export default new Provider({ id: 'zendesk', name: 'Zendesk', - getToken(location) { - return store.getters['data/zendeskTokensBySub'][location.sub]; + getToken({ sub }) { + return store.getters['data/zendeskTokensBySub'][sub]; }, - getLocationUrl(location) { - const token = this.getToken(location); - return `https://${token.subdomain}.zendesk.com/hc/${location.locale}/articles/${location.articleId}`; + getLocationUrl({ sub, locale, articleId }) { + const token = this.getToken({ sub }); + return `https://${token.subdomain}.zendesk.com/hc/${locale}/articles/${articleId}`; }, getLocationDescription({ articleId }) { return articleId; diff --git a/src/services/publishSvc.js b/src/services/publishSvc.js index 04c7d198..21a716ab 100644 --- a/src/services/publishSvc.js +++ b/src/services/publishSvc.js @@ -129,7 +129,8 @@ const createPublishLocation = (publishLocation) => { store.dispatch( 'queue/enqueue', async () => { - workspaceSvc.addPublishLocation(await publish(publishLocation)); + const publishLocationToStore = await publish(publishLocation); + workspaceSvc.addPublishLocation(publishLocationToStore); store.dispatch('notification/info', `A new publication location was added to "${currentFile.name}".`); }, ); diff --git a/src/services/syncSvc.js b/src/services/syncSvc.js index b532d3d7..032821cd 100644 --- a/src/services/syncSvc.js +++ b/src/services/syncSvc.js @@ -7,6 +7,7 @@ import providerRegistry from './providers/common/providerRegistry'; import googleDriveAppDataProvider from './providers/googleDriveAppDataProvider'; import './providers/couchdbWorkspaceProvider'; import './providers/githubWorkspaceProvider'; +import './providers/gitlabWorkspaceProvider'; import './providers/googleDriveWorkspaceProvider'; import tempFileSvc from './tempFileSvc'; import workspaceSvc from './workspaceSvc'; diff --git a/src/services/userSvc.js b/src/services/userSvc.js index 61406b04..fb18e8b2 100644 --- a/src/services/userSvc.js +++ b/src/services/userSvc.js @@ -1,70 +1,79 @@ -import googleHelper from './providers/helpers/googleHelper'; -import githubHelper from './providers/helpers/githubHelper'; import store from '../store'; -import dropboxHelper from './providers/helpers/dropboxHelper'; -import constants from '../data/constants'; -const promised = {}; +const infoPromisesByUserId = {}; +const infoResolversByType = {}; +const subPrefixesByType = {}; +const typesBySubPrefix = {}; const parseUserId = (userId) => { const prefix = userId[2] === ':' && userId.slice(0, 2); - const type = prefix && constants.userIdPrefixes[prefix]; + const type = typesBySubPrefix[prefix]; return type ? [type, userId.slice(3)] : ['google', userId]; }; + export default { - addInfo({ id, name, imageUrl }) { - promised[id] = true; - store.commit('userInfo/addItem', { id, name, imageUrl }); + setInfoResolver(type, subPrefix, resolver) { + infoResolversByType[type] = resolver; + subPrefixesByType[type] = subPrefix; + typesBySubPrefix[subPrefix] = type; + }, + getCurrentUserId() { + const loginToken = store.getters['workspace/loginToken']; + if (!loginToken) { + return null; + } + const loginType = store.getters['workspace/loginToken']; + const prefix = subPrefixesByType[loginType]; + return prefix ? `${prefix}:${loginToken.sub}` : loginToken.sub; + }, + addInfo(info) { + infoPromisesByUserId[info.id] = Promise.resolve(info); + store.commit('userInfo/addItem', info); }, async getInfo(userId) { - if (userId && !promised[userId]) { - const [type, sub] = parseUserId(userId); + if (!userId) { + return {}; + } - // Try to find a token with this sub - const token = store.getters[`data/${type}TokensBySub`][sub]; - if (token) { - store.commit('userInfo/addItem', { - id: userId, - name: token.name, - }); - } + let infoPromise = infoPromisesByUserId[userId]; + if (infoPromise) { + return infoPromise; + } - // Get user info from provider - if (!store.state.offline) { - promised[userId] = true; - switch (type) { - case 'dropbox': { - const dropboxToken = Object.values(store.getters['data/dropboxTokensBySub'])[0]; - try { - await dropboxHelper.getAccount(dropboxToken, sub); - } catch (err) { - if (!token || err.status !== 404) { - promised[userId] = false; - } - } - break; + const [type, sub] = parseUserId(userId); + + // Try to find a token with this sub to resolve name as soon as possible + const token = store.getters[`data/${type}TokensBySub`][sub]; + if (token) { + store.commit('userInfo/addItem', { + id: userId, + name: token.name, + }); + } + + if (store.state.offline) { + return {}; + } + + // Get user info from helper + infoPromise = new Promise(async (resolve) => { + const infoResolver = infoResolversByType[type]; + if (infoResolver) { + try { + const userInfo = await infoResolver(sub); + this.addInfo(userInfo); + resolve(userInfo); + } catch (err) { + if (err && err.message === 'RETRY') { + infoPromisesByUserId[userId] = null; } - case 'github': - try { - await githubHelper.getUser(sub); - } catch (err) { - if (err.status !== 404) { - promised[userId] = false; - } - } - break; - case 'google': - default: - try { - await googleHelper.getUser(sub); - } catch (err) { - if (err.status !== 404) { - promised[userId] = false; - } - } + resolve({}); } } - } + }); + + infoPromisesByUserId[userId] = infoPromise; + return infoPromise; }, }; diff --git a/src/services/utils.js b/src/services/utils.js index 1c18220d..5f01264c 100644 --- a/src/services/utils.js +++ b/src/services/utils.js @@ -287,6 +287,10 @@ export default { repo: parsedRepo[2], }; }, + parseGitlabProjectPath(url) { + const parsedProject = url && url.match(/^https:\/\/[^/]+\/(.+?)(?:\.git|\/)?$/); + return parsedProject && parsedProject[1]; + }, createHiddenIframe(url) { const iframeElt = document.createElement('iframe'); iframeElt.style.position = 'absolute'; diff --git a/src/store/data.js b/src/store/data.js index bdc6814e..a46ccf71 100644 --- a/src/store/data.js +++ b/src/store/data.js @@ -85,7 +85,7 @@ const additionalTemplates = { // For tokens const tokenAdder = providerId => ({ getters, dispatch }, token) => { - dispatch('patchTokensByProviderId', { + dispatch('patchTokensByType', { [providerId]: { ...getters[`${providerId}TokensBySub`], [token.sub]: token, @@ -188,13 +188,14 @@ export default { return result; }, dataSyncDataById: getter('dataSyncData'), - tokensByProviderId: getter('tokens'), - googleTokensBySub: (state, { tokensByProviderId }) => tokensByProviderId.google || {}, - couchdbTokensBySub: (state, { tokensByProviderId }) => tokensByProviderId.couchdb || {}, - dropboxTokensBySub: (state, { tokensByProviderId }) => tokensByProviderId.dropbox || {}, - githubTokensBySub: (state, { tokensByProviderId }) => tokensByProviderId.github || {}, - wordpressTokensBySub: (state, { tokensByProviderId }) => tokensByProviderId.wordpress || {}, - zendeskTokensBySub: (state, { tokensByProviderId }) => tokensByProviderId.zendesk || {}, + tokensByType: getter('tokens'), + googleTokensBySub: (state, { tokensByType }) => tokensByType.google || {}, + couchdbTokensBySub: (state, { tokensByType }) => tokensByType.couchdb || {}, + dropboxTokensBySub: (state, { tokensByType }) => tokensByType.dropbox || {}, + githubTokensBySub: (state, { tokensByType }) => tokensByType.github || {}, + gitlabTokensBySub: (state, { tokensByType }) => tokensByType.gitlab || {}, + wordpressTokensBySub: (state, { tokensByType }) => tokensByType.wordpress || {}, + zendeskTokensBySub: (state, { tokensByType }) => tokensByType.zendesk || {}, }, actions: { setSettings: setter('settings'), @@ -258,11 +259,12 @@ export default { setSyncDataById: setter('syncData'), patchSyncDataById: patcher('syncData'), patchDataSyncDataById: patcher('dataSyncData'), - patchTokensByProviderId: patcher('tokens'), + patchTokensByType: patcher('tokens'), addGoogleToken: tokenAdder('google'), addCouchdbToken: tokenAdder('couchdb'), addDropboxToken: tokenAdder('dropbox'), addGithubToken: tokenAdder('github'), + addGitlabToken: tokenAdder('gitlab'), addWordpressToken: tokenAdder('wordpress'), addZendeskToken: tokenAdder('zendesk'), }, diff --git a/src/store/workspace.js b/src/store/workspace.js index ea436a09..1e94e846 100644 --- a/src/store/workspace.js +++ b/src/store/workspace.js @@ -1,6 +1,5 @@ import utils from '../services/utils'; import providerRegistry from '../services/providers/common/providerRegistry'; -import constants from '../data/constants'; export default { namespaced: true, @@ -44,9 +43,11 @@ export default { currentWorkspace: ({ currentWorkspaceId }, { workspacesById, mainWorkspace }) => workspacesById[currentWorkspaceId] || mainWorkspace, currentWorkspaceIsGit: (state, { currentWorkspace }) => - currentWorkspace.providerId === 'githubWorkspace', + currentWorkspace.providerId === 'githubWorkspace' + || currentWorkspace.providerId === 'gitlabWorkspace', currentWorkspaceHasUniquePaths: (state, { currentWorkspace }) => - currentWorkspace.providerId === 'githubWorkspace', + currentWorkspace.providerId === 'githubWorkspace' + || currentWorkspace.providerId === 'gitlabWorkspace', lastSyncActivityKey: (state, { currentWorkspace }) => `${currentWorkspace.id}/lastSyncActivity`, lastFocusKey: (state, { currentWorkspace }) => `${currentWorkspace.id}/lastWindowFocus`, mainWorkspaceToken: (state, getters, rootState, rootGetters) => @@ -62,33 +63,28 @@ export default { return rootGetters['data/googleTokensBySub'][currentWorkspace.sub]; case 'githubWorkspace': return rootGetters['data/githubTokensBySub'][currentWorkspace.sub]; + case 'gitlabWorkspace': + return rootGetters['data/gitlabTokensBySub'][currentWorkspace.sub]; case 'couchdbWorkspace': return rootGetters['data/couchdbTokensBySub'][currentWorkspace.id]; default: return mainWorkspaceToken; } }, - loginToken: (state, { currentWorkspace, mainWorkspaceToken }, rootState, rootGetters) => { + loginType: (state, { currentWorkspace }) => { switch (currentWorkspace.providerId) { case 'googleDriveWorkspace': - return rootGetters['data/googleTokensBySub'][currentWorkspace.sub]; - case 'githubWorkspace': - return rootGetters['data/githubTokensBySub'][currentWorkspace.sub]; default: - return mainWorkspaceToken; + return 'google'; + case 'githubWorkspace': + return 'github'; + case 'gitlabWorkspace': + return 'gitlab'; } }, - userId: (state, { loginToken }, rootState, rootGetters) => { - if (!loginToken) { - return null; - } - const prefix = utils.someResult(Object.entries(constants.userIdPrefixes), ([key, value]) => { - if (rootGetters[`data/${value}TokensBySub`][loginToken.sub]) { - return key; - } - return null; - }); - return prefix ? `${prefix}:${loginToken.sub}` : loginToken.sub; + loginToken: (state, { loginType, currentWorkspace }, rootState, rootGetters) => { + const tokensBySub = rootGetters['data/tokensByType'][loginType]; + return tokensBySub && tokensBySub[currentWorkspace.sub]; }, sponsorToken: (state, { mainWorkspaceToken }) => mainWorkspaceToken, }, diff --git a/src/styles/app.scss b/src/styles/app.scss index a6c8f349..22c05148 100644 --- a/src/styles/app.scss +++ b/src/styles/app.scss @@ -49,6 +49,10 @@ body { outline: none; } +input[type=checkbox] { + outline: #349be8 auto 5px; +} + .icon { width: 100%; height: 100%; @@ -153,6 +157,7 @@ textarea { color: #fff; margin: -2px 0 -2px 4px; padding: 10px 20px; + font-size: 18px; &:active, &:focus, diff --git a/src/styles/variables.scss b/src/styles/variables.scss index 4e65bf42..f34ed491 100644 --- a/src/styles/variables.scss +++ b/src/styles/variables.scss @@ -9,10 +9,10 @@ $font-size-monospace: 0.85em; $highlighting-color: #ff0; $selection-highlighting-color: #ff9632; $info-bg: transparentize($selection-highlighting-color, 0.85); -$code-border-radius: 2px; +$code-border-radius: 3px; $link-color: #0c93e4; $error-color: #f31; -$border-radius-base: 2px; +$border-radius-base: 3px; $hr-color: rgba(128, 128, 128, 0.2); $navbar-bg: #2c2c2c; $navbar-color: mix($navbar-bg, #fff, 33%);