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 @@
+
+
+
+
+
+
+
Open a file from your GitLab project and keep it synced.
+
+
+
+ Example: {{config.token.serverUrl}}path/to/project
+
+
+
+
+
+ Example: path/to/README.md
+
+
+
+
+
+ If not supplied, the master
branch will be used.
+
+
+
+
+ Cancel
+ Ok
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
Publish {{currentFileName}} to your GitLab project.
+
+
+
+ Example: {{config.token.serverUrl}}/path/to/project
+
+
+
+
+
+ Example: path/to/README.md
+ If the file exists, it will be overwritten.
+
+
+
+
+
+ If not supplied, the master
branch will be used.
+
+
+
+
+
+ {{ template.name }}
+
+
+
+
+
+
+ Cancel
+ Ok
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
Save {{currentFileName}} to your GitLab project and keep it synced.
+
+
+
+ Example: {{config.token.serverUrl}}/path/to/project
+
+
+
+
+
+ Example: path/to/README.md
+ If the file exists, it will be overwritten.
+
+
+
+
+
+ If not supplied, the master
branch will be used.
+
+
+
+
+ Cancel
+ Ok
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
Create a workspace synced with a GitLab project folder.
+
+
+
+ Example: {{config.token.serverUrl}}/path/to/project
+
+
+
+
+
+ If not supplied, the root folder will be used.
+
+
+
+
+
+ If not supplied, the master
branch will be used.
+
+
+
+
+ Cancel
+ Ok
+
+
+
+
+
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 @@
- You have to configure an OAuth Client with redirect URL
{{redirectUrl}}
-
More info
+ You have to configure an OAuth Client with redirect URL
{{redirectUrl}}
+
+
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%);