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 { .find-replace__find-stats {
text-align: right; text-align: right;
font-size: 0.75em; font-size: 0.75em;
opacity: 0.5; opacity: 0.6;
} }
.find-replace-highlighting { .find-replace-highlighting {

View File

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

View File

@ -2,12 +2,12 @@
<div class="tour" @keydown.esc="skip"> <div class="tour" @keydown.esc="skip">
<div class="tour-step" :class="'tour-step--' + step" :style="stepStyle"> <div class="tour-step" :class="'tour-step--' + step" :style="stepStyle">
<div class="tour-step__inner" v-if="step === 'welcome'"> <div class="tour-step__inner" v-if="step === 'welcome'">
<h2>Welcome to StackEdit!</h2> <h2>Welcome back!</h2>
<p>Greater, lighter, faster... <b>StackEdit 5</b> is here!</p> <p>The new <b>StackEdit 5</b> is here!</p>
<p>Please click <b>Next</b> to take a quick tour.</p> <p>Please click <b>Next</b> to take a quick tour.</p>
<div class="tour-step__button-bar"> <div class="tour-step__button-bar">
<button class="button" @click="finish">Skip</button> <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> </div>
<div class="tour-step__inner" v-else-if="step === 'editor'"> <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> <p>Click <icon-side-preview></icon-side-preview> to toggle the side preview.</p>
<div class="tour-step__button-bar"> <div class="tour-step__button-bar">
<button class="button" @click="finish">Skip</button> <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> </div>
<div class="tour-step__inner" v-else-if="step === 'explorer'"> <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> <p>Click <icon-folder></icon-folder> to open the file explorer.</p>
<div class="tour-step__button-bar"> <div class="tour-step__button-bar">
<button class="button" @click="finish">Skip</button> <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> </div>
<div class="tour-step__inner" v-else-if="step === 'menu'"> <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> <p>Click <icon-provider provider-id="stackedit"></icon-provider> to explore the menu.</p>
<div class="tour-step__button-bar"> <div class="tour-step__button-bar">
<button class="button" @click="finish">Skip</button> <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> </div>
<div class="tour-step__inner" v-else-if="step === 'end'"> <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>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> <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"> <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> </div>
</div> </div>
@ -139,12 +139,12 @@ export default {
} }
$tour-step-background: mix(#f3f3f3, $selection-highlighting-color, 75%); $tour-step-background: mix(#f3f3f3, $selection-highlighting-color, 75%);
$tour-step-width: 220px; $tour-step-width: 240px;
.tour-step__inner { .tour-step__inner {
position: absolute; position: absolute;
background-color: $tour-step-background; background-color: $tour-step-background;
padding: 1.5em 1em 1em; padding: 1em;
font-size: 0.9em; font-size: 0.9em;
line-height: 1.33; line-height: 1.33;
width: $tour-step-width; width: $tour-step-width;
@ -213,6 +213,11 @@ $tour-step-width: 220px;
} }
.tour-step__button-bar { .tour-step__button-bar {
margin-top: 1.75em;
text-align: right; text-align: right;
.button {
font-size: 1.1em;
}
} }
</style> </style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<template> <template>
<modal-inner class="modal__inner-1--sponsor" aria-label="Sponsor"> <modal-inner class="modal__inner-1--sponsor" aria-label="Sponsor">
<div class="modal__content"> <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"> <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 class="flex flex--column">
<div>{{button.price}}<div class="paypal-option__offer" v-if="button.offer">{{button.offer}}</div></div> <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"> <style lang="scss">
@import '../../styles/variables.scss'; @import '../../styles/variables.scss';
.modal__inner-1--sponsor { .modal__inner-1.modal__inner-1--sponsor {
max-width: 380px; max-width: 400px;
} }
.paypal-option { .paypal-option {
@ -81,7 +81,7 @@ export default {
span { span {
display: inline-block; display: inline-block;
font-size: 0.75rem; font-size: 0.75rem;
opacity: 0.5; opacity: 0.6;
white-space: normal; white-space: normal;
line-height: 1.5; line-height: 1.5;
} }

View File

@ -23,7 +23,7 @@
</div> </div>
<div class="sync-entry__row flex flex--row flex--align-center"> <div class="sync-entry__row flex flex--row flex--align-center">
<div class="sync-entry__url"> <div class="sync-entry__url">
{{location.url || 'Workspace location'}} {{location.url || 'Google Drive app data'}}
</div> </div>
<div class="sync-entry__buttons flex flex--row flex--center" v-if="location.url"> <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'"> <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"> <style lang="scss">
@import '../../styles/variables.scss'; @import '../../styles/variables.scss';
.modal__inner-1--sync-management {
max-width: 560px;
}
.sync-entry { .sync-entry {
margin: 1.5em 0; margin: 1.5em 0;
height: auto; height: auto;

View File

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

View File

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

View File

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

View File

@ -11,18 +11,18 @@
<b>Example:</b> https://github.com/benweet/stackedit <b>Example:</b> https://github.com/benweet/stackedit
</div> </div>
</form-entry> </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"> <form-entry label="File path" error="path">
<input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
<b>Example:</b> path/to/README.md <b>Example:</b> path/to/README.md
</div> </div>
</form-entry> </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>
<div class="modal__button-bar"> <div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button> <button class="button" @click="config.reject()">Cancel</button>

View File

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

View File

@ -27,12 +27,12 @@
</template> </template>
<script> <script>
import utils from '../../../services/utils';
import modalTemplate from '../common/modalTemplate'; import modalTemplate from '../common/modalTemplate';
import constants from '../../../data/constants';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({
redirectUrl: utils.oauth2RedirectUri, redirectUrl: constants.oauth2RedirectUri,
}), }),
computedLocalSettings: { computedLocalSettings: {
siteUrl: 'zendeskSiteUrl', 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 # Adjust maximum text width in editor and preview
maxWidthFactor: 1 maxWidthFactor: 1
# Auto-sync frequency (in ms). Minimum is 60000. # Auto-sync frequency (in ms). Minimum is 60000.
autoSyncEvery: 60000 autoSyncEvery: 90000
# Editor settings # Editor settings
editor: editor:
@ -77,10 +77,11 @@ turndown:
linkStyle: inlined linkStyle: inlined
linkReferenceStyle: full linkReferenceStyle: full
# GitHub commit messages
github: github:
createFileMessage: Create {{path}} from https://stackedit.io/ createFileMessage: '{{path}} created from https://stackedit.io/'
updateFileMessage: Update {{path}} from https://stackedit.io/ updateFileMessage: '{{path}} updated from https://stackedit.io/'
deleteFileMessage: Delete {{path}} from https://stackedit.io/ deleteFileMessage: '{{path}} deleted from https://stackedit.io/'
# Default content for new files # Default content for new files
newFileContent: | 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. 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?** **Can StackEdit access my data without telling me?**

View File

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

View File

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

View File

@ -1,11 +1,13 @@
import utils from './utils'; import utils from './utils';
import store from '../store'; import store from '../store';
import constants from '../data/constants';
const scriptLoadingPromises = Object.create(null); 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 const networkTimeout = 30 * 1000; // 30 sec
let isConnectionDown = false; 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) { function parseHeaders(xhr) {
@ -54,9 +56,9 @@ export default {
// Check browser is online periodically // Check browser is online periodically
const checkOffline = async () => { const checkOffline = async () => {
const isBrowserOffline = window.navigator.onLine === false; const isBrowserOffline = window.navigator.onLine === false;
if (!isBrowserOffline && if (!isBrowserOffline
store.state.lastOfflineCheck + networkTimeout + 5000 < Date.now() && && store.state.lastOfflineCheck + networkTimeout + 5000 < Date.now()
this.isUserActive() && this.isUserActive()
) { ) {
store.commit('updateLastOfflineCheck'); store.commit('updateLastOfflineCheck');
const script = document.createElement('script'); const script = document.createElement('script');
@ -121,85 +123,98 @@ export default {
} }
return scriptLoadingPromises[url]; return scriptLoadingPromises[url];
}, },
async startOauth2(url, params = {}, silent = false) { async startOauth2(url, params = {}, silent = false, reattempt = 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;
try { try {
return await new Promise((resolve, reject) => { // Build the authorize URL
if (silent) { const state = utils.uid();
iframeElt.onerror = () => { const authorizeUrl = utils.addQueryParams(url, {
reject(new Error('Unknown error.')); ...params,
}; state,
closeTimeout = setTimeout(() => { redirect_uri: constants.oauth2RedirectUri,
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);
}
}); });
} finally {
clearInterval(checkClosedInterval); let iframeElt;
if (!silent && !wnd.closed) { let wnd;
wnd.close(); 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); } catch (e) {
window.removeEventListener('message', msgHandler); if (e.message === 'REATTEMPT') {
return this.startOauth2(url, params, silent, true);
}
throw e;
} }
}, },
async request(configParam, offlineCheck = false) { async request(configParam, offlineCheck = false) {

View File

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

View File

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

View File

@ -1,10 +1,10 @@
import providerRegistry from './providerRegistry'; import providerRegistry from './providerRegistry';
import emptyContent from '../../../data/emptyContent'; import emptyContent from '../../../data/empties/emptyContent';
import utils from '../../utils'; import utils from '../../utils';
import store from '../../../store'; import store from '../../../store';
import workspaceSvc from '../../workspaceSvc'; 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 { export default class Provider {
prepareChanges = changes => changes prepareChanges = changes => changes
@ -70,14 +70,6 @@ export default class Provider {
return utils.addItemHash(result); 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 * Find and open a file with location that meets the criteria
*/ */

View File

@ -7,6 +7,7 @@ let syncLastSeq;
export default new Provider({ export default new Provider({
id: 'couchdbWorkspace', id: 'couchdbWorkspace',
name: 'CouchDB',
getToken() { getToken() {
return store.getters['workspace/syncToken']; return store.getters['workspace/syncToken'];
}, },
@ -91,19 +92,22 @@ export default new Provider({
}, },
async saveWorkspaceItem({ item, syncData }) { async saveWorkspaceItem({ item, syncData }) {
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];
const { id, rev } = couchdbHelper.uploadDocument({ const { id, rev } = await couchdbHelper.uploadDocument({
token: syncToken, token: syncToken,
item, item,
documentId: syncData && syncData.id, documentId: syncData && syncData.id,
rev: syncData && syncData.rev, rev: syncData && syncData.rev,
}); });
// Build sync data to save
return { return {
// Build sync data syncData: {
id, id,
itemId: item.id, itemId: item.id,
type: item.type, type: item.type,
hash: item.hash, hash: item.hash,
rev, rev,
},
}; };
}, },
removeWorkspaceItem({ syncData }) { removeWorkspaceItem({ syncData }) {
@ -190,31 +194,34 @@ export default new Provider({
}, },
}; };
}, },
async listRevisions(token, fileId) { async listFileRevisions({ token, contentSyncData }) {
const syncData = Provider.getContentSyncData(fileId); const body = await couchdbHelper.retrieveDocumentWithRevisions(token, contentSyncData.id);
const body = await couchdbHelper.retrieveDocumentWithRevisions(token, syncData.id);
const revisions = []; 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') { if (revInfo.status === 'available') {
revisions.push({ revisions.push({
id: revInfo.rev, id: revInfo.rev,
sub: null, sub: null,
created: null, created: idx,
loaded: false,
}); });
} }
}); });
return revisions; return revisions;
}, },
async loadRevision(token, fileId, revision) { async loadFileRevision({ token, contentSyncData, revision }) {
const syncData = Provider.getContentSyncData(fileId); if (revision.loaded) {
const body = await couchdbHelper.retrieveDocument(token, syncData.id, revision.id); return false;
}
const body = await couchdbHelper.retrieveDocument(token, contentSyncData.id, revision.id);
revision.sub = body.sub; 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) { async getFileRevisionContent({ token, contentSyncData, revisionId }) {
const syncData = Provider.getContentSyncData(fileId);
const body = await couchdbHelper const body = await couchdbHelper
.retrieveDocumentWithAttachments(token, syncData.id, revisionId); .retrieveDocumentWithAttachments(token, contentSyncData.id, revisionId);
return Provider.parseContent(body.attachments.data, body.item.id); return Provider.parseContent(body.attachments.data, body.item.id);
}, },
}); });

View File

@ -19,6 +19,7 @@ const makePathRelative = (token, path) => {
export default new Provider({ export default new Provider({
id: 'dropbox', id: 'dropbox',
name: 'Dropbox',
getToken({ sub }) { getToken({ sub }) {
return store.getters['data/dropboxTokensBySub'][sub]; return store.getters['data/dropboxTokensBySub'][sub];
}, },
@ -27,8 +28,8 @@ export default new Provider({
const filename = pathComponents.pop(); const filename = pathComponents.pop();
return `https://www.dropbox.com/home${pathComponents.join('/')}?preview=${filename}`; return `https://www.dropbox.com/home${pathComponents.join('/')}?preview=${filename}`;
}, },
getLocationDescription({ path }) { getLocationDescription({ path, dropboxFileId }) {
return path; return dropboxFileId || path;
}, },
checkPath(path) { checkPath(path) {
return path && path.match(/^\/[^\\<>:"|?*]+$/); return path && path.match(/^\/[^\\<>:"|?*]+$/);
@ -122,4 +123,27 @@ export default new Provider({
path, 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 githubHelper from './helpers/githubHelper';
import Provider from './common/Provider'; import Provider from './common/Provider';
import utils from '../utils'; import utils from '../utils';
import userSvc from '../userSvc';
export default new Provider({ export default new Provider({
id: 'gist', id: 'gist',
name: 'Gist',
getToken({ sub }) { getToken({ sub }) {
return store.getters['data/githubTokensBySub'][sub]; return store.getters['data/githubTokensBySub'][sub];
}, },
@ -56,4 +58,37 @@ export default new Provider({
gistId, 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 Provider from './common/Provider';
import utils from '../utils'; import utils from '../utils';
import workspaceSvc from '../workspaceSvc'; import workspaceSvc from '../workspaceSvc';
import userSvc from '../userSvc';
const savedSha = {}; const savedSha = {};
export default new Provider({ export default new Provider({
id: 'github', id: 'github',
name: 'GitHub',
getToken({ sub }) { getToken({ sub }) {
return store.getters['data/githubTokensBySub'][sub]; return store.getters['data/githubTokensBySub'][sub];
}, },
@ -23,21 +25,21 @@ export default new Provider({
return path; return path;
}, },
async downloadContent(token, syncLocation) { async downloadContent(token, syncLocation) {
try { const { sha, data } = await githubHelper.downloadFile({
const { sha, data } = await githubHelper.downloadFile({ ...syncLocation,
...syncLocation, token,
token, });
}); savedSha[syncLocation.id] = sha;
savedSha[syncLocation.id] = sha; return Provider.parseContent(data, `${syncLocation.fileId}/content`);
return Provider.parseContent(data, `${syncLocation.fileId}/content`);
} catch (e) {
// Ignore error, upload is going to fail anyway
return null;
}
}, },
async uploadContent(token, content, syncLocation) { async uploadContent(token, content, syncLocation) {
if (!savedSha[syncLocation.id]) { 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]; const sha = savedSha[syncLocation.id];
delete savedSha[syncLocation.id]; delete savedSha[syncLocation.id];
@ -50,7 +52,12 @@ export default new Provider({
return syncLocation; return syncLocation;
}, },
async publish(token, html, metadata, publishLocation) { 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]; const sha = savedSha[publishLocation.id];
delete savedSha[publishLocation.id]; delete savedSha[publishLocation.id];
await githubHelper.uploadFile({ await githubHelper.uploadFile({
@ -109,4 +116,50 @@ export default new Provider({
path, 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({ export default new Provider({
id: 'githubWorkspace', id: 'githubWorkspace',
name: 'GitHub',
getToken() { getToken() {
return store.getters['workspace/syncToken']; return store.getters['workspace/syncToken'];
}, },
@ -136,76 +137,90 @@ export default new Provider({
// Collect changes // Collect changes
const changes = []; const changes = [];
const pathIds = {}; const idsByPath = {};
const syncDataToKeep = Object.create(null);
const syncDataByPath = store.getters['data/syncDataById']; const syncDataByPath = store.getters['data/syncDataById'];
const { itemsByGitPath } = store.getters; const { itemIdsByGitPath } = store.getters;
const getId = (path) => { const getIdFromPath = (path, isFile) => {
const existingItem = itemsByGitPath[path]; let itemId = idsByPath[path];
// Use the item ID only if the item was already synced if (!itemId) {
if (existingItem && syncDataByPath[path]) { const existingItemId = itemIdsByGitPath[path];
pathIds[path] = existingItem.id; // We can replace the item only if it was already synced
return existingItem.id; 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 return itemId;
let id = utils.uid();
if (path[0] === '/') {
id += '/content';
}
pathIds[path] = id;
return id;
}; };
// Folder creations/updates // Folder creations/updates
// Assume map entries are sorted from top to bottom // Assume map entries are sorted from top to bottom
Object.entries(treeFolderMap).forEach(([path, parentPath]) => { Object.entries(treeFolderMap).forEach(([path, parentPath]) => {
const item = utils.addItemHash({ if (path === '.stackedit-trash/') {
id: getId(path), idsByPath[path] = 'trash';
type: 'folder', } else {
name: path.slice(parentPath.length, -1), const item = utils.addItemHash({
parentId: pathIds[parentPath] || null, id: getIdFromPath(path),
}); type: 'folder',
changes.push({ name: path.slice(parentPath.length, -1),
syncDataId: path, parentId: idsByPath[parentPath] || null,
item, });
syncData: {
id: path, const folderSyncData = syncDataByPath[path];
type: item.type, if (!folderSyncData || folderSyncData.hash !== item.hash) {
hash: item.hash, changes.push({
}, syncDataId: path,
}); item,
syncData: {
id: path,
type: item.type,
hash: item.hash,
},
});
}
}
}); });
// File/content creations/updates // File/content creations/updates
Object.entries(treeFileMap).forEach(([path, parentPath]) => { 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 contentPath = `/${path}`;
const contentId = getId(contentPath); const contentId = idsByPath[contentPath];
// File creations/updates // File creations/updates
const [fileId] = contentId.split('/');
const item = utils.addItemHash({ const item = utils.addItemHash({
id: fileId, id: fileId,
type: 'file', type: 'file',
name: path.slice(parentPath.length, -'.md'.length), name: path.slice(parentPath.length, -'.md'.length),
parentId: pathIds[parentPath] || null, parentId: idsByPath[parentPath] || null,
});
changes.push({
syncDataId: path,
item,
syncData: {
id: path,
type: item.type,
hash: item.hash,
},
}); });
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 // Content creations/updates
const contentSyncData = syncDataByPath[contentPath]; const contentSyncData = syncDataByPath[contentPath];
if (contentSyncData) {
syncDataToKeep[path] = true;
syncDataToKeep[contentPath] = true;
}
if (!contentSyncData || contentSyncData.sha !== treeShaMap[path]) { if (!contentSyncData || contentSyncData.sha !== treeShaMap[path]) {
const type = 'content'; const type = 'content';
// Use `/` as a prefix to get a unique syncData id // Use `/` as a prefix to get a unique syncData id
@ -233,11 +248,8 @@ export default new Provider({
// Only template data are stored // Only template data are stored
const [, id] = path.match(/^\.stackedit-data\/(templates)\.json$/) || []; const [, id] = path.match(/^\.stackedit-data\/(templates)\.json$/) || [];
if (id) { if (id) {
pathIds[path] = id; idsByPath[path] = id;
const syncData = syncDataByItemId[id]; const syncData = syncDataByItemId[id];
if (syncData) {
syncDataToKeep[syncData.id] = true;
}
if (!syncData || syncData.sha !== treeShaMap[path]) { if (!syncData || syncData.sha !== treeShaMap[path]) {
const type = 'data'; const type = 'data';
changes.push({ changes.push({
@ -273,14 +285,11 @@ export default new Provider({
const [, filePath, data] = path.match(pathMatcher) || []; const [, filePath, data] = path.match(pathMatcher) || [];
if (filePath) { if (filePath) {
// If there is a corresponding md file in the tree // If there is a corresponding md file in the tree
const fileId = pathIds[`${filePath}.md`]; const fileId = idsByPath[`${filePath}.md`];
if (fileId) { if (fileId) {
// Reuse existing ID or create a new one // Reuse existing ID or create a new one
const existingItem = itemsByGitPath[path]; const id = itemIdsByGitPath[path] || utils.uid();
const id = existingItem idsByPath[path] = id;
? existingItem.id
: utils.uid();
pathIds[path] = id;
const item = utils.addItemHash({ const item = utils.addItemHash({
...JSON.parse(utils.decodeBase64(data)), ...JSON.parse(utils.decodeBase64(data)),
@ -288,22 +297,26 @@ export default new Provider({
type, type,
fileId, fileId,
}); });
changes.push({
syncDataId: path, const locationSyncData = syncDataByPath[path];
item, if (!locationSyncData || locationSyncData.hash !== item.hash) {
syncData: { changes.push({
id: path, syncDataId: path,
type: item.type, item,
hash: item.hash, syncData: {
}, id: path,
}); type: item.type,
hash: item.hash,
},
});
}
} }
} }
})); }));
// Deletions // Deletions
Object.keys(syncDataByPath).forEach((path) => { Object.keys(syncDataByPath).forEach((path) => {
if (!pathIds[path] && !syncDataToKeep[path]) { if (!idsByPath[path]) {
changes.push({ syncDataId: path }); changes.push({ syncDataId: path });
} }
}); });
@ -331,7 +344,11 @@ export default new Provider({
content: '', content: '',
sha: treeShaMap[syncData.id], sha: treeShaMap[syncData.id],
}); });
return syncData;
// Return sync data to save
return {
syncData,
};
}, },
async removeWorkspaceItem({ syncData }) { async removeWorkspaceItem({ syncData }) {
if (treeShaMap[syncData.id]) { 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 { owner, repo, branch } = store.getters['workspace/currentWorkspace'];
const syncData = Provider.getContentSyncData(fileId);
const entries = await githubHelper.getCommits({ const entries = await githubHelper.getCommits({
token, token,
owner, owner,
repo, repo,
sha: branch, sha: branch,
path: syncData.id, path: getAbsolutePath(fileSyncData),
}); });
return entries.map(({ return entries.map(({
author, author,
committer, committer,
@ -466,17 +483,24 @@ export default new Provider({
sub, sub,
created: date ? new Date(date).getTime() : 1, created: date ? new Date(date).getTime() : 1,
}; };
}) });
.sort((revision1, revision2) => revision2.created - revision1.created);
}, },
async getRevisionContent(token, fileId, revisionId) { async loadFileRevision() {
const syncData = Provider.getContentSyncData(fileId); // Revisions are already loaded
return false;
},
async getFileRevisionContent({
token,
contentId,
fileSyncData,
revisionId,
}) {
const { data } = await githubHelper.downloadFile({ const { data } = await githubHelper.downloadFile({
...store.getters['workspace/currentWorkspace'], ...store.getters['workspace/currentWorkspace'],
token, token,
branch: revisionId, 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({ export default new Provider({
id: 'googleDriveAppData', id: 'googleDriveAppData',
name: 'Google Drive app data',
getToken() { getToken() {
return store.getters['workspace/syncToken']; return store.getters['workspace/syncToken'];
}, },
@ -69,12 +70,15 @@ export default new Provider({
fileId: syncData && syncData.id, fileId: syncData && syncData.id,
ifNotTooLate, ifNotTooLate,
}); });
// Build sync data
// Build sync data to save
return { return {
id: file.id, syncData: {
itemId: item.id, id: file.id,
type: item.type, itemId: item.id,
hash: item.hash, type: item.type,
hash: item.hash,
},
}; };
}, },
removeWorkspaceItem({ syncData, ifNotTooLate }) { removeWorkspaceItem({ syncData, ifNotTooLate }) {
@ -163,19 +167,21 @@ export default new Provider({
}, },
}; };
}, },
async listRevisions(token, fileId) { async listFileRevisions({ token, contentSyncData }) {
const syncData = Provider.getContentSyncData(fileId); const revisions = await googleHelper.getAppDataFileRevisions(token, contentSyncData.id);
const revisions = await googleHelper.getAppDataFileRevisions(token, syncData.id);
return revisions.map(revision => ({ return revisions.map(revision => ({
id: revision.id, id: revision.id,
sub: `go:${revision.lastModifyingUser.permissionId}`, sub: `go:${revision.lastModifyingUser.permissionId}`,
created: new Date(revision.modifiedTime).getTime(), created: new Date(revision.modifiedTime).getTime(),
})) }));
.sort((revision1, revision2) => revision2.created - revision1.created);
}, },
async getRevisionContent(token, fileId, revisionId) { async loadFileRevision() {
const syncData = Provider.getContentSyncData(fileId); // Revisions are already loaded
const content = await googleHelper.downloadAppDataFileRevision(token, syncData.id, revisionId); return false;
},
async getFileRevisionContent({ token, contentSyncData, revisionId }) {
const content = await googleHelper
.downloadAppDataFileRevision(token, contentSyncData.id, revisionId);
return JSON.parse(content); return JSON.parse(content);
}, },
}); });

View File

@ -6,6 +6,7 @@ import workspaceSvc from '../workspaceSvc';
export default new Provider({ export default new Provider({
id: 'googleDrive', id: 'googleDrive',
name: 'Google Drive',
getToken({ sub }) { getToken({ sub }) {
const token = store.getters['data/googleTokensBySub'][sub]; const token = store.getters['data/googleTokensBySub'][sub];
return token && token.isDrive ? token : null; return token && token.isDrive ? token : null;
@ -190,4 +191,26 @@ export default new Provider({
} }
return location; 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({ export default new Provider({
id: 'googleDriveWorkspace', id: 'googleDriveWorkspace',
name: 'Google Drive',
getToken() { getToken() {
return store.getters['workspace/syncToken']; return store.getters['workspace/syncToken'];
}, },
@ -361,12 +362,16 @@ export default new Provider({
ifNotTooLate, ifNotTooLate,
}); });
} }
// Build sync data
// Build sync data to save
return { return {
id: file.id, syncData: {
itemId: item.id, id: file.id,
type: item.type, parentIds: file.parents,
hash: item.hash, itemId: item.id,
type: item.type,
hash: item.hash,
},
}; };
}, },
async removeWorkspaceItem({ syncData, ifNotTooLate }) { async removeWorkspaceItem({ syncData, ifNotTooLate }) {
@ -449,6 +454,7 @@ export default new Provider({
// Create file sync data // Create file sync data
newFileSyncData = { newFileSyncData = {
id: gdriveFile.id, id: gdriveFile.id,
parentIds: gdriveFile.parents,
itemId: file.id, itemId: file.id,
type: file.type, type: file.type,
hash: file.hash, hash: file.hash,
@ -495,25 +501,32 @@ export default new Provider({
return { return {
syncData: { syncData: {
id: file.id, id: file.id,
parentIds: file.parents,
itemId: item.id, itemId: item.id,
type: item.type, type: item.type,
hash: item.hash, hash: item.hash,
}, },
}; };
}, },
async listRevisions(token, fileId) { async listFileRevisions({ token, fileSyncData }) {
const syncData = Provider.getContentSyncData(fileId); const revisions = await googleHelper.getFileRevisions(token, fileSyncData.id);
const revisions = await googleHelper.getFileRevisions(token, syncData.id);
return revisions.map(revision => ({ return revisions.map(revision => ({
id: revision.id, id: revision.id,
sub: revision.lastModifyingUser && revision.lastModifyingUser.permissionId, sub: `go:${revision.lastModifyingUser.permissionId}`,
created: new Date(revision.modifiedTime).getTime(), created: new Date(revision.modifiedTime).getTime(),
})) }));
.sort((revision1, revision2) => revision2.created - revision1.created);
}, },
async getRevisionContent(token, fileId, revisionId) { async loadFileRevision() {
const syncData = Provider.getContentSyncData(fileId); // Revision are already loaded
const content = await googleHelper.downloadFileRevision(token, syncData.id, revisionId); return false;
return Provider.parseContent(content, `${fileId}/content`); },
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 * http://docs.couchdb.org/en/2.1.1/api/document/common.html#delete--db-docid
*/ */
async removeDocument(token, documentId, rev) { async removeDocument(token, documentId, rev) {
if (!documentId) {
// Prevent from deleting the whole database
throw new Error('Missing document ID');
}
return request(token, { return request(token, {
method: 'DELETE', method: 'DELETE',
path: documentId, path: documentId,

View File

@ -11,14 +11,14 @@ const getAppKey = (fullAccess) => {
const httpHeaderSafeJson = args => args && JSON.stringify(args) const httpHeaderSafeJson = args => args && JSON.stringify(args)
.replace(/[\u007f-\uffff]/g, c => `\\u${`000${c.charCodeAt(0).toString(16)}`.slice(-4)}`); .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, ...options,
headers: { headers: {
...options.headers || {}, ...options.headers || {},
'Content-Type': options.body && (typeof options.body === 'string' 'Content-Type': options.body && (typeof options.body === 'string'
? 'application/octet-stream' : 'application/json; charset=utf-8'), ? 'application/octet-stream' : 'application/json; charset=utf-8'),
'Dropbox-API-Arg': httpHeaderSafeJson(args), 'Dropbox-API-Arg': httpHeaderSafeJson(args),
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
}); });
@ -64,6 +64,28 @@ export default {
return this.startOauth2(fullAccess); 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 * 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 * https://www.dropbox.com/developers/chooser
*/ */

View File

@ -95,11 +95,14 @@ export default {
t: Date.now(), // Prevent from caching t: Date.now(), // Prevent from caching
}, },
})).body; })).body;
// Add user info to the store
store.commit('userInfo/addItem', { store.commit('userInfo/addItem', {
id: `gh:${user.id}`, id: `gh:${user.id}`,
name: user.login, name: user.login,
imageUrl: user.avatar_url || '', imageUrl: user.avatar_url || '',
}); });
return user; return user;
}, },
@ -263,4 +266,35 @@ export default {
} }
return result.content; 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({ export default new Provider({
id: 'wordpress', id: 'wordpress',
name: 'WordPress',
getToken(location) { getToken(location) {
return store.getters['data/wordpressTokensBySub'][location.sub]; return store.getters['data/wordpressTokensBySub'][location.sub];
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
import providerRegistry from '../services/providers/common/providerRegistry';
const defaultTimeout = 5000; const defaultTimeout = 5000;
export default { export default {
@ -34,7 +36,8 @@ export default {
} else if (error.status) { } else if (error.status) {
const location = rootState.queue.currentLocation; const location = rootState.queue.currentLocation;
if (location.providerId) { 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 { } else {
item.content = `HTTP error ${error.status}.`; item.content = `HTTP error ${error.status}.`;
} }

View File

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

View File

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

View File

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