Sync refactoring

This commit is contained in:
Benoit Schweblin 2018-07-17 20:58:40 +01:00
parent a1673d3e87
commit 987d66ef26
75 changed files with 902 additions and 528 deletions

View File

@ -353,7 +353,7 @@ export default {
.find-replace__find-stats {
text-align: right;
font-size: 0.75em;
opacity: 0.5;
opacity: 0.6;
}
.find-replace-highlighting {

View File

@ -116,6 +116,11 @@ export default {
hr + hr {
display: none;
}
.textfield {
font-size: 14px;
height: 26px;
}
}
.side-bar__inner {

View File

@ -2,12 +2,12 @@
<div class="tour" @keydown.esc="skip">
<div class="tour-step" :class="'tour-step--' + step" :style="stepStyle">
<div class="tour-step__inner" v-if="step === 'welcome'">
<h2>Welcome to StackEdit!</h2>
<p>Greater, lighter, faster... <b>StackEdit 5</b> is here!</p>
<h2>Welcome back!</h2>
<p>The new <b>StackEdit 5</b> is here!</p>
<p>Please click <b>Next</b> to take a quick tour.</p>
<div class="tour-step__button-bar">
<button class="button" @click="finish">Skip</button>
<button class="button" @click="next">Next</button>
<button class="button button--resolve" @click="next">Next</button>
</div>
</div>
<div class="tour-step__inner" v-else-if="step === 'editor'">
@ -16,7 +16,7 @@
<p>Click <icon-side-preview></icon-side-preview> to toggle the side preview.</p>
<div class="tour-step__button-bar">
<button class="button" @click="finish">Skip</button>
<button class="button" @click="next">Next</button>
<button class="button button--resolve" @click="next">Next</button>
</div>
</div>
<div class="tour-step__inner" v-else-if="step === 'explorer'">
@ -25,7 +25,7 @@
<p>Click <icon-folder></icon-folder> to open the file explorer.</p>
<div class="tour-step__button-bar">
<button class="button" @click="finish">Skip</button>
<button class="button" @click="next">Next</button>
<button class="button button--resolve" @click="next">Next</button>
</div>
</div>
<div class="tour-step__inner" v-else-if="step === 'menu'">
@ -34,7 +34,7 @@
<p>Click <icon-provider provider-id="stackedit"></icon-provider> to explore the menu.</p>
<div class="tour-step__button-bar">
<button class="button" @click="finish">Skip</button>
<button class="button" @click="next">Next</button>
<button class="button button--resolve" @click="next">Next</button>
</div>
</div>
<div class="tour-step__inner" v-else-if="step === 'end'">
@ -42,7 +42,7 @@
<p>If you like StackEdit, please rate 5 stars on the <a target="_blank" href="https://chrome.google.com/webstore/detail/iiooodelglhkcpgbajoejffhijaclcdg/reviews">Chrome Web Store</a>.</p>
<p>You can also star the project on <a target="_blank" href="https://github.com/benweet/stackedit">GitHub</a> and join the <a target="_blank" href="https://community.stackedit.io/">community</a>.</p>
<div class="tour-step__button-bar">
<button class="button" @click="finish">Ok</button>
<button class="button button--resolve" @click="finish">Ok</button>
</div>
</div>
</div>
@ -139,12 +139,12 @@ export default {
}
$tour-step-background: mix(#f3f3f3, $selection-highlighting-color, 75%);
$tour-step-width: 220px;
$tour-step-width: 240px;
.tour-step__inner {
position: absolute;
background-color: $tour-step-background;
padding: 1.5em 1em 1em;
padding: 1em;
font-size: 0.9em;
line-height: 1.33;
width: $tour-step-width;
@ -213,6 +213,11 @@ $tour-step-width: 220px;
}
.tour-step__button-bar {
margin-top: 1.75em;
text-align: right;
.button {
font-size: 1.1em;
}
}
</style>

View File

@ -10,13 +10,11 @@ export default {
props: ['userId'],
computed: {
url() {
userSvc.getInfo(this.userId);
const userInfo = this.$store.state.userInfo.itemsById[this.userId];
return userInfo && userInfo.imageUrl && `url('${userInfo.imageUrl}')`;
},
},
created() {
userSvc.getInfo(this.userId);
},
};
</script>

View File

@ -9,12 +9,10 @@ export default {
props: ['userId'],
computed: {
name() {
userSvc.getInfo(this.userId);
const userInfo = this.$store.state.userInfo.itemsById[this.userId];
return userInfo ? userInfo.name : 'Someone';
},
},
created() {
userSvc.getInfo(this.userId);
},
};
</script>

View File

@ -1,26 +1,41 @@
<template>
<div class="history side-bar__panel">
<div class="side-bar__info" v-if="!syncToken">
<p>You have to <a href="javascript:void(0)" @click="signin">sign in with Google</a> to enable revision history.</p>
<p><b>Note:</b> This will sync your main workspace.</p>
</div>
<div class="side-bar__info" v-if="loading">
<p>Loading history</p>
</div>
<div class="side-bar__info" v-else-if="!revisionsWithSpacer.length">
<p><b>{{currentFileName}}</b> has no history.</p>
</div>
<div class="revision" v-for="revision in revisionsWithSpacer" :key="revision.id">
<div class="history__spacer" v-if="revision.spacer"></div>
<a class="revision__button button flex flex--row" href="javascript:void(0)" @click="open(revision)">
<div class="revision__icon">
<user-image :user-id="revision.sub"></user-image>
<div class="history side-bar__panel side-bar__panel--menu">
<div class="side-bar__info">
<p v-if="syncLocations.length > 1">
<select slot="field" class="textfield" v-model="syncLocationId" @keydown.enter="resolve()">
<option v-for="location in syncLocations" :key="location.id" :value="location.id">
{{ location.description }}
</option>
</select>
</p>
<p v-if="!historyContext">Synchronize <b>{{currentFileName}}</b> to enable revision history or <a href="javascript:void(0)" @click="signin">sign in with Google</a> to synchronize your main workspace.</p>
<p v-else-if="loading">Loading history</p>
<p v-else-if="!revisionsWithSpacer.length"><b>{{currentFileName}}</b> has no history.</p>
<div class="menu-entry menu-entry--info flex flex--row flex--align-center" v-else>
<div class="menu-entry__icon menu-entry__icon--image">
<icon-provider :provider-id="syncLocation.providerId"></icon-provider>
</div>
<div class="revision__header flex flex--column">
<user-name :user-id="revision.sub"></user-name>
<div class="revision__created">{{revision.created | formatTime}}</div>
</div>
</a>
<span v-if="syncLocation.url">
The following revisions are stored in <a :href="syncLocation.url" target="_blank">{{ syncLocationProviderName }}</a>.
</span>
<span v-else>
The following revisions are stored in {{ syncLocationProviderName }}.
</span>
</div>
</div>
<div>
<div class="revision" v-for="revision in revisionsWithSpacer" :key="revision.id">
<div class="history__spacer" v-if="revision.spacer"></div>
<a class="revision__button button flex flex--row" href="javascript:void(0)" @click="open(revision)">
<div class="revision__icon">
<user-image :user-id="revision.sub"></user-image>
</div>
<div class="revision__header flex flex--column">
<user-name :user-id="revision.sub"></user-name>
<div class="revision__created">{{revision.created | formatTime}}</div>
</div>
</a>
</div>
</div>
<div class="history__spacer history__spacer--last" v-if="revisions.length"></div>
<div class="flex flex--row flex--end" v-if="showMoreButton">
@ -30,7 +45,7 @@
</template>
<script>
import { mapMutations, mapGetters } from 'vuex';
import { mapState, mapMutations, mapGetters } from 'vuex';
import providerRegistry from '../../services/providers/common/providerRegistry';
import MenuEntry from './common/MenuEntry';
import UserImage from '../UserImage';
@ -44,7 +59,7 @@ import syncSvc from '../../services/syncSvc';
let editorClassAppliers = [];
let previewClassAppliers = [];
let cachedFileId;
let cachedHistoryContextHash;
let revisionsPromise;
let revisionContentPromises;
const pageSize = 30;
@ -60,16 +75,73 @@ export default {
allRevisions: [],
loading: false,
showCount: pageSize,
syncLocationId: null,
}),
computed: {
...mapGetters('workspace', [
'syncToken',
...mapGetters('data', [
'syncDataByItemId',
]),
...mapGetters('syncLocation', {
syncLocations: 'currentWithWorkspaceSyncLocation',
}),
...mapState('content', [
'revisionContent',
]),
syncLocation() {
return utils.someResult(this.syncLocations, (syncLocation) => {
if (syncLocation.id === this.syncLocationId) {
return syncLocation;
}
return null;
});
},
syncLocationProviderName() {
if (!this.syncLocation) {
return null;
}
return providerRegistry.providersById[this.syncLocation.providerId].name;
},
currentFileName() {
return this.$store.getters['file/current'].name;
},
historyContext() {
const { syncLocation } = this;
if (syncLocation) {
const provider = providerRegistry.providersById[syncLocation.providerId];
const token = provider.getToken(syncLocation);
const fileId = this.$store.getters['file/current'].id;
const contentId = `${fileId}/content`;
const historyContext = {
token,
fileId,
contentId,
syncLocation: this.syncLocation,
};
if (syncLocation.id !== 'main') {
return historyContext;
}
// Add syncData for workspace sync location
const { syncDataByItemId } = this;
const fileSyncData = syncDataByItemId[fileId];
const contentSyncData = syncDataByItemId[contentId];
if (fileSyncData && contentSyncData) {
return {
...historyContext,
fileSyncData,
contentSyncData,
};
}
}
return null;
},
historyContextHash() {
return utils.serializeObject(this.historyContext);
},
revisions() {
return this.allRevisions.slice(0, this.showCount);
return this.allRevisions.slice()
.sort((revision1, revision2) => revision2.created - revision1.created)
.slice(0, this.showCount);
},
revisionsWithSpacer() {
let previousCreated = 0;
@ -85,12 +157,6 @@ export default {
showMoreButton() {
return this.showCount < this.allRevisions.length;
},
refreshTrigger() {
return utils.serializeObject([
this.$store.getters['file/current'].id,
this.syncToken,
]);
},
},
methods: {
...mapMutations('content', [
@ -113,32 +179,31 @@ export default {
open(revision) {
let revisionContentPromise = revisionContentPromises[revision.id];
if (!revisionContentPromise) {
revisionContentPromise = new Promise((resolve, reject) => {
const { syncToken } = this;
const currentFile = this.$store.getters['file/current'];
this.$store.dispatch(
const historyContext = utils.deepCopy(this.historyContext);
if (historyContext) {
const provider = providerRegistry.providersById[this.syncLocation.providerId];
revisionContentPromise = new Promise((resolve, reject) => this.$store.dispatch(
'queue/enqueue',
async () => {
try {
const content = await this.workspaceProvider
.getRevisionContent(syncToken, currentFile.id, revision.id);
resolve(content);
} catch (e) {
reject(e);
}
},
);
});
revisionContentPromises[revision.id] = revisionContentPromise;
revisionContentPromise.catch(() => {
revisionContentPromises[revision.id] = null;
});
() => provider.getFileRevisionContent({
...historyContext,
revisionId: revision.id,
})
.then(resolve, reject),
));
revisionContentPromises[revision.id] = revisionContentPromise;
revisionContentPromise.catch((err) => {
this.$store.dispatch('notification/error', err);
revisionContentPromises[revision.id] = null;
});
}
}
if (revisionContentPromise) {
revisionContentPromise.then(revisionContent =>
this.$store.dispatch('content/setRevisionContent', revisionContent));
}
revisionContentPromise.then(revisionContent =>
this.$store.dispatch('content/setRevisionContent', revisionContent));
},
refreshHighlighters() {
const { revisionContent } = this.$store.state.content;
const { revisionContent } = this;
editorClassAppliers.forEach(editorClassApplier => editorClassApplier.stop());
editorClassAppliers = [];
previewClassAppliers.forEach(previewClassApplier => previewClassApplier.stop());
@ -166,40 +231,40 @@ export default {
}
},
},
created() {
// Find the workspace provider
const workspace = this.$store.getters['workspace/currentWorkspace'];
this.workspaceProvider = providerRegistry.providersById[workspace.providerId];
// Watch file changes
this.$watch(
() => this.refreshTrigger,
() => {
watch: {
// Fix syncLocationId
syncLocation: {
immediate: true,
handler(value) {
if (!value) {
const firstSyncLocation = this.syncLocations[0];
if (firstSyncLocation) {
this.syncLocationId = firstSyncLocation.id;
}
}
},
},
// Load revision list on context changes
historyContextHash: {
immediate: true,
handler() {
this.allRevisions = [];
const { id } = this.$store.getters['file/current'];
const { syncToken } = this;
if (id && syncToken) {
if (id !== cachedFileId) {
const historyContext = utils.deepCopy(this.historyContext);
if (historyContext) {
if (this.historyContextHash !== cachedHistoryContextHash) {
this.setRevisionContent();
cachedFileId = id;
cachedHistoryContextHash = this.historyContextHash;
revisionContentPromises = {};
const currentFile = this.$store.getters['file/current'];
revisionsPromise = new Promise((resolve, reject) => {
this.$store.dispatch(
'queue/enqueue',
async () => {
try {
const revisions = await this.workspaceProvider
.listRevisions(syncToken, currentFile.id);
resolve(revisions);
} catch (e) {
reject(e);
}
},
);
})
.catch(() => {
cachedFileId = null;
const provider = providerRegistry.providersById[this.syncLocation.providerId];
revisionsPromise = new Promise((resolve, reject) => this.$store.dispatch(
'queue/enqueue',
() => provider
.listFileRevisions(historyContext)
.then(resolve, reject),
))
.catch((err) => {
this.$store.dispatch('notification/error', err);
cachedHistoryContextHash = null;
return [];
});
}
@ -211,44 +276,40 @@ export default {
});
}
}
}, { immediate: true },
);
const loadOne = () => {
if (!this.destroyed) {
},
},
// Load each revision on revision list changes
revisions(revisions) {
const { historyContext } = this;
if (historyContext) {
this.$store.dispatch(
'queue/enqueue',
() => {
let loadPromise;
this.revisions.some((revision) => {
if (!revision.created) {
const { syncToken } = this;
const currentFile = this.$store.getters['file/current'];
loadPromise = this.workspaceProvider
.loadRevision(syncToken, currentFile.id, revision)
.then(() => loadOne());
}
return loadPromise;
});
return loadPromise;
},
() => utils.awaitSequence(revisions, async (revision) => {
// Make sure revisions and historyContext haven't changed
if (!this.destroyed
&& this.revisions === revisions
&& this.historyContext === historyContext
) {
const provider = providerRegistry.providersById[this.syncLocation.providerId];
await provider.loadFileRevision({
...historyContext,
revision,
});
}
}),
);
}
};
this.$watch(
() => this.revisions,
() => loadOne(),
{ immediate: true },
);
// Watch diffs changes
this.$watch(
() => this.$store.state.content.revisionContent,
() => this.refreshHighlighters(),
);
// Close revision
},
// Refresh highlighters on open/close revision
revisionContent: {
immediate: true,
handler() {
this.refreshHighlighters();
},
},
},
created() {
// Close revision on escape
this.onKeyup = (evt) => {
if (evt.which === 27) {
// Esc key
@ -273,10 +334,6 @@ export default {
<style lang="scss">
@import '../../styles/variables.scss';
.history {
padding: 5px 5px 50px;
}
.history__button {
font-size: 14px;
margin-top: 0.5em;
@ -291,7 +348,7 @@ export default {
position: absolute;
height: 100%;
top: 0;
left: 24px;
left: 19px;
border-left: 2px dotted $hr-color;
}
}
@ -302,7 +359,7 @@ export default {
.revision__button {
text-align: left;
padding: 15px;
padding: 10px;
height: auto;
text-transform: none;
position: relative;
@ -312,7 +369,7 @@ export default {
position: absolute;
height: 100%;
top: 0;
left: 24px;
left: 19px;
border-left: 2px solid $hr-color;
}
@ -343,20 +400,21 @@ export default {
.revision__header {
font-size: 15px;
width: 100%;
line-height: 1.33;
}
.revision__created {
font-size: 0.75em;
opacity: 0.5;
opacity: 0.6;
}
.layout--revision {
.cledit-section *,
.cl-preview-section * {
color: transparentize($editor-color-light, 0.67) !important;
color: transparentize($editor-color-light, 0.5) !important;
.app--dark & {
color: transparentize($editor-color-dark, 0.67) !important;
color: transparentize($editor-color-dark, 0.5) !important;
}
}

View File

@ -15,10 +15,13 @@
<b>{{currentWorkspace.name}}</b> synced with your Google Drive app data folder.
</span>
<span v-else-if="currentWorkspace.providerId === 'googleDriveWorkspace'">
<b>{{currentWorkspace.name}}</b> synced with a <a :href="currentWorkspaceUrl" target="_blank">Google Drive folder</a>.
<b>{{currentWorkspace.name}}</b> synced with a <a :href="workspaceLocationUrl" target="_blank">Google Drive folder</a>.
</span>
<span v-else-if="currentWorkspace.providerId === 'couchdbWorkspace'">
<b>{{currentWorkspace.name}}</b> synced with a <a :href="currentWorkspaceUrl" target="_blank">CouchDB database</a>.
<b>{{currentWorkspace.name}}</b> synced with a <a :href="workspaceLocationUrl" target="_blank">CouchDB database</a>.
</span>
<span v-else-if="currentWorkspace.providerId === 'githubWorkspace'">
<b>{{currentWorkspace.name}}</b> synced with a <a :href="workspaceLocationUrl" target="_blank">GitHub repo</a>.
</span>
</div>
<div class="menu-entry menu-entry--info flex flex--row flex--align-center" v-else>
@ -108,7 +111,7 @@ export default {
'loginToken',
'userId',
]),
currentWorkspaceUrl() {
workspaceLocationUrl() {
const provider = providerRegistry.providersById[this.currentWorkspace.providerId];
return provider.getWorkspaceLocationUrl(this.currentWorkspace);
},

View File

@ -7,7 +7,7 @@
</menu-entry>
<menu-entry @click.native="templates">
<icon-code-braces slot="icon"></icon-code-braces>
<div>Templates</div>
<div><div class="menu-entry__label menu-entry__label--count">{{templateCount}}</div> Templates</div>
<span>Configure Handlebars templates for your exports.</span>
</menu-entry>
<menu-entry @click.native="reset">
@ -50,6 +50,11 @@ export default {
components: {
MenuEntry,
},
computed: {
templateCount() {
return Object.keys(this.$store.getters['data/allTemplatesById']).length;
},
},
methods: {
onImportBackup(evt) {
const file = evt.target.files[0];

View File

@ -65,6 +65,7 @@ export default {
small {
display: block;
font-size: 0.75em;
opacity: 0.75;
}
hr {

View File

@ -228,8 +228,8 @@ export default {
<style lang="scss">
@import '../../styles/variables.scss';
.modal__inner-1--file-properties {
max-width: 540px;
.modal__inner-1.modal__inner-1--file-properties {
max-width: 520px;
}
.modal__error--file-properties {

View File

@ -64,10 +64,6 @@ export default {
<style lang="scss">
@import '../../styles/variables.scss';
.modal__inner-1--publish-management {
max-width: 560px;
}
.publish-entry {
padding: 0.5rem 0.25rem;
border-bottom: 1px solid $hr-color;

View File

@ -36,7 +36,7 @@ import { mapGetters } from 'vuex';
import ModalInner from './common/ModalInner';
import Tab from './common/Tab';
import CodeEditor from '../CodeEditor';
import defaultSettings from '../../data/defaultSettings.yml';
import defaultSettings from '../../data/defaults/defaultSettings.yml';
const emptySettings = `# Add your custom settings here to override the
# default settings.
@ -83,8 +83,8 @@ export default {
<style lang="scss">
@import '../../styles/variables.scss';
.modal__inner-1--settings {
max-width: 600px;
.modal__inner-1.modal__inner-1--settings {
max-width: 560px;
}
.modal__error--settings {

View File

@ -1,7 +1,7 @@
<template>
<modal-inner class="modal__inner-1--sponsor" aria-label="Sponsor">
<div class="modal__content">
<p>Please choose a <b>PayPal</b> option.</p>
<p>Please choose a <b>PayPal</b> option:</p>
<a class="paypal-option button flex flex--row flex--center" v-for="button in buttons" :key="button.id" :href="button.link">
<div class="flex flex--column">
<div>{{button.price}}<div class="paypal-option__offer" v-if="button.offer">{{button.offer}}</div></div>
@ -65,8 +65,8 @@ export default {
<style lang="scss">
@import '../../styles/variables.scss';
.modal__inner-1--sponsor {
max-width: 380px;
.modal__inner-1.modal__inner-1--sponsor {
max-width: 400px;
}
.paypal-option {
@ -81,7 +81,7 @@ export default {
span {
display: inline-block;
font-size: 0.75rem;
opacity: 0.5;
opacity: 0.6;
white-space: normal;
line-height: 1.5;
}

View File

@ -23,7 +23,7 @@
</div>
<div class="sync-entry__row flex flex--row flex--align-center">
<div class="sync-entry__url">
{{location.url || 'Workspace location'}}
{{location.url || 'Google Drive app data'}}
</div>
<div class="sync-entry__buttons flex flex--row flex--center" v-if="location.url">
<button class="sync-entry__button button" v-clipboard="location.url" @click="info('Location URL copied to clipboard!')" v-title="'Copy URL'">
@ -83,10 +83,6 @@ export default {
<style lang="scss">
@import '../../styles/variables.scss';
.modal__inner-1--sync-management {
max-width: 560px;
}
.sync-entry {
margin: 1.5em 0;
height: auto;

View File

@ -55,8 +55,8 @@ import { mapGetters } from 'vuex';
import utils from '../../services/utils';
import ModalInner from './common/ModalInner';
import CodeEditor from '../CodeEditor';
import emptyTemplateValue from '../../data/emptyTemplateValue.html';
import emptyTemplateHelpers from '!raw-loader!../../data/emptyTemplateHelpers.js'; // eslint-disable-line
import emptyTemplateValue from '../../data/empties/emptyTemplateValue.html';
import emptyTemplateHelpers from '!raw-loader!../../data/empties/emptyTemplateHelpers.js'; // eslint-disable-line
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
@ -163,7 +163,7 @@ export default {
</script>
<style lang="scss">
.modal__inner-1--templates {
max-width: 680px;
.modal__inner-1.modal__inner-1--templates {
max-width: 600px;
}
</style>

View File

@ -126,10 +126,6 @@ export default {
<style lang="scss">
@import '../../styles/variables.scss';
.modal__inner-1--workspace-management {
max-width: 560px;
}
.workspace-entry {
margin: 1.75em 0;
height: auto;

View File

@ -54,8 +54,8 @@ export default {
top: 7px;
right: 7px;
color: rgba(0, 0, 0, 0.5);
width: 28px;
height: 28px;
width: 30px;
height: 30px;
padding: 2px;
&:active,

View File

@ -11,18 +11,18 @@
<b>Example:</b> https://github.com/benweet/stackedit
</div>
</form-entry>
<form-entry label="Branch" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="branch" @keydown.enter="resolve()">
<div class="form-entry__info">
If not supplied, the <code>master</code> branch will be used.
</div>
</form-entry>
<form-entry label="File path" error="path">
<input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()">
<div class="form-entry__info">
<b>Example:</b> path/to/README.md
</div>
</form-entry>
<form-entry label="Branch" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="branch" @keydown.enter="resolve()">
<div class="form-entry__info">
If not supplied, the <code>master</code> branch will be used.
</div>
</form-entry>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>

View File

@ -11,12 +11,6 @@
<b>Example:</b> https://github.com/benweet/stackedit
</div>
</form-entry>
<form-entry label="Branch" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="branch" @keydown.enter="resolve()">
<div class="form-entry__info">
If not supplied, the <code>master</code> branch will be used.
</div>
</form-entry>
<form-entry label="File path" error="path">
<input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()">
<div class="form-entry__info">
@ -24,6 +18,12 @@
If the file exists, it will be overwritten.
</div>
</form-entry>
<form-entry label="Branch" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="branch" @keydown.enter="resolve()">
<div class="form-entry__info">
If not supplied, the <code>master</code> branch will be used.
</div>
</form-entry>
<form-entry label="Template">
<select slot="field" class="textfield" v-model="selectedTemplate" @keydown.enter="resolve()">
<option v-for="(template, id) in allTemplatesById" :key="id" :value="id">

View File

@ -27,12 +27,12 @@
</template>
<script>
import utils from '../../../services/utils';
import modalTemplate from '../common/modalTemplate';
import constants from '../../../data/constants';
export default modalTemplate({
data: () => ({
redirectUrl: utils.oauth2RedirectUri,
redirectUrl: constants.oauth2RedirectUri,
}),
computedLocalSettings: {
siteUrl: 'zendeskSiteUrl',

30
src/data/constants.js Normal file
View File

@ -0,0 +1,30 @@
const origin = `${window.location.protocol}//${window.location.host}`;
export default {
cleanTrashAfter: 0 * 24 * 60 * 60 * 1000, // 7 days
origin,
oauth2RedirectUri: `${origin}/oauth2/callback`,
types: [
'contentState',
'syncedContent',
'content',
'file',
'folder',
'syncLocation',
'publishLocation',
'data',
],
localStorageDataIds: [
'workspaces',
'settings',
'layoutSettings',
'tokens',
],
userIdPrefixes: {
db: 'dropbox',
gh: 'github',
go: 'google',
},
textMaxLength: 250000,
defaultName: 'Untitled',
};

View File

@ -5,7 +5,7 @@ fontSizeFactor: 1
# Adjust maximum text width in editor and preview
maxWidthFactor: 1
# Auto-sync frequency (in ms). Minimum is 60000.
autoSyncEvery: 60000
autoSyncEvery: 90000
# Editor settings
editor:
@ -77,10 +77,11 @@ turndown:
linkStyle: inlined
linkReferenceStyle: full
# GitHub commit messages
github:
createFileMessage: Create {{path}} from https://stackedit.io/
updateFileMessage: Update {{path}} from https://stackedit.io/
deleteFileMessage: Delete {{path}} from https://stackedit.io/
createFileMessage: '{{path}} created from https://stackedit.io/'
updateFileMessage: '{{path}} updated from https://stackedit.io/'
deleteFileMessage: '{{path}} deleted from https://stackedit.io/'
# Default content for new files
newFileContent: |

View File

@ -2,7 +2,7 @@
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.
We recommend syncing your workspace to make sure files won't be lost in case your browser data is cleared.
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.
**Can StackEdit access my data without telling me?**

View File

@ -54,7 +54,7 @@ export default {
const deleteFile = (id) => {
if (moveToTrash) {
store.commit('file/patchItem', {
workspaceSvc.setOrPatchItem({
id,
parentId: 'trash',
});

View File

@ -3,6 +3,7 @@ import utils from './utils';
import store from '../store';
import welcomeFile from '../data/welcomeFile.md';
import workspaceSvc from './workspaceSvc';
import constants from '../data/constants';
const dbVersion = 1;
const dbStoreName = 'objects';
@ -82,7 +83,7 @@ const contentTypes = {
};
const hashMap = {};
utils.types.forEach((type) => {
constants.types.forEach((type) => {
hashMap[type] = Object.create(null);
});
const lsHashMap = Object.create(null);
@ -96,7 +97,7 @@ const localDbSvc = {
* Sync data items stored in the localStorage.
*/
syncLocalStorage() {
utils.localStorageDataIds.forEach((id) => {
constants.localStorageDataIds.forEach((id) => {
const key = `data/${id}`;
// Skip reloading the layoutSettings
@ -327,7 +328,7 @@ const localDbSvc = {
if (resetApp) {
await Promise.all(Object.keys(store.getters['workspace/workspacesById'])
.map(workspaceId => workspaceSvc.removeWorkspace(workspaceId)));
utils.localStorageDataIds.forEach((id) => {
constants.localStorageDataIds.forEach((id) => {
// Clean data stored in localStorage
localStorage.removeItem(`data/${id}`);
});
@ -372,7 +373,7 @@ const localDbSvc = {
// If app was last opened 7 days ago and synchronization is off
if (!store.getters['workspace/syncToken'] &&
(store.state.workspace.lastFocus + utils.cleanTrashAfter < Date.now())
(store.state.workspace.lastFocus + constants.cleanTrashAfter < Date.now())
) {
// Clean files
store.getters['file/items']

View File

@ -1,11 +1,13 @@
import utils from './utils';
import store from '../store';
import constants from '../data/constants';
const scriptLoadingPromises = Object.create(null);
const oauth2AuthorizationTimeout = 120 * 1000; // 2 minutes
const authorizeTimeout = 6 * 60 * 1000; // 2 minutes
const silentAuthorizeTimeout = 15 * 1000; // 15 secondes (which will be reattempted)
const networkTimeout = 30 * 1000; // 30 sec
let isConnectionDown = false;
const userInactiveAfter = 2 * 60 * 1000; // 2 minutes
const userInactiveAfter = 3 * 60 * 1000; // 3 minutes (twice the default sync period)
function parseHeaders(xhr) {
@ -54,9 +56,9 @@ export default {
// Check browser is online periodically
const checkOffline = async () => {
const isBrowserOffline = window.navigator.onLine === false;
if (!isBrowserOffline &&
store.state.lastOfflineCheck + networkTimeout + 5000 < Date.now() &&
this.isUserActive()
if (!isBrowserOffline
&& store.state.lastOfflineCheck + networkTimeout + 5000 < Date.now()
&& this.isUserActive()
) {
store.commit('updateLastOfflineCheck');
const script = document.createElement('script');
@ -121,85 +123,98 @@ export default {
}
return scriptLoadingPromises[url];
},
async startOauth2(url, params = {}, silent = false) {
// Build the authorize URL
const state = utils.uid();
params.state = state;
params.redirect_uri = utils.oauth2RedirectUri;
const authorizeUrl = utils.addQueryParams(url, params);
let iframeElt;
let wnd;
if (silent) {
// Use an iframe as wnd for silent mode
iframeElt = utils.createHiddenIframe(authorizeUrl);
document.body.appendChild(iframeElt);
wnd = iframeElt.contentWindow;
} else {
// Open a tab otherwise
wnd = window.open(authorizeUrl);
if (!wnd) {
return Promise.reject(new Error('The authorize window was blocked.'));
}
}
let checkClosedInterval;
let closeTimeout;
let msgHandler;
async startOauth2(url, params = {}, silent = false, reattempt = false) {
try {
return await new Promise((resolve, reject) => {
if (silent) {
iframeElt.onerror = () => {
reject(new Error('Unknown error.'));
};
closeTimeout = setTimeout(() => {
isConnectionDown = true;
store.commit('setOffline', true);
store.commit('updateLastOfflineCheck');
reject(new Error('You are offline.'));
}, networkTimeout);
} else {
closeTimeout = setTimeout(() => {
reject(new Error('Timeout.'));
}, oauth2AuthorizationTimeout);
}
msgHandler = (event) => {
if (event.source === wnd && event.origin === utils.origin) {
const data = utils.parseQueryParams(`${event.data}`.slice(1));
if (data.error || data.state !== state) {
console.error(data); // eslint-disable-line no-console
reject(new Error('Could not get required authorization.'));
} else {
resolve({
accessToken: data.access_token,
code: data.code,
idToken: data.id_token,
expiresIn: data.expires_in,
});
}
}
};
window.addEventListener('message', msgHandler);
if (!silent) {
checkClosedInterval = setInterval(() => {
if (wnd.closed) {
reject(new Error('Authorize window was closed.'));
}
}, 250);
}
// Build the authorize URL
const state = utils.uid();
const authorizeUrl = utils.addQueryParams(url, {
...params,
state,
redirect_uri: constants.oauth2RedirectUri,
});
} finally {
clearInterval(checkClosedInterval);
if (!silent && !wnd.closed) {
wnd.close();
let iframeElt;
let wnd;
if (silent) {
// Use an iframe as wnd for silent mode
iframeElt = utils.createHiddenIframe(authorizeUrl);
document.body.appendChild(iframeElt);
wnd = iframeElt.contentWindow;
} else {
// Open a tab otherwise
wnd = window.open(authorizeUrl);
if (!wnd) {
throw new Error('The authorize window was blocked.');
}
}
if (iframeElt) {
document.body.removeChild(iframeElt);
let checkClosedInterval;
let closeTimeout;
let msgHandler;
try {
return await new Promise((resolve, reject) => {
if (silent) {
iframeElt.onerror = () => {
reject(new Error('Unknown error.'));
};
closeTimeout = setTimeout(() => {
if (!reattempt) {
reject(new Error('REATTEMPT'));
} else {
isConnectionDown = true;
store.commit('setOffline', true);
store.commit('updateLastOfflineCheck');
reject(new Error('You are offline.'));
}
}, silentAuthorizeTimeout);
} else {
closeTimeout = setTimeout(() => {
reject(new Error('Timeout.'));
}, authorizeTimeout);
}
msgHandler = (event) => {
if (event.source === wnd && event.origin === constants.origin) {
const data = utils.parseQueryParams(`${event.data}`.slice(1));
if (data.error || data.state !== state) {
console.error(data); // eslint-disable-line no-console
reject(new Error('Could not get required authorization.'));
} else {
resolve({
accessToken: data.access_token,
code: data.code,
idToken: data.id_token,
expiresIn: data.expires_in,
});
}
}
};
window.addEventListener('message', msgHandler);
if (!silent) {
checkClosedInterval = setInterval(() => {
if (wnd.closed) {
reject(new Error('Authorize window was closed.'));
}
}, 250);
}
});
} finally {
clearInterval(checkClosedInterval);
if (!silent && !wnd.closed) {
wnd.close();
}
if (iframeElt) {
document.body.removeChild(iframeElt);
}
clearTimeout(closeTimeout);
window.removeEventListener('message', msgHandler);
}
clearTimeout(closeTimeout);
window.removeEventListener('message', msgHandler);
} catch (e) {
if (e.message === 'REATTEMPT') {
return this.startOauth2(url, params, silent, true);
}
throw e;
}
},
async request(configParam, offlineCheck = false) {

View File

@ -4,6 +4,7 @@ import Provider from './common/Provider';
export default new Provider({
id: 'bloggerPage',
name: 'Blogger Page',
getToken({ sub }) {
const token = store.getters['data/googleTokensBySub'][sub];
return token && token.isBlogger ? token : null;

View File

@ -4,6 +4,7 @@ import Provider from './common/Provider';
export default new Provider({
id: 'blogger',
name: 'Blogger',
getToken({ sub }) {
const token = store.getters['data/googleTokensBySub'][sub];
return token && token.isBlogger ? token : null;

View File

@ -1,10 +1,10 @@
import providerRegistry from './providerRegistry';
import emptyContent from '../../../data/emptyContent';
import emptyContent from '../../../data/empties/emptyContent';
import utils from '../../utils';
import store from '../../../store';
import workspaceSvc from '../../workspaceSvc';
const dataExtractor = /<!--stackedit_data:([A-Za-z0-9+/=\s]+)-->$/;
const dataExtractor = /<!--stackedit_data:([A-Za-z0-9+/=\s]+)-->\s*$/;
export default class Provider {
prepareChanges = changes => changes
@ -70,14 +70,6 @@ export default class Provider {
return utils.addItemHash(result);
}
static getContentSyncData(fileId) {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
if (!syncData) {
throw new Error(); // No need for a proper error message.
}
return syncData;
}
/**
* Find and open a file with location that meets the criteria
*/

View File

@ -7,6 +7,7 @@ let syncLastSeq;
export default new Provider({
id: 'couchdbWorkspace',
name: 'CouchDB',
getToken() {
return store.getters['workspace/syncToken'];
},
@ -91,19 +92,22 @@ export default new Provider({
},
async saveWorkspaceItem({ item, syncData }) {
const syncToken = store.getters['workspace/syncToken'];
const { id, rev } = couchdbHelper.uploadDocument({
const { id, rev } = await couchdbHelper.uploadDocument({
token: syncToken,
item,
documentId: syncData && syncData.id,
rev: syncData && syncData.rev,
});
// Build sync data to save
return {
// Build sync data
id,
itemId: item.id,
type: item.type,
hash: item.hash,
rev,
syncData: {
id,
itemId: item.id,
type: item.type,
hash: item.hash,
rev,
},
};
},
removeWorkspaceItem({ syncData }) {
@ -190,31 +194,34 @@ export default new Provider({
},
};
},
async listRevisions(token, fileId) {
const syncData = Provider.getContentSyncData(fileId);
const body = await couchdbHelper.retrieveDocumentWithRevisions(token, syncData.id);
async listFileRevisions({ token, contentSyncData }) {
const body = await couchdbHelper.retrieveDocumentWithRevisions(token, contentSyncData.id);
const revisions = [];
body._revs_info.forEach((revInfo) => { // eslint-disable-line no-underscore-dangle
body._revs_info.forEach((revInfo, idx) => { // eslint-disable-line no-underscore-dangle
if (revInfo.status === 'available') {
revisions.push({
id: revInfo.rev,
sub: null,
created: null,
created: idx,
loaded: false,
});
}
});
return revisions;
},
async loadRevision(token, fileId, revision) {
const syncData = Provider.getContentSyncData(fileId);
const body = await couchdbHelper.retrieveDocument(token, syncData.id, revision.id);
async loadFileRevision({ token, contentSyncData, revision }) {
if (revision.loaded) {
return false;
}
const body = await couchdbHelper.retrieveDocument(token, contentSyncData.id, revision.id);
revision.sub = body.sub;
revision.created = body.time || 1; // Has to be truthy to prevent from loading several times
revision.created = body.time;
revision.loaded = true;
return true;
},
async getRevisionContent(token, fileId, revisionId) {
const syncData = Provider.getContentSyncData(fileId);
async getFileRevisionContent({ token, contentSyncData, revisionId }) {
const body = await couchdbHelper
.retrieveDocumentWithAttachments(token, syncData.id, revisionId);
.retrieveDocumentWithAttachments(token, contentSyncData.id, revisionId);
return Provider.parseContent(body.attachments.data, body.item.id);
},
});

View File

@ -19,6 +19,7 @@ const makePathRelative = (token, path) => {
export default new Provider({
id: 'dropbox',
name: 'Dropbox',
getToken({ sub }) {
return store.getters['data/dropboxTokensBySub'][sub];
},
@ -27,8 +28,8 @@ export default new Provider({
const filename = pathComponents.pop();
return `https://www.dropbox.com/home${pathComponents.join('/')}?preview=${filename}`;
},
getLocationDescription({ path }) {
return path;
getLocationDescription({ path, dropboxFileId }) {
return dropboxFileId || path;
},
checkPath(path) {
return path && path.match(/^\/[^\\<>:"|?*]+$/);
@ -122,4 +123,27 @@ export default new Provider({
path,
};
},
async listFileRevisions({ token, syncLocation }) {
const entries = await dropboxHelper.listRevisions(token, syncLocation.dropboxFileId);
return entries.map(entry => ({
id: entry.rev,
sub: `db:${(entry.sharing_info || {}).modified_by || token.sub}`,
created: new Date(entry.server_modified).getTime(),
}));
},
async loadFileRevision() {
// Revision are already loaded
return false;
},
async getFileRevisionContent({
token,
contentId,
revisionId,
}) {
const { content } = await dropboxHelper.downloadFile({
token,
path: `rev:${revisionId}`,
});
return Provider.parseContent(content, contentId);
},
});

View File

@ -2,9 +2,11 @@ import store from '../../store';
import githubHelper from './helpers/githubHelper';
import Provider from './common/Provider';
import utils from '../utils';
import userSvc from '../userSvc';
export default new Provider({
id: 'gist',
name: 'Gist',
getToken({ sub }) {
return store.getters['data/githubTokensBySub'][sub];
},
@ -56,4 +58,37 @@ export default new Provider({
gistId,
};
},
async listFileRevisions({ token, syncLocation }) {
const entries = await githubHelper.getGistCommits({
...syncLocation,
token,
});
return entries.map((entry) => {
const sub = `gh:${entry.user.id}`;
userSvc.addInfo({ id: sub, name: entry.user.login, imageUrl: entry.user.avatar_url });
return {
sub,
id: entry.version,
created: new Date(entry.committed_at).getTime(),
};
});
},
async loadFileRevision() {
// Revision are already loaded
return false;
},
async getFileRevisionContent({
token,
contentId,
syncLocation,
revisionId,
}) {
const data = await githubHelper.downloadGistRevision({
...syncLocation,
token,
sha: revisionId,
});
return Provider.parseContent(data, contentId);
},
});

View File

@ -3,11 +3,13 @@ import githubHelper from './helpers/githubHelper';
import Provider from './common/Provider';
import utils from '../utils';
import workspaceSvc from '../workspaceSvc';
import userSvc from '../userSvc';
const savedSha = {};
export default new Provider({
id: 'github',
name: 'GitHub',
getToken({ sub }) {
return store.getters['data/githubTokensBySub'][sub];
},
@ -23,21 +25,21 @@ export default new Provider({
return path;
},
async downloadContent(token, syncLocation) {
try {
const { sha, data } = await githubHelper.downloadFile({
...syncLocation,
token,
});
savedSha[syncLocation.id] = sha;
return Provider.parseContent(data, `${syncLocation.fileId}/content`);
} catch (e) {
// Ignore error, upload is going to fail anyway
return null;
}
const { sha, data } = await githubHelper.downloadFile({
...syncLocation,
token,
});
savedSha[syncLocation.id] = sha;
return Provider.parseContent(data, `${syncLocation.fileId}/content`);
},
async uploadContent(token, content, syncLocation) {
if (!savedSha[syncLocation.id]) {
await this.downloadContent(token, syncLocation); // Get the last sha
try {
// Get the last sha
await this.downloadContent(token, syncLocation);
} catch (e) {
// Ignore error
}
}
const sha = savedSha[syncLocation.id];
delete savedSha[syncLocation.id];
@ -50,7 +52,12 @@ export default new Provider({
return syncLocation;
},
async publish(token, html, metadata, publishLocation) {
await this.downloadContent(token, publishLocation); // Get the last sha
try {
// Get the last sha
await this.downloadContent(token, publishLocation);
} catch (e) {
// Ignore error
}
const sha = savedSha[publishLocation.id];
delete savedSha[publishLocation.id];
await githubHelper.uploadFile({
@ -109,4 +116,50 @@ export default new Provider({
path,
};
},
async listFileRevisions({ token, syncLocation }) {
const entries = await githubHelper.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 = `gh:${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 githubHelper.downloadFile({
...syncLocation,
token,
branch: revisionId,
});
return Provider.parseContent(data, contentId);
},
});

View File

@ -18,6 +18,7 @@ const endsWith = (str, suffix) => str.slice(-suffix.length) === suffix;
export default new Provider({
id: 'githubWorkspace',
name: 'GitHub',
getToken() {
return store.getters['workspace/syncToken'];
},
@ -136,76 +137,90 @@ export default new Provider({
// Collect changes
const changes = [];
const pathIds = {};
const syncDataToKeep = Object.create(null);
const idsByPath = {};
const syncDataByPath = store.getters['data/syncDataById'];
const { itemsByGitPath } = store.getters;
const getId = (path) => {
const existingItem = itemsByGitPath[path];
// Use the item ID only if the item was already synced
if (existingItem && syncDataByPath[path]) {
pathIds[path] = existingItem.id;
return existingItem.id;
const { itemIdsByGitPath } = store.getters;
const getIdFromPath = (path, isFile) => {
let itemId = idsByPath[path];
if (!itemId) {
const existingItemId = itemIdsByGitPath[path];
// We can replace the item only if it was already synced
if (existingItemId
&& (syncDataByPath[path]
// Content may have already be synced
|| (isFile && 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;
}
// Generate a new ID
let id = utils.uid();
if (path[0] === '/') {
id += '/content';
}
pathIds[path] = id;
return id;
return itemId;
};
// Folder creations/updates
// Assume map entries are sorted from top to bottom
Object.entries(treeFolderMap).forEach(([path, parentPath]) => {
const item = utils.addItemHash({
id: getId(path),
type: 'folder',
name: path.slice(parentPath.length, -1),
parentId: pathIds[parentPath] || null,
});
changes.push({
syncDataId: path,
item,
syncData: {
id: path,
type: item.type,
hash: item.hash,
},
});
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]) => {
// Look for content sync data as it's created before file sync data
const fileId = getIdFromPath(path, true);
const contentPath = `/${path}`;
const contentId = getId(contentPath);
const contentId = idsByPath[contentPath];
// File creations/updates
const [fileId] = contentId.split('/');
const item = utils.addItemHash({
id: fileId,
type: 'file',
name: path.slice(parentPath.length, -'.md'.length),
parentId: pathIds[parentPath] || null,
});
changes.push({
syncDataId: path,
item,
syncData: {
id: path,
type: item.type,
hash: item.hash,
},
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) {
syncDataToKeep[path] = true;
syncDataToKeep[contentPath] = true;
}
if (!contentSyncData || contentSyncData.sha !== treeShaMap[path]) {
const type = 'content';
// Use `/` as a prefix to get a unique syncData id
@ -233,11 +248,8 @@ export default new Provider({
// Only template data are stored
const [, id] = path.match(/^\.stackedit-data\/(templates)\.json$/) || [];
if (id) {
pathIds[path] = id;
idsByPath[path] = id;
const syncData = syncDataByItemId[id];
if (syncData) {
syncDataToKeep[syncData.id] = true;
}
if (!syncData || syncData.sha !== treeShaMap[path]) {
const type = 'data';
changes.push({
@ -273,14 +285,11 @@ export default new Provider({
const [, filePath, data] = path.match(pathMatcher) || [];
if (filePath) {
// If there is a corresponding md file in the tree
const fileId = pathIds[`${filePath}.md`];
const fileId = idsByPath[`${filePath}.md`];
if (fileId) {
// Reuse existing ID or create a new one
const existingItem = itemsByGitPath[path];
const id = existingItem
? existingItem.id
: utils.uid();
pathIds[path] = id;
const id = itemIdsByGitPath[path] || utils.uid();
idsByPath[path] = id;
const item = utils.addItemHash({
...JSON.parse(utils.decodeBase64(data)),
@ -288,22 +297,26 @@ export default new Provider({
type,
fileId,
});
changes.push({
syncDataId: path,
item,
syncData: {
id: path,
type: item.type,
hash: item.hash,
},
});
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 (!pathIds[path] && !syncDataToKeep[path]) {
if (!idsByPath[path]) {
changes.push({ syncDataId: path });
}
});
@ -331,7 +344,11 @@ export default new Provider({
content: '',
sha: treeShaMap[syncData.id],
});
return syncData;
// Return sync data to save
return {
syncData,
};
},
async removeWorkspaceItem({ syncData }) {
if (treeShaMap[syncData.id]) {
@ -435,16 +452,16 @@ export default new Provider({
},
};
},
async listRevisions(token, fileId) {
async listFileRevisions({ token, fileSyncData }) {
const { owner, repo, branch } = store.getters['workspace/currentWorkspace'];
const syncData = Provider.getContentSyncData(fileId);
const entries = await githubHelper.getCommits({
token,
owner,
repo,
sha: branch,
path: syncData.id,
path: getAbsolutePath(fileSyncData),
});
return entries.map(({
author,
committer,
@ -466,17 +483,24 @@ export default new Provider({
sub,
created: date ? new Date(date).getTime() : 1,
};
})
.sort((revision1, revision2) => revision2.created - revision1.created);
});
},
async getRevisionContent(token, fileId, revisionId) {
const syncData = Provider.getContentSyncData(fileId);
async loadFileRevision() {
// Revisions are already loaded
return false;
},
async getFileRevisionContent({
token,
contentId,
fileSyncData,
revisionId,
}) {
const { data } = await githubHelper.downloadFile({
...store.getters['workspace/currentWorkspace'],
token,
branch: revisionId,
path: getAbsolutePath(syncData),
path: getAbsolutePath(fileSyncData),
});
return Provider.parseContent(data, `${fileId}/content`);
return Provider.parseContent(data, contentId);
},
});

View File

@ -7,6 +7,7 @@ let syncStartPageToken;
export default new Provider({
id: 'googleDriveAppData',
name: 'Google Drive app data',
getToken() {
return store.getters['workspace/syncToken'];
},
@ -69,12 +70,15 @@ export default new Provider({
fileId: syncData && syncData.id,
ifNotTooLate,
});
// Build sync data
// Build sync data to save
return {
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
syncData: {
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
},
};
},
removeWorkspaceItem({ syncData, ifNotTooLate }) {
@ -163,19 +167,21 @@ export default new Provider({
},
};
},
async listRevisions(token, fileId) {
const syncData = Provider.getContentSyncData(fileId);
const revisions = await googleHelper.getAppDataFileRevisions(token, syncData.id);
async listFileRevisions({ token, contentSyncData }) {
const revisions = await googleHelper.getAppDataFileRevisions(token, contentSyncData.id);
return revisions.map(revision => ({
id: revision.id,
sub: `go:${revision.lastModifyingUser.permissionId}`,
created: new Date(revision.modifiedTime).getTime(),
}))
.sort((revision1, revision2) => revision2.created - revision1.created);
}));
},
async getRevisionContent(token, fileId, revisionId) {
const syncData = Provider.getContentSyncData(fileId);
const content = await googleHelper.downloadAppDataFileRevision(token, syncData.id, revisionId);
async loadFileRevision() {
// Revisions are already loaded
return false;
},
async getFileRevisionContent({ token, contentSyncData, revisionId }) {
const content = await googleHelper
.downloadAppDataFileRevision(token, contentSyncData.id, revisionId);
return JSON.parse(content);
},
});

View File

@ -6,6 +6,7 @@ import workspaceSvc from '../workspaceSvc';
export default new Provider({
id: 'googleDrive',
name: 'Google Drive',
getToken({ sub }) {
const token = store.getters['data/googleTokensBySub'][sub];
return token && token.isDrive ? token : null;
@ -190,4 +191,26 @@ export default new Provider({
}
return location;
},
async listFileRevisions({ token, syncLocation }) {
const revisions = await googleHelper.getFileRevisions(token, syncLocation.driveFileId);
return revisions.map(revision => ({
id: revision.id,
sub: `go:${revision.lastModifyingUser.permissionId}`,
created: new Date(revision.modifiedTime).getTime(),
}));
},
async loadFileRevision() {
// Revision are already loaded
return false;
},
async getFileRevisionContent({
token,
contentId,
syncLocation,
revisionId,
}) {
const content = await googleHelper
.downloadFileRevision(token, syncLocation.driveFileId, revisionId);
return Provider.parseContent(content, contentId);
},
});

View File

@ -9,6 +9,7 @@ let syncStartPageToken;
export default new Provider({
id: 'googleDriveWorkspace',
name: 'Google Drive',
getToken() {
return store.getters['workspace/syncToken'];
},
@ -361,12 +362,16 @@ export default new Provider({
ifNotTooLate,
});
}
// Build sync data
// Build sync data to save
return {
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
syncData: {
id: file.id,
parentIds: file.parents,
itemId: item.id,
type: item.type,
hash: item.hash,
},
};
},
async removeWorkspaceItem({ syncData, ifNotTooLate }) {
@ -449,6 +454,7 @@ export default new Provider({
// Create file sync data
newFileSyncData = {
id: gdriveFile.id,
parentIds: gdriveFile.parents,
itemId: file.id,
type: file.type,
hash: file.hash,
@ -495,25 +501,32 @@ export default new Provider({
return {
syncData: {
id: file.id,
parentIds: file.parents,
itemId: item.id,
type: item.type,
hash: item.hash,
},
};
},
async listRevisions(token, fileId) {
const syncData = Provider.getContentSyncData(fileId);
const revisions = await googleHelper.getFileRevisions(token, syncData.id);
async listFileRevisions({ token, fileSyncData }) {
const revisions = await googleHelper.getFileRevisions(token, fileSyncData.id);
return revisions.map(revision => ({
id: revision.id,
sub: revision.lastModifyingUser && revision.lastModifyingUser.permissionId,
sub: `go:${revision.lastModifyingUser.permissionId}`,
created: new Date(revision.modifiedTime).getTime(),
}))
.sort((revision1, revision2) => revision2.created - revision1.created);
}));
},
async getRevisionContent(token, fileId, revisionId) {
const syncData = Provider.getContentSyncData(fileId);
const content = await googleHelper.downloadFileRevision(token, syncData.id, revisionId);
return Provider.parseContent(content, `${fileId}/content`);
async loadFileRevision() {
// Revision are already loaded
return false;
},
async getFileRevisionContent({
token,
contentId,
fileSyncData,
revisionId,
}) {
const content = await googleHelper.downloadFileRevision(token, fileSyncData.id, revisionId);
return Provider.parseContent(content, contentId);
},
});

View File

@ -141,6 +141,11 @@ export default {
* http://docs.couchdb.org/en/2.1.1/api/document/common.html#delete--db-docid
*/
async removeDocument(token, documentId, rev) {
if (!documentId) {
// Prevent from deleting the whole database
throw new Error('Missing document ID');
}
return request(token, {
method: 'DELETE',
path: documentId,

View File

@ -11,14 +11,14 @@ const getAppKey = (fullAccess) => {
const httpHeaderSafeJson = args => args && JSON.stringify(args)
.replace(/[\u007f-\uffff]/g, c => `\\u${`000${c.charCodeAt(0).toString(16)}`.slice(-4)}`);
const request = (token, options, args) => networkSvc.request({
const request = ({ accessToken }, options, args) => networkSvc.request({
...options,
headers: {
...options.headers || {},
'Content-Type': options.body && (typeof options.body === 'string'
? 'application/octet-stream' : 'application/json; charset=utf-8'),
'Dropbox-API-Arg': httpHeaderSafeJson(args),
Authorization: `Bearer ${token.accessToken}`,
Authorization: `Bearer ${accessToken}`,
},
});
@ -64,6 +64,28 @@ 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
*/
@ -104,6 +126,22 @@ export default {
};
},
/**
* https://www.dropbox.com/developers/documentation/http/documentation#list-revisions
*/
async listRevisions(token, fileId) {
const res = await request(token, {
method: 'POST',
url: 'https://api.dropboxapi.com/2/files/list_revisions',
body: {
path: fileId,
mode: 'id',
limit: 100,
},
});
return res.body.entries;
},
/**
* https://www.dropbox.com/developers/chooser
*/

View File

@ -95,11 +95,14 @@ export default {
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;
},
@ -263,4 +266,35 @@ export default {
}
return result.content;
},
/**
* https://developer.github.com/v3/gists/#list-gist-commits
*/
async getGistCommits({
token,
gistId,
}) {
const { body } = await request(token, {
url: `https://api.github.com/gists/${gistId}/commits`,
});
return body;
},
/**
* https://developer.github.com/v3/gists/#get-a-specific-revision-of-a-gist
*/
async downloadGistRevision({
token,
gistId,
filename,
sha,
}) {
const result = (await request(token, {
url: `https://api.github.com/gists/${gistId}/${sha}`,
})).body.files[filename];
if (!result) {
throw new Error('Gist file not found.');
}
return result.content;
},
};

View File

@ -4,6 +4,7 @@ import Provider from './common/Provider';
export default new Provider({
id: 'wordpress',
name: 'WordPress',
getToken(location) {
return store.getters['data/wordpressTokensBySub'][location.sub];
},

View File

@ -4,6 +4,7 @@ import Provider from './common/Provider';
export default new Provider({
id: 'zendesk',
name: 'Zendesk',
getToken(location) {
return store.getters['data/zendeskTokensBySub'][location.sub];
},

View File

@ -79,6 +79,7 @@ const publishFile = async (fileId) => {
utils.serializeObject(publishLocationToStore)
) {
store.commit('publishLocation/patchItem', publishLocationToStore);
workspaceSvc.ensureUniqueLocations();
}
counter += 1;
} catch (err) {

View File

@ -10,6 +10,7 @@ import './providers/githubWorkspaceProvider';
import './providers/googleDriveWorkspaceProvider';
import tempFileSvc from './tempFileSvc';
import workspaceSvc from './workspaceSvc';
import constants from '../data/constants';
const minAutoSyncEvery = 60 * 1000; // 60 sec
const inactivityThreshold = 3 * 1000; // 3 sec
@ -128,7 +129,7 @@ const cleanSyncedContent = (syncedContent) => {
};
/**
* Apply changes retrieved from the main provider. Update sync data accordingly.
* Apply changes retrieved from the workspace provider. Update sync data accordingly.
*/
const applyChanges = (changes) => {
const allItemsById = { ...store.getters.allItemsById };
@ -138,10 +139,7 @@ const applyChanges = (changes) => {
let getExistingItem;
if (store.getters['workspace/currentWorkspaceIsGit']) {
const itemsByGitPath = { ...store.getters.itemsByGitPath };
getExistingItem = (existingSyncData) => {
const items = existingSyncData && itemsByGitPath[existingSyncData.id];
return items ? items[0] : null;
};
getExistingItem = existingSyncData => existingSyncData && itemsByGitPath[existingSyncData.id];
} else {
getExistingItem = existingSyncData => existingSyncData && allItemsById[existingSyncData.itemId];
}
@ -476,6 +474,13 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
return;
}
// If content is to be created, schedule a restart to create the file as well
if (provider === workspaceProvider &&
!store.getters['data/syncDataByItemId'][fileId]
) {
syncContext.restartSkipContents = true;
}
// Upload merged content
const item = {
...mergedContent,
@ -491,13 +496,7 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
utils.serializeObject(syncLocationToStore)
) {
store.commit('syncLocation/patchItem', syncLocationToStore);
}
// If content was just created, restart sync to create the file as well
if (provider === workspaceProvider &&
!store.getters['data/syncDataByItemId'][fileId]
) {
syncContext.restartSkipContents = true;
workspaceSvc.ensureUniqueLocations();
}
};
@ -668,16 +667,13 @@ const syncWorkspace = async (skipContents = false) => {
if (!changedItem) return false;
const resultSyncData = await workspaceProvider
.saveWorkspaceItem({
// Use deepCopy to freeze objects
item: utils.deepCopy(changedItem),
syncData: utils.deepCopy(syncDataToUpdate),
ifNotTooLate,
});
store.dispatch('data/patchSyncDataById', {
[resultSyncData.id]: resultSyncData,
});
updateSyncData(await workspaceProvider.saveWorkspaceItem({
// Use deepCopy to freeze objects
item: utils.deepCopy(changedItem),
syncData: utils.deepCopy(syncDataToUpdate),
ifNotTooLate,
}));
return true;
}));
@ -819,7 +815,7 @@ const requestSync = () => {
// Determine if we have to clean files
const fileHashesToClean = {};
if (getLastStoredSyncActivity() + utils.cleanTrashAfter < Date.now()) {
if (getLastStoredSyncActivity() + constants.cleanTrashAfter < Date.now()) {
// Last synchronization happened 7 days ago
const syncDataByItemId = store.getters['data/syncDataByItemId'];
store.getters['file/items'].forEach((file) => {

View File

@ -1,13 +1,14 @@
import googleHelper from './providers/helpers/googleHelper';
import githubHelper from './providers/helpers/githubHelper';
import utils from './utils';
import store from '../store';
import dropboxHelper from './providers/helpers/dropboxHelper';
import constants from '../data/constants';
const promised = {};
const parseUserId = (userId) => {
const prefix = userId[2] === ':' && userId.slice(0, 2);
const type = prefix && utils.userIdPrefixes[prefix];
const type = prefix && constants.userIdPrefixes[prefix];
return type ? [type, userId.slice(3)] : ['google', userId];
};
@ -17,7 +18,7 @@ export default {
store.commit('userInfo/addItem', { id, name, imageUrl });
},
async getInfo(userId) {
if (!promised[userId]) {
if (userId && !promised[userId]) {
const [type, sub] = parseUserId(userId);
// Try to find a token with this sub
@ -33,6 +34,17 @@ export default {
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;
}
case 'github':
try {
await githubHelper.getUser(sub);

View File

@ -1,8 +1,7 @@
import yaml from 'js-yaml';
import '../libs/clunderscore';
import presets from '../data/presets';
const origin = `${window.location.protocol}//${window.location.host}`;
import constants from '../data/constants';
// For utils.uid()
const uidLength = 16;
@ -16,7 +15,18 @@ const parseQueryParams = (params) => {
const result = {};
params.split('&').forEach((param) => {
const [key, value] = param.split('=').map(decodeURIComponent);
if (key) {
if (key && value != null) {
result[key] = value;
}
});
return result;
};
// For utils.setQueryParams()
const filterParams = (params = {}) => {
const result = {};
Object.entries(params).forEach(([key, value]) => {
if (key && value != null) {
result[key] = value;
}
});
@ -67,12 +77,9 @@ Object.keys(presets).forEach((key) => {
export default {
computedPresets,
cleanTrashAfter: 7 * 24 * 60 * 60 * 1000, // 7 days
origin,
oauth2RedirectUri: `${origin}/oauth2/callback`,
queryParams: parseQueryParams(window.location.hash.slice(1)),
setQueryParams(params = {}) {
this.queryParams = params;
this.queryParams = filterParams(params);
const serializedParams = Object.entries(this.queryParams).map(([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&');
const hash = `#${serializedParams}`;
@ -80,39 +87,17 @@ export default {
window.location.replace(hash);
}
},
types: [
'contentState',
'syncedContent',
'content',
'file',
'folder',
'syncLocation',
'publishLocation',
'data',
],
localStorageDataIds: [
'workspaces',
'settings',
'layoutSettings',
'tokens',
],
userIdPrefixes: {
go: 'google',
gh: 'github',
},
textMaxLength: 250000,
sanitizeText(text) {
const result = `${text || ''}`.slice(0, this.textMaxLength);
const result = `${text || ''}`.slice(0, constants.textMaxLength);
// last char must be a `\n`.
return `${result}\n`.replace(/\n\n$/, '\n');
},
defaultName: 'Untitled',
sanitizeName(name) {
return `${name || ''}`
// Replace `/`, control characters and other kind of spaces with a space
.replace(/[/\x00-\x1F\x7f-\xa0\s]+/g, ' ').trim() // eslint-disable-line no-control-regex
// Keep only 250 characters
.slice(0, 250) || this.defaultName;
.slice(0, 250) || constants.defaultName;
},
deepCopy,
serializeObject(obj) {

View File

@ -1,5 +1,6 @@
import store from '../store';
import utils from './utils';
import constants from '../data/constants';
const forbiddenFolderNameMatcher = /^\.stackedit-data$|^\.stackedit-trash$|\.md$|\.sync$|\.publish$/;
@ -35,7 +36,7 @@ export default {
// Show warning dialogs
if (!background) {
// If name is being stripped
if (item.name !== utils.defaultName && item.name !== name) {
if (item.name !== constants.defaultName && item.name !== name) {
await store.dispatch('modal/open', {
type: 'stripName',
item,
@ -83,7 +84,7 @@ export default {
// Show warning dialogs
// If name has been stripped
if (sanitizedName !== utils.defaultName && sanitizedName !== item.name) {
if (sanitizedName !== constants.defaultName && sanitizedName !== item.name) {
await store.dispatch('modal/open', {
type: 'stripName',
item,

View File

@ -1,6 +1,6 @@
import DiffMatchPatch from 'diff-match-patch';
import moduleTemplate from './moduleTemplate';
import empty from '../data/emptyContent';
import empty from '../data/empties/emptyContent';
import utils from '../services/utils';
import cledit from '../services/editor/cledit';

View File

@ -1,5 +1,5 @@
import moduleTemplate from './moduleTemplate';
import empty from '../data/emptyContentState';
import empty from '../data/empties/emptyContentState';
const module = moduleTemplate(empty, true);

View File

@ -1,14 +1,15 @@
import Vue from 'vue';
import yaml from 'js-yaml';
import utils from '../services/utils';
import defaultWorkspaces from '../data/defaultWorkspaces';
import defaultSettings from '../data/defaultSettings.yml';
import defaultLocalSettings from '../data/defaultLocalSettings';
import defaultLayoutSettings from '../data/defaultLayoutSettings';
import plainHtmlTemplate from '../data/plainHtmlTemplate.html';
import styledHtmlTemplate from '../data/styledHtmlTemplate.html';
import styledHtmlWithTocTemplate from '../data/styledHtmlWithTocTemplate.html';
import jekyllSiteTemplate from '../data/jekyllSiteTemplate.html';
import defaultWorkspaces from '../data/defaults/defaultWorkspaces';
import defaultSettings from '../data/defaults/defaultSettings.yml';
import defaultLocalSettings from '../data/defaults/defaultLocalSettings';
import defaultLayoutSettings from '../data/defaults/defaultLayoutSettings';
import plainHtmlTemplate from '../data/templates/plainHtmlTemplate.html';
import styledHtmlTemplate from '../data/templates/styledHtmlTemplate.html';
import styledHtmlWithTocTemplate from '../data/templates/styledHtmlWithTocTemplate.html';
import jekyllSiteTemplate from '../data/templates/jekyllSiteTemplate.html';
import constants from '../data/constants';
const itemTemplate = (id, data = {}) => ({
id,
@ -33,7 +34,7 @@ const empty = (id) => {
};
// Item IDs that will be stored in the localStorage
const lsItemIdSet = new Set(utils.localStorageDataIds);
const lsItemIdSet = new Set(constants.localStorageDataIds);
// Getter/setter/patcher factories
const getter = id => state => ((lsItemIdSet.has(id)
@ -58,13 +59,13 @@ const layoutSettingsToggler = propertyName => ({ getters, dispatch }, value) =>
[propertyName]: value === undefined ? !getters.layoutSettings[propertyName] : value,
});
const notEnoughSpace = (getters) => {
const constants = getters['layout/constants'];
const layoutConstants = getters['layout/constants'];
const showGutter = getters['discussion/currentDiscussion'];
return document.body.clientWidth < constants.editorMinWidth +
constants.explorerWidth +
constants.sideBarWidth +
constants.buttonBarWidth +
(showGutter ? constants.gutterWidth : 0);
return document.body.clientWidth < layoutConstants.editorMinWidth +
layoutConstants.explorerWidth +
layoutConstants.sideBarWidth +
layoutConstants.buttonBarWidth +
(showGutter ? layoutConstants.gutterWidth : 0);
};
// For templates

View File

@ -1,6 +1,6 @@
import Vue from 'vue';
import emptyFile from '../data/emptyFile';
import emptyFolder from '../data/emptyFolder';
import emptyFile from '../data/empties/emptyFile';
import emptyFolder from '../data/empties/emptyFolder';
const setter = propertyName => (state, value) => {
state[propertyName] = value;

View File

@ -1,5 +1,5 @@
import moduleTemplate from './moduleTemplate';
import empty from '../data/emptyFile';
import empty from '../data/empties/emptyFile';
const module = moduleTemplate(empty);

View File

@ -1,5 +1,5 @@
import moduleTemplate from './moduleTemplate';
import empty from '../data/emptyFolder';
import empty from '../data/empties/emptyFolder';
const module = moduleTemplate(empty);

View File

@ -19,8 +19,9 @@ import syncedContent from './syncedContent';
import userInfo from './userInfo';
import workspace from './workspace';
import locationTemplate from './locationTemplate';
import emptyPublishLocation from '../data/emptyPublishLocation';
import emptySyncLocation from '../data/emptySyncLocation';
import emptyPublishLocation from '../data/empties/emptyPublishLocation';
import emptySyncLocation from '../data/empties/emptySyncLocation';
import constants from '../data/constants';
Vue.use(Vuex);
@ -77,7 +78,7 @@ const store = new Vuex.Store({
getters: {
allItemsById: (state) => {
const result = {};
utils.types.forEach(type => Object.assign(result, state[type].itemsById));
constants.types.forEach(type => Object.assign(result, state[type].itemsById));
return result;
},
pathsByItemId: (state, getters) => {
@ -86,7 +87,7 @@ const store = new Vuex.Store({
const getPath = (item) => {
let itemPath = result[item.id];
if (!itemPath) {
if (item.parendId === 'trash') {
if (item.parentId === 'trash') {
itemPath = `.stackedit-trash/${item.name}`;
} else {
let { name } = item;
@ -150,14 +151,19 @@ const store = new Vuex.Store({
});
return result;
},
itemIdsByGitPath: (state, { gitPathsByItemId }) => {
const result = {};
Object.entries(gitPathsByItemId).forEach(([id, path]) => {
result[path] = id;
});
return result;
},
itemsByGitPath: (state, { allItemsById, gitPathsByItemId }) => {
const result = {};
Object.entries(gitPathsByItemId).forEach(([id, path]) => {
const item = allItemsById[id];
if (item) {
const items = result[path] || [];
items.push(item);
result[path] = items;
result[path] = item;
}
});
return result;

View File

@ -1,5 +1,6 @@
import moduleTemplate from './moduleTemplate';
import providerRegistry from '../services/providers/common/providerRegistry';
import utils from '../services/utils';
const addToGroup = (groups, item) => {
const list = groups[item.fileId];
@ -54,7 +55,7 @@ export default (empty) => {
const provider = providerRegistry.providersById[location.providerId];
return {
...location,
description: provider.getLocationDescription(location),
description: utils.sanitizeName(provider.getLocationDescription(location)),
url: provider.getLocationUrl(location),
};
});
@ -74,7 +75,8 @@ export default (empty) => {
id: 'main',
providerId: workspaceProvider.id,
fileId,
description: workspaceProvider.getSyncDataDescription(fileSyncData, contentSyncData),
description: utils.sanitizeName(workspaceProvider
.getSyncDataDescription(fileSyncData, contentSyncData)),
url: workspaceProvider.getSyncDataUrl(fileSyncData, contentSyncData),
}, ...current];
},

View File

@ -1,3 +1,5 @@
import providerRegistry from '../services/providers/common/providerRegistry';
const defaultTimeout = 5000;
export default {
@ -34,7 +36,8 @@ export default {
} else if (error.status) {
const location = rootState.queue.currentLocation;
if (location.providerId) {
item.content = `HTTP error ${error.status} on ${location.providerId} location.`;
const provider = providerRegistry.providersById[location.providerId];
item.content = `HTTP error ${error.status} on ${provider.name} location.`;
} else {
item.content = `HTTP error ${error.status}.`;
}

View File

@ -1,5 +1,5 @@
import moduleTemplate from './moduleTemplate';
import empty from '../data/emptySyncedContent';
import empty from '../data/empties/emptySyncedContent';
const module = moduleTemplate(empty, true);

View File

@ -1,5 +1,6 @@
import utils from '../services/utils';
import providerRegistry from '../services/providers/common/providerRegistry';
import constants from '../data/constants';
export default {
namespaced: true,
@ -22,14 +23,14 @@ export default {
Object.entries(rootGetters['data/workspaces']).forEach(([id, workspace]) => {
const sanitizedWorkspace = {
id,
providerId: mainWorkspaceToken && 'googleDriveAppData',
providerId: 'googleDriveAppData',
sub: mainWorkspaceToken && mainWorkspaceToken.sub,
...workspace,
};
// Filter workspaces that don't have a provider
const workspaceProvider = providerRegistry.providersById[sanitizedWorkspace.providerId];
if (workspaceProvider) {
// Rebuild the url with the current hostname
// Build the url with the current hostname
const params = workspaceProvider.getWorkspaceParams(sanitizedWorkspace);
sanitizedWorkspace.url = utils.addQueryParams('app', params, true);
sanitizedWorkspace.locationUrl = workspaceProvider
@ -81,7 +82,7 @@ export default {
if (!loginToken) {
return null;
}
const prefix = utils.someResult(Object.entries(utils.userIdPrefixes), ([key, value]) => {
const prefix = utils.someResult(Object.entries(constants.userIdPrefixes), ([key, value]) => {
if (rootGetters[`data/${value}TokensBySub`][loginToken.sub]) {
return key;
}

View File

@ -173,7 +173,7 @@ textarea {
width: 100%;
max-width: 100%;
color: inherit;
height: 2.5rem;
height: 2.4rem;
&:focus {
outline: none;