New workspace and sync management modals. UI enhancements

This commit is contained in:
Benoit Schweblin 2018-07-04 00:41:24 +01:00
parent 7a87015af1
commit a1673d3e87
107 changed files with 1210 additions and 889 deletions

View File

@ -21,7 +21,7 @@ import syncSvc from '../services/syncSvc';
import networkSvc from '../services/networkSvc'; import networkSvc from '../services/networkSvc';
import sponsorSvc from '../services/sponsorSvc'; import sponsorSvc from '../services/sponsorSvc';
import tempFileSvc from '../services/tempFileSvc'; import tempFileSvc from '../services/tempFileSvc';
import './common/globals'; import './common/vueGlobals';
const themeClasses = { const themeClasses = {
light: ['app--light'], light: ['app--light'],
@ -53,7 +53,7 @@ export default {
this.ready = true; this.ready = true;
tempFileSvc.setReady(); tempFileSvc.setReady();
} catch (err) { } catch (err) {
if (err && err.message !== 'reload') { if (err && err.message !== 'RELOAD') {
console.error(err); // eslint-disable-line no-console console.error(err); // eslint-disable-line no-console
this.$store.dispatch('notification/error', err); this.$store.dispatch('notification/error', err);
} }

View File

@ -4,7 +4,7 @@
<script> <script>
import Prism from 'prismjs'; import Prism from 'prismjs';
import cledit from '../services/cledit'; import cledit from '../services/editor/cledit';
export default { export default {
props: ['value', 'lang', 'disabled'], props: ['value', 'lang', 'disabled'],

View File

@ -19,7 +19,7 @@
<script> <script>
import { mapMutations, mapActions } from 'vuex'; import { mapMutations, mapActions } from 'vuex';
import fileSvc from '../services/fileSvc'; import workspaceSvc from '../services/workspaceSvc';
import explorerSvc from '../services/explorerSvc'; import explorerSvc from '../services/explorerSvc';
export default { export default {
@ -102,10 +102,10 @@ export default {
if (!cancel && !newChildNode.isNil && newChildNode.item.name) { if (!cancel && !newChildNode.isNil && newChildNode.item.name) {
try { try {
if (newChildNode.isFolder) { if (newChildNode.isFolder) {
const item = await fileSvc.storeItem(newChildNode.item); const item = await workspaceSvc.storeItem(newChildNode.item);
this.select(item.id); this.select(item.id);
} else { } else {
const item = await fileSvc.createFile(newChildNode.item); const item = await workspaceSvc.createFile(newChildNode.item);
this.select(item.id); this.select(item.id);
} }
} catch (e) { } catch (e) {
@ -120,7 +120,7 @@ export default {
this.setEditingId(null); this.setEditingId(null);
if (!cancel && item.id && value) { if (!cancel && item.id && value) {
try { try {
await fileSvc.storeItem({ await workspaceSvc.storeItem({
...item, ...item,
name: value, name: value,
}); });
@ -147,7 +147,7 @@ export default {
&& !targetNode.isNil && !targetNode.isNil
&& sourceNode.item.id !== targetNode.item.id && sourceNode.item.id !== targetNode.item.id
) { ) {
fileSvc.storeItem({ workspaceSvc.storeItem({
...sourceNode.item, ...sourceNode.item,
parentId: targetNode.item.id, parentId: targetNode.item.id,
}); });

View File

@ -34,7 +34,7 @@
<script> <script>
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import editorSvc from '../services/editorSvc'; import editorSvc from '../services/editorSvc';
import cledit from '../services/cledit'; import cledit from '../services/editor/cledit';
import store from '../store'; import store from '../store';
import EditorClassApplier from './common/EditorClassApplier'; import EditorClassApplier from './common/EditorClassApplier';

View File

@ -5,7 +5,7 @@
<div class="modal__content" v-html="simpleModal.contentHtml(config)"></div> <div class="modal__content" v-html="simpleModal.contentHtml(config)"></div>
<div class="modal__button-bar"> <div class="modal__button-bar">
<button class="button" v-if="simpleModal.rejectText" @click="config.reject()">{{simpleModal.rejectText}}</button> <button class="button" v-if="simpleModal.rejectText" @click="config.reject()">{{simpleModal.rejectText}}</button>
<button class="button" v-if="simpleModal.resolveText" @click="config.resolve()">{{simpleModal.resolveText}}</button> <button class="button button--resolve" v-if="simpleModal.resolveText" @click="config.resolve()">{{simpleModal.resolveText}}</button>
</div> </div>
</modal-inner> </modal-inner>
</div> </div>
@ -250,7 +250,7 @@ export default {
} }
.modal__sub-title { .modal__sub-title {
opacity: 0.5; opacity: 0.6;
font-size: 0.75rem; font-size: 0.75rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
@ -278,8 +278,10 @@ export default {
} }
.modal__button-bar { .modal__button-bar {
margin-top: 1.75rem; margin-top: 2rem;
text-align: right; display: flex;
flex-direction: row;
justify-content: flex-end;
} }
.form-entry { .form-entry {

View File

@ -56,7 +56,7 @@ import tempFileSvc from '../services/tempFileSvc';
import utils from '../services/utils'; import utils from '../services/utils';
import pagedownButtons from '../data/pagedownButtons'; import pagedownButtons from '../data/pagedownButtons';
import store from '../store'; import store from '../store';
import fileSvc from '../services/fileSvc'; import workspaceSvc from '../services/workspaceSvc';
// According to mousetrap // According to mousetrap
const mod = /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'Meta' : 'Ctrl'; const mod = /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'Meta' : 'Ctrl';
@ -200,7 +200,7 @@ export default {
this.title = this.$store.getters['file/current'].name; this.title = this.$store.getters['file/current'].name;
if (title) { if (title) {
try { try {
await fileSvc.storeItem({ await workspaceSvc.storeItem({
...this.$store.getters['file/current'], ...this.$store.getters['file/current'],
name: title, name: title,
}); });

View File

@ -13,7 +13,7 @@
</div> </div>
<div class="side-bar__inner"> <div class="side-bar__inner">
<main-menu v-if="panel === 'menu'"></main-menu> <main-menu v-if="panel === 'menu'"></main-menu>
<workspaces-menu v-if="panel === 'workspaces'"></workspaces-menu> <workspaces-menu v-else-if="panel === 'workspaces'"></workspaces-menu>
<sync-menu v-else-if="panel === 'sync'"></sync-menu> <sync-menu v-else-if="panel === 'sync'"></sync-menu>
<publish-menu v-else-if="panel === 'publish'"></publish-menu> <publish-menu v-else-if="panel === 'publish'"></publish-menu>
<history-menu v-else-if="panel === 'history'"></history-menu> <history-menu v-else-if="panel === 'history'"></history-menu>
@ -75,7 +75,11 @@ export default {
}), }),
computed: { computed: {
panel() { panel() {
return !this.$store.state.light && this.$store.getters['data/layoutSettings'].sideBarPanel; if (this.$store.state.light) {
return null; // No menu in light mode
}
const result = this.$store.getters['data/layoutSettings'].sideBarPanel;
return panelNames[result] ? result : 'menu';
}, },
panelName() { panelName() {
return panelNames[this.panel]; return panelNames[this.panel];
@ -164,7 +168,7 @@ export default {
padding: 10px; padding: 10px;
margin: -10px -10px 10px; margin: -10px -10px 10px;
background-color: $info-bg; background-color: $info-bg;
font-size: 0.95em; font-size: 0.9em;
p { p {
margin: 10px; margin: 10px;

View File

@ -64,7 +64,7 @@ export default {
const updateMaskY = () => { const updateMaskY = () => {
const scrollPosition = editorSvc.getScrollPosition(); const scrollPosition = editorSvc.getScrollPosition();
if (scrollPosition) { if (scrollPosition) {
const sectionDesc = editorSvc.previewCtx.sectionDescList[scrollPosition.sectionIdx]; const sectionDesc = editorSvc.previewCtxMeasured.sectionDescList[scrollPosition.sectionIdx];
this.maskY = sectionDesc.tocDimension.startOffset + this.maskY = sectionDesc.tocDimension.startOffset +
(scrollPosition.posInSection * sectionDesc.tocDimension.height); (scrollPosition.posInSection * sectionDesc.tocDimension.height);
} }

View File

@ -1,4 +1,4 @@
import cledit from '../../services/cledit'; import cledit from '../../services/editor/cledit';
import editorSvc from '../../services/editorSvc'; import editorSvc from '../../services/editorSvc';
import utils from '../../services/utils'; import utils from '../../services/utils';

View File

@ -1,4 +1,4 @@
import cledit from '../../services/cledit'; import cledit from '../../services/editor/cledit';
import editorSvc from '../../services/editorSvc'; import editorSvc from '../../services/editorSvc';
import utils from '../../services/utils'; import utils from '../../services/utils';

View File

@ -1,4 +1,5 @@
import Vue from 'vue'; import Vue from 'vue';
import Clipboard from 'clipboard';
import timeSvc from '../../services/timeSvc'; import timeSvc from '../../services/timeSvc';
import store from '../../store'; import store from '../../store';
@ -32,10 +33,43 @@ Vue.directive('show', {
}, },
}); });
const setElTitle = (el, title) => {
el.title = title;
el.setAttribute('aria-label', title);
};
Vue.directive('title', { Vue.directive('title', {
bind(el, { value }) { bind(el, { value }) {
el.title = value; setElTitle(el, value);
el.setAttribute('aria-label', value); },
update(el, { value, oldValue }) {
if (value !== oldValue) {
setElTitle(el, value);
}
},
});
// Clipboard directive
const createClipboard = (el, value) => {
el.seClipboard = new Clipboard(el, { text: () => value });
};
const destroyClipboard = (el) => {
if (el.seClipboard) {
el.seClipboard.destroy();
el.seClipboard = null;
}
};
Vue.directive('clipboard', {
bind(el, { value }) {
createClipboard(el, value);
},
update(el, { value, oldValue }) {
if (value !== oldValue) {
destroyClipboard(el);
createClipboard(el, value);
}
},
unbind(el) {
destroyClipboard(el);
}, },
}); });

View File

@ -28,7 +28,7 @@
</template> </template>
<script> <script>
import { mapState, mapGetters, mapMutations } from 'vuex'; import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import editorSvc from '../../services/editorSvc'; import editorSvc from '../../services/editorSvc';
import animationSvc from '../../services/animationSvc'; import animationSvc from '../../services/animationSvc';
import markdownConversionSvc from '../../services/markdownConversionSvc'; import markdownConversionSvc from '../../services/markdownConversionSvc';
@ -67,6 +67,9 @@ export default {
...mapMutations('discussion', [ ...mapMutations('discussion', [
'setCurrentDiscussionId', 'setCurrentDiscussionId',
]), ]),
...mapActions('notification', [
'info',
]),
goToDiscussion(discussionId = this.currentDiscussionId) { goToDiscussion(discussionId = this.currentDiscussionId) {
this.setCurrentDiscussionId(discussionId); this.setCurrentDiscussionId(discussionId);
const layoutSettings = this.$store.getters['data/layoutSettings']; const layoutSettings = this.$store.getters['data/layoutSettings'];
@ -75,7 +78,7 @@ export default {
? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end) ? editorSvc.clEditor.selectionMgr.getCoordinates(discussion.end)
: editorSvc.getPreviewOffsetCoordinates(editorSvc.getPreviewOffset(discussion.end)); : editorSvc.getPreviewOffsetCoordinates(editorSvc.getPreviewOffset(discussion.end));
if (!coordinates) { if (!coordinates) {
this.$store.dispatch('notification/info', "Discussion can't be located in the file."); this.info("Discussion can't be located in the file.");
} else { } else {
const scrollerElt = layoutSettings.showEditor const scrollerElt = layoutSettings.showEditor
? editorSvc.editorElt.parentNode ? editorSvc.editorElt.parentNode

View File

@ -24,7 +24,7 @@
import { mapGetters, mapMutations, mapActions } from 'vuex'; import { mapGetters, mapMutations, mapActions } from 'vuex';
import Prism from 'prismjs'; import Prism from 'prismjs';
import UserImage from '../UserImage'; import UserImage from '../UserImage';
import cledit from '../../services/cledit'; import cledit from '../../services/editor/cledit';
import editorSvc from '../../services/editorSvc'; import editorSvc from '../../services/editorSvc';
import markdownConversionSvc from '../../services/markdownConversionSvc'; import markdownConversionSvc from '../../services/markdownConversionSvc';
import utils from '../../services/utils'; import utils from '../../services/utils';

View File

@ -12,18 +12,19 @@
</menu-entry> </menu-entry>
<menu-entry @click.native="exportPdf"> <menu-entry @click.native="exportPdf">
<icon-download slot="icon"></icon-download> <icon-download slot="icon"></icon-download>
<div><div class="menu-entry__label">sponsor</div> Export as PDF</div> <div><div class="menu-entry__label" :class="{'menu-entry__label--warning': !isSponsor}">sponsor</div> Export as PDF</div>
<span>Produce a PDF from an HTML template.</span> <span>Produce a PDF from an HTML template.</span>
</menu-entry> </menu-entry>
<menu-entry @click.native="exportPandoc"> <menu-entry @click.native="exportPandoc">
<icon-download slot="icon"></icon-download> <icon-download slot="icon"></icon-download>
<div><div class="menu-entry__label">sponsor</div> Export with Pandoc</div> <div><div class="menu-entry__label" :class="{'menu-entry__label--warning': !isSponsor}">sponsor</div> Export with Pandoc</div>
<span>Convert to PDF, Word, EPUB...</span> <span>Convert to PDF, Word, EPUB...</span>
</menu-entry> </menu-entry>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex';
import MenuEntry from './common/MenuEntry'; import MenuEntry from './common/MenuEntry';
import exportSvc from '../../services/exportSvc'; import exportSvc from '../../services/exportSvc';
@ -31,6 +32,7 @@ export default {
components: { components: {
MenuEntry, MenuEntry,
}, },
computed: mapGetters(['isSponsor']),
methods: { methods: {
exportMarkdown() { exportMarkdown() {
const currentFile = this.$store.getters['file/current']; const currentFile = this.$store.getters['file/current'];

View File

@ -169,7 +169,7 @@ export default {
created() { created() {
// Find the workspace provider // Find the workspace provider
const workspace = this.$store.getters['workspace/currentWorkspace']; const workspace = this.$store.getters['workspace/currentWorkspace'];
this.workspaceProvider = providerRegistry.providers[workspace.providerId]; this.workspaceProvider = providerRegistry.providersById[workspace.providerId];
// Watch file changes // Watch file changes
this.$watch( this.$watch(

View File

@ -29,7 +29,7 @@ import htmlSanitizer from '../../libs/htmlSanitizer';
import MenuEntry from './common/MenuEntry'; import MenuEntry from './common/MenuEntry';
import Provider from '../../services/providers/common/Provider'; import Provider from '../../services/providers/common/Provider';
import store from '../../store'; import store from '../../store';
import fileSvc from '../../services/fileSvc'; import workspaceSvc from '../../services/workspaceSvc';
const turndownService = new TurndownService(store.getters['data/computedSettings'].turndown); const turndownService = new TurndownService(store.getters['data/computedSettings'].turndown);
@ -56,7 +56,7 @@ export default {
async onImportMarkdown(evt) { async onImportMarkdown(evt) {
const file = evt.target.files[0]; const file = evt.target.files[0];
const content = await readFile(file); const content = await readFile(file);
const item = await fileSvc.createFile({ const item = await workspaceSvc.createFile({
...Provider.parseContent(content), ...Provider.parseContent(content),
name: file.name, name: file.name,
}); });
@ -67,7 +67,7 @@ export default {
const content = await readFile(file); const content = await readFile(file);
const sanitizedContent = htmlSanitizer.sanitizeHtml(content) const sanitizedContent = htmlSanitizer.sanitizeHtml(content)
.replace(/&#160;/g, ' '); // Replace non-breaking spaces with classic spaces .replace(/&#160;/g, ' '); // Replace non-breaking spaces with classic spaces
const item = await fileSvc.createFile({ const item = await workspaceSvc.createFile({
...Provider.parseContent(turndownService.turndown(sanitizedContent)), ...Provider.parseContent(turndownService.turndown(sanitizedContent)),
name: file.name, name: file.name,
}); });

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="side-bar__panel side-bar__panel--menu"> <div class="side-bar__panel side-bar__panel--menu">
<div class="menu-info-entries"> <div class="side-bar__info">
<div class="menu-entry menu-entry--info flex flex--row flex--align-center" v-if="loginToken"> <div class="menu-entry menu-entry--info flex flex--row flex--align-center" v-if="loginToken">
<div class="menu-entry__icon menu-entry__icon--image"> <div class="menu-entry__icon menu-entry__icon--image">
<user-image :user-id="userId"></user-image> <user-image :user-id="userId"></user-image>
@ -11,7 +11,15 @@
<div class="menu-entry__icon menu-entry__icon--image"> <div class="menu-entry__icon menu-entry__icon--image">
<icon-provider :provider-id="currentWorkspace.providerId"></icon-provider> <icon-provider :provider-id="currentWorkspace.providerId"></icon-provider>
</div> </div>
<span><b>{{currentWorkspace.name}}</b> synced.</span> <span v-if="currentWorkspace.providerId === 'googleDriveAppData'">
<b>{{currentWorkspace.name}}</b> synced with your Google Drive app data folder.
</span>
<span v-else-if="currentWorkspace.providerId === 'googleDriveWorkspace'">
<b>{{currentWorkspace.name}}</b> synced with a <a :href="currentWorkspaceUrl" target="_blank">Google Drive folder</a>.
</span>
<span v-else-if="currentWorkspace.providerId === 'couchdbWorkspace'">
<b>{{currentWorkspace.name}}</b> synced with a <a :href="currentWorkspaceUrl" target="_blank">CouchDB database</a>.
</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>
<div class="menu-entry__icon menu-entry__icon--disabled"> <div class="menu-entry__icon menu-entry__icon--disabled">
@ -27,7 +35,7 @@
</menu-entry> </menu-entry>
<menu-entry @click.native="setPanel('workspaces')"> <menu-entry @click.native="setPanel('workspaces')">
<icon-database slot="icon"></icon-database> <icon-database slot="icon"></icon-database>
<div>Workspaces</div> <div><div class="menu-entry__label menu-entry__label--warning">new</div> Workspaces</div>
<span>Switch to another workspace.</span> <span>Switch to another workspace.</span>
</menu-entry> </menu-entry>
<hr> <hr>
@ -83,6 +91,7 @@
<script> <script>
import { mapGetters, mapActions } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import MenuEntry from './common/MenuEntry'; import MenuEntry from './common/MenuEntry';
import providerRegistry from '../../services/providers/common/providerRegistry';
import UserImage from '../UserImage'; import UserImage from '../UserImage';
import googleHelper from '../../services/providers/helpers/googleHelper'; import googleHelper from '../../services/providers/helpers/googleHelper';
import syncSvc from '../../services/syncSvc'; import syncSvc from '../../services/syncSvc';
@ -99,6 +108,10 @@ export default {
'loginToken', 'loginToken',
'userId', 'userId',
]), ]),
currentWorkspaceUrl() {
const provider = providerRegistry.providersById[this.currentWorkspace.providerId];
return provider.getWorkspaceLocationUrl(this.currentWorkspace);
},
}, },
methods: { methods: {
...mapActions('data', { ...mapActions('data', {

View File

@ -16,7 +16,7 @@
</menu-entry> </menu-entry>
<menu-entry @click.native="managePublish"> <menu-entry @click.native="managePublish">
<icon-view-list slot="icon"></icon-view-list> <icon-view-list slot="icon"></icon-view-list>
<div>File publication</div> <div><div class="menu-entry__label menu-entry__label--count">{{locationCount}}</div> File publication</div>
<span>Manage current file publication locations.</span> <span>Manage current file publication locations.</span>
</menu-entry> </menu-entry>
</div> </div>
@ -113,8 +113,7 @@ import zendeskHelper from '../../services/providers/helpers/zendeskHelper';
import publishSvc from '../../services/publishSvc'; import publishSvc from '../../services/publishSvc';
import store from '../../store'; import store from '../../store';
const tokensToArray = (tokens, filter = () => true) => Object.keys(tokens) const tokensToArray = (tokens, filter = () => true) => Object.values(tokens)
.map(sub => tokens[sub])
.filter(token => filter(token)) .filter(token => filter(token))
.sort((token1, token2) => token1.name.localeCompare(token2.name)); .sort((token1, token2) => token1.name.localeCompare(token2.name));
@ -142,6 +141,9 @@ export default {
...mapGetters('publishLocation', { ...mapGetters('publishLocation', {
publishLocations: 'current', publishLocations: 'current',
}), }),
locationCount() {
return Object.keys(this.publishLocations).length;
},
currentFileName() { currentFileName() {
return this.$store.getters['file/current'].name; return this.$store.getters['file/current'].name;
}, },
@ -178,8 +180,10 @@ export default {
publishSvc.requestPublish(); publishSvc.requestPublish();
} }
}, },
managePublish() { async managePublish() {
return this.$store.dispatch('modal/open', 'publishManagement'); try {
await this.$store.dispatch('modal/open', 'publishManagement');
} catch (e) { /* cancel */ }
}, },
async addGoogleDriveAccount() { async addGoogleDriveAccount() {
try { try {

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="side-bar__panel side-bar__panel--menu"> <div class="side-bar__panel side-bar__panel--menu">
<div class="side-bar__info" v-if="isCurrentTemp"> <div class="side-bar__info" v-if="isCurrentTemp">
<p><b>{{currentFileName}}</b> can not be synchronized as it's a temporary file.</p> <p><b>{{currentFileName}}</b> can not be synced as it's a temporary file.</p>
</div> </div>
<div v-else> <div v-else>
<div class="side-bar__info" v-if="noToken"> <div class="side-bar__info" v-if="noToken">
@ -16,7 +16,7 @@
</menu-entry> </menu-entry>
<menu-entry @click.native="manageSync"> <menu-entry @click.native="manageSync">
<icon-view-list slot="icon"></icon-view-list> <icon-view-list slot="icon"></icon-view-list>
<div>File synchronization</div> <div><div class="menu-entry__label menu-entry__label--count">{{locationCount}}</div> File synchronization</div>
<span>Manage current file synchronized locations.</span> <span>Manage current file synchronized locations.</span>
</menu-entry> </menu-entry>
</div> </div>
@ -91,8 +91,7 @@ import githubProvider from '../../services/providers/githubProvider';
import syncSvc from '../../services/syncSvc'; import syncSvc from '../../services/syncSvc';
import store from '../../store'; import store from '../../store';
const tokensToArray = (tokens, filter = () => true) => Object.keys(tokens) const tokensToArray = (tokens, filter = () => true) => Object.values(tokens)
.map(sub => tokens[sub])
.filter(token => filter(token)) .filter(token => filter(token))
.sort((token1, token2) => token1.name.localeCompare(token2.name)); .sort((token1, token2) => token1.name.localeCompare(token2.name));
@ -116,8 +115,11 @@ export default {
'isCurrentTemp', 'isCurrentTemp',
]), ]),
...mapGetters('syncLocation', { ...mapGetters('syncLocation', {
syncLocations: 'current', syncLocations: 'currentWithWorkspaceSyncLocation',
}), }),
locationCount() {
return Object.keys(this.syncLocations).length;
},
currentFileName() { currentFileName() {
return this.$store.getters['file/current'].name; return this.$store.getters['file/current'].name;
}, },
@ -142,8 +144,10 @@ export default {
syncSvc.requestSync(); syncSvc.requestSync();
} }
}, },
manageSync() { async manageSync() {
return this.$store.dispatch('modal/open', 'syncManagement'); try {
await this.$store.dispatch('modal/open', 'syncManagement');
} catch (e) { /* cancel */ }
}, },
async addGoogleDriveAccount() { async addGoogleDriveAccount() {
try { try {
@ -180,16 +184,12 @@ export default {
async saveGoogleDrive(token) { async saveGoogleDrive(token) {
try { try {
await openSyncModal(token, 'googleDriveSave'); await openSyncModal(token, 'googleDriveSave');
} catch (e) { } catch (e) { /* cancel */ }
// Cancel
}
}, },
async saveDropbox(token) { async saveDropbox(token) {
try { try {
await openSyncModal(token, 'dropboxSave'); await openSyncModal(token, 'dropboxSave');
} catch (e) { } catch (e) { /* cancel */ }
// Cancel
}
}, },
async openGithub(token) { async openGithub(token) {
try { try {
@ -201,23 +201,17 @@ export default {
'queue/enqueue', 'queue/enqueue',
() => githubProvider.openFile(token, syncLocation), () => githubProvider.openFile(token, syncLocation),
); );
} catch (e) { } catch (e) { /* cancel */ }
// Cancel
}
}, },
async saveGithub(token) { async saveGithub(token) {
try { try {
await openSyncModal(token, 'githubSave'); await openSyncModal(token, 'githubSave');
} catch (e) { } catch (e) { /* cancel */ }
// Cancel
}
}, },
async saveGist(token) { async saveGist(token) {
try { try {
await openSyncModal(token, 'gistSync'); await openSyncModal(token, 'gistSync');
} catch (e) { } catch (e) { /* cancel */ }
// Cancel
}
}, },
}, },
}; };

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="side-bar__panel side-bar__panel--menu"> <div class="side-bar__panel side-bar__panel--menu">
<div class="workspace" v-for="(workspace, id) in sanitizedWorkspacesById" :key="id"> <div class="workspace" v-for="(workspace, id) in workspacesById" :key="id">
<menu-entry :href="workspace.url" target="_blank"> <menu-entry :href="workspace.url" target="_blank">
<icon-provider slot="icon" :provider-id="workspace.providerId"></icon-provider> <icon-provider slot="icon" :provider-id="workspace.providerId"></icon-provider>
<div class="workspace__name"><div class="menu-entry__label" v-if="currentWorkspace === workspace">current</div>{{workspace.name}}</div> <div class="workspace__name"><div class="menu-entry__label" v-if="currentWorkspace === workspace">current</div>{{workspace.name}}</div>
@ -9,19 +9,22 @@
<hr> <hr>
<menu-entry @click.native="addCouchdbWorkspace"> <menu-entry @click.native="addCouchdbWorkspace">
<icon-provider slot="icon" provider-id="couchdbWorkspace"></icon-provider> <icon-provider slot="icon" provider-id="couchdbWorkspace"></icon-provider>
<span>Add CouchDB workspace</span> <div>CouchDB workspace</div>
<span>Add a workspace synced with your CouchDB database.</span>
</menu-entry> </menu-entry>
<menu-entry @click.native="addGithubWorkspace"> <menu-entry @click.native="addGithubWorkspace">
<icon-provider slot="icon" provider-id="githubWorkspace"></icon-provider> <icon-provider slot="icon" provider-id="githubWorkspace"></icon-provider>
<span>Add GitHub workspace</span> <div>GitHub workspace</div>
<span>Add a workspace synced with a GitHub repository.</span>
</menu-entry> </menu-entry>
<menu-entry @click.native="addGoogleDriveWorkspace"> <menu-entry @click.native="addGoogleDriveWorkspace">
<icon-provider slot="icon" provider-id="googleDriveWorkspace"></icon-provider> <icon-provider slot="icon" provider-id="googleDriveWorkspace"></icon-provider>
<span>Add Google Drive workspace</span> <div>Google Drive workspace</div>
<span>Add a workspace synced with a Google Drive folder.</span>
</menu-entry> </menu-entry>
<menu-entry @click.native="manageWorkspaces"> <menu-entry @click.native="manageWorkspaces">
<icon-database slot="icon"></icon-database> <icon-database slot="icon"></icon-database>
<span>Manage workspaces</span> <span><div class="menu-entry__label menu-entry__label--count">{{workspaceCount}}</div> Manage workspaces</span>
</menu-entry> </menu-entry>
</div> </div>
</template> </template>
@ -36,12 +39,13 @@ export default {
MenuEntry, MenuEntry,
}, },
computed: { computed: {
...mapGetters('data', [
'sanitizedWorkspaceById',
]),
...mapGetters('workspace', [ ...mapGetters('workspace', [
'workspacesById',
'currentWorkspace', 'currentWorkspace',
]), ]),
workspaceCount() {
return Object.keys(this.workspacesById).length;
},
}, },
methods: { methods: {
async addCouchdbWorkspace() { async addCouchdbWorkspace() {

View File

@ -24,9 +24,13 @@
span { span {
display: inline-block; display: inline-block;
font-size: 0.75rem; font-size: 0.75rem;
opacity: 0.5; opacity: 0.6;
line-height: 1.3; line-height: 1.3;
.menu-entry__label {
opacity: 1;
}
span { span {
display: inline; display: inline;
opacity: 1; opacity: 1;
@ -34,12 +38,6 @@
} }
} }
.menu-info-entries {
padding: 10px;
margin: -10px -10px 10px;
background-color: rgba(255, 255, 255, 0.2);
}
.menu-entry--info { .menu-entry--info {
padding-top: 3px; padding-top: 3px;
padding-bottom: 3px; padding-bottom: 3px;
@ -70,10 +68,22 @@
float: right; float: right;
font-size: 0.6rem; font-size: 0.6rem;
font-weight: 600; font-weight: 600;
padding: 0.05em 0.25em; line-height: 1;
background-color: darken($error-color, 10); padding: 0.15em 0.25em;
background-color: #fff;
border-radius: 3px; border-radius: 3px;
opacity: 0.6;
}
.menu-entry__label--warning {
color: #fff; color: #fff;
background-color: darken($error-color, 10);
opacity: 1;
}
.menu-entry__label--count {
font-size: 0.75rem;
font-weight: 400;
} }
.menu-entry__text { .menu-entry__text {

View File

@ -24,7 +24,7 @@
<a target="_blank" href="privacy_policy.html">Privacy Policy</a> <a target="_blank" href="privacy_policy.html">Privacy Policy</a>
</div> </div>
<div class="modal__button-bar"> <div class="modal__button-bar">
<button class="button" @click="config.resolve()">Close</button> <button class="button button--resolve" @click="config.resolve()">Close</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -78,7 +78,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -14,15 +14,15 @@
</form-entry> </form-entry>
</div> </div>
<div class="modal__button-bar"> <div class="modal__button-bar">
<button class="button button--copy">Copy to clipboard</button> <button class="button button--copy" v-clipboard="result" @click="info('HTML copied to clipboard!')">Copy</button>
<button class="button" @click="config.reject()">Cancel</button> <button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>
<script> <script>
import Clipboard from 'clipboard'; import { mapActions } from 'vuex';
import exportSvc from '../../services/exportSvc'; import exportSvc from '../../services/exportSvc';
import modalTemplate from './common/modalTemplate'; import modalTemplate from './common/modalTemplate';
@ -48,14 +48,11 @@ export default modalTemplate({
}, { }, {
immediate: true, immediate: true,
}); });
this.clipboard = new Clipboard(this.$el.querySelector('.button--copy'), {
text: () => this.result,
});
},
destroyed() {
this.clipboard.destroy();
}, },
methods: { methods: {
...mapActions('notification', [
'info',
]),
resolve() { resolve() {
const { config } = this; const { config } = this;
const currentFile = this.$store.getters['file/current']; const currentFile = this.$store.getters['file/current'];

View File

@ -17,7 +17,7 @@
</div> </div>
<div class="modal__button-bar"> <div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button> <button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>
@ -36,9 +36,8 @@ export default modalTemplate({
}), }),
computed: { computed: {
googlePhotosTokens() { googlePhotosTokens() {
const googleTokens = this.$store.getters['data/googleTokensBySub']; const googleTokensBySub = this.$store.getters['data/googleTokensBySub'];
return Object.entries(googleTokens) return Object.values(googleTokensBySub)
.map(([, token]) => token)
.filter(token => token.isPhotos) .filter(token => token.isPhotos)
.sort((token1, token2) => token1.name.localeCompare(token2.name)); .sort((token1, token2) => token1.name.localeCompare(token2.name));
}, },

View File

@ -8,7 +8,7 @@
</div> </div>
<div class="modal__button-bar"> <div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button> <button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -20,7 +20,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -15,7 +15,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -1,6 +1,9 @@
<template> <template>
<modal-inner class="modal__inner-1--publish-management" aria-label="Manage publication locations"> <modal-inner class="modal__inner-1--publish-management" aria-label="Manage publication locations">
<div class="modal__content"> <div class="modal__content">
<div class="modal__image">
<icon-upload></icon-upload>
</div>
<p v-if="publishLocations.length"><b>{{currentFileName}}</b> is published to the following location(s):</p> <p v-if="publishLocations.length"><b>{{currentFileName}}</b> is published to the following location(s):</p>
<p v-else><b>{{currentFileName}}</b> is not published yet.</p> <p v-else><b>{{currentFileName}}</b> is not published yet.</p>
<div> <div>
@ -12,21 +15,21 @@
{{location.description}} {{location.description}}
</div> </div>
<div class="publish-entry__buttons flex flex--row flex--center"> <div class="publish-entry__buttons flex flex--row flex--center">
<a class="publish-entry__button button" :href="location.url" target="_blank"> <a class="publish-entry__button button" :href="location.url" target="_blank" v-title="'Open location'">
<icon-open-in-new></icon-open-in-new> <icon-open-in-new></icon-open-in-new>
</a> </a>
<button class="publish-entry__button button" @click="remove(location)"> <button class="publish-entry__button button" @click="remove(location)" v-title="'Remove location'">
<icon-delete></icon-delete> <icon-delete></icon-delete>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="modal__info" v-if="publishLocations.length"> <div class="modal__info" v-if="publishLocations.length">
<b>Note:</b> Removing a synchronized location won't delete any file. <b>Tip:</b> Removing a location won't delete any file.
</div> </div>
</div> </div>
<div class="modal__button-bar"> <div class="modal__button-bar">
<button class="button" @click="config.resolve()">Close</button> <button class="button button--resolve" @click="config.resolve()">Close</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -25,7 +25,7 @@
</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>
<button class="button" @click="!error && config.resolve(strippedCustomSettings)">Ok</button> <button class="button button--resolve" @click="!error && config.resolve(strippedCustomSettings)">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -1,38 +1,53 @@
<template> <template>
<modal-inner class="modal__inner-1--sync-management" aria-label="Manage synchronized locations"> <modal-inner class="modal__inner-1--sync-management" aria-label="Manage synchronized locations">
<div class="modal__content"> <div class="modal__content">
<div class="modal__image">
<icon-sync></icon-sync>
</div>
<p v-if="syncLocations.length"><b>{{currentFileName}}</b> is synchronized with the following location(s):</p> <p v-if="syncLocations.length"><b>{{currentFileName}}</b> is synchronized with the following location(s):</p>
<p v-else><b>{{currentFileName}}</b> is not synchronized yet.</p> <p v-else><b>{{currentFileName}}</b> is not synchronized yet.</p>
<div> <div>
<div class="sync-entry flex flex--row flex--align-center" v-for="location in syncLocations" :key="location.id"> <div class="sync-entry flex flex--column" v-for="location in syncLocations" :key="location.id">
<div class="sync-entry__icon flex flex--column flex--center"> <div class="sync-entry__header flex flex--row flex--align-center">
<icon-provider :provider-id="location.providerId"></icon-provider> <div class="sync-entry__icon flex flex--column flex--center">
<icon-provider :provider-id="location.providerId"></icon-provider>
</div>
<div class="sync-entry__description">
{{location.description}}
</div>
<div class="sync-entry__buttons flex flex--row flex--center">
<button class="sync-entry__button button" @click="remove(location)" v-title="'Remove location'">
<icon-delete></icon-delete>
</button>
</div>
</div> </div>
<div class="sync-entry__description"> <div class="sync-entry__row flex flex--row flex--align-center">
{{location.description}} <div class="sync-entry__url">
</div> {{location.url || 'Workspace location'}}
<div class="sync-entry__buttons flex flex--row flex--center"> </div>
<a class="sync-entry__button button" :href="location.url" target="_blank"> <div class="sync-entry__buttons flex flex--row flex--center" v-if="location.url">
<icon-open-in-new></icon-open-in-new> <button class="sync-entry__button button" v-clipboard="location.url" @click="info('Location URL copied to clipboard!')" v-title="'Copy URL'">
</a> <icon-content-copy></icon-content-copy>
<button class="sync-entry__button button" @click="remove(location)"> </button>
<icon-delete></icon-delete> <a class="sync-entry__button button" v-if="location.url" :href="location.url" target="_blank" v-title="'Open location'">
</button> <icon-open-in-new></icon-open-in-new>
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal__info" v-if="syncLocations.length"> <div class="modal__info" v-if="syncLocations.length">
<b>Note:</b> Removing a synchronized location won't delete any file. <b>Tip:</b> Removing a location won't delete any file.
</div> </div>
</div> </div>
<div class="modal__button-bar"> <div class="modal__button-bar">
<button class="button" @click="config.resolve()">Close</button> <button class="button button--resolve" @click="config.resolve()">Close</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import ModalInner from './common/ModalInner'; import ModalInner from './common/ModalInner';
export default { export default {
@ -44,15 +59,22 @@ export default {
'config', 'config',
]), ]),
...mapGetters('syncLocation', { ...mapGetters('syncLocation', {
syncLocations: 'current', syncLocations: 'currentWithWorkspaceSyncLocation',
}), }),
currentFileName() { currentFileName() {
return this.$store.getters['file/current'].name; return this.$store.getters['file/current'].name;
}, },
}, },
methods: { methods: {
...mapActions('notification', [
'info',
]),
remove(location) { remove(location) {
this.$store.commit('syncLocation/deleteItem', location.id); if (location.id === 'main') {
this.info('This location can not be removed.');
} else {
this.$store.commit('syncLocation/deleteItem', location.id);
}
}, },
}, },
}; };
@ -66,44 +88,75 @@ export default {
} }
.sync-entry { .sync-entry {
padding: 0.5rem 0.25rem; margin: 1.5em 0;
border-bottom: 1px solid $hr-color; height: auto;
font-size: 17px;
line-height: 1.5;
}
&:last-child { $button-size: 30px;
border-bottom: none; $small-button-size: 22px;
}
.sync-entry__header {
line-height: $button-size;
}
.sync-entry__row {
margin-top: 1px;
padding-top: 1px;
border-top: 1px solid rgba(128, 128, 128, 0.15);
line-height: $small-button-size;
} }
.sync-entry__icon { .sync-entry__icon {
height: 30px; height: 22px;
width: 30px; width: 22px;
margin-right: 0.75rem; margin-right: 0.75rem;
flex: none; flex: none;
} }
.sync-entry__description { .sync-entry__description {
opacity: 0.5;
line-height: 1.4;
font-size: 0.9em;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.sync-entry__url {
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
opacity: 0.5;
font-size: 0.67em;
} }
.sync-entry__buttons { .sync-entry__buttons {
margin-left: 0.75rem; margin-left: 0.75rem;
.sync-entry__row & {
margin-left: 0.5rem;
}
} }
.sync-entry__button { .sync-entry__button {
width: 38px; width: $button-size;
height: 38px; height: $button-size;
padding: 6px; padding: 4px;
background-color: transparent; background-color: transparent;
opacity: 0.75; opacity: 0.75;
.sync-entry__row & {
width: $small-button-size;
height: $small-button-size;
padding: 4px;
}
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {
opacity: 1; opacity: 1;
background-color: rgba(0, 0, 0, 0.1);
} }
} }
</style> </style>

View File

@ -45,7 +45,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -1,39 +1,69 @@
<template> <template>
<modal-inner class="modal__inner-1--workspace-management" aria-label="Manage workspaces"> <modal-inner class="modal__inner-1--workspace-management" aria-label="Manage workspaces">
<div class="modal__content"> <div class="modal__content">
<div class="workspace-entry flex flex--row flex--align-center" v-for="(workspace, id) in sanitizedWorkspacesById" :key="id"> <div class="modal__image">
<div class="workspace-entry__icon flex flex--column flex--center"> <icon-database></icon-database>
<icon-provider :provider-id="workspace.providerId"></icon-provider> </div>
</div> <p>The following workspaces are locally available:</p>
<div class="workspace-entry__description flex flex--column"> <div class="workspace-entry flex flex--column" v-for="(workspace, id) in workspacesById" :key="id">
<input class="text-input" type="text" v-if="editedId === id" v-focus @blur="submitEdit()" @keydown.enter="submitEdit()" @keydown.esc.stop="submitEdit(true)" v-model="editingName"> <div class="flex flex--column">
<div class="workspace-entry__name" v-else> <div class="workspace-entry__header flex flex--row flex--align-center">
{{workspace.name}} <div class="workspace-entry__icon">
<icon-provider :provider-id="workspace.providerId"></icon-provider>
</div>
<input class="text-input" type="text" v-if="editedId === id" v-focus @blur="submitEdit()" @keydown.enter="submitEdit()" @keydown.esc.stop="submitEdit(true)" v-model="editingName">
<div class="workspace-entry__name" v-else>{{workspace.name}}</div>
<div class="workspace-entry__buttons flex flex--row">
<button class="workspace-entry__button button" @click="edit(id)" v-title="'Edit name'">
<icon-pen></icon-pen>
</button>
<button class="workspace-entry__button button" @click="remove(id)" v-title="'Remove'">
<icon-delete></icon-delete>
</button>
</div>
</div> </div>
<div class="workspace-entry__url"> <div class="workspace-entry__row flex flex--row flex--align-center">
{{workspace.url}} <div class="workspace-entry__url">
{{workspace.url}}
</div>
<div class="workspace-entry__buttons flex flex--row">
<button class="workspace-entry__button button" v-clipboard="workspace.url" @click="info('Workspace URL copied to clipboard!')" v-title="'Copy URL'">
<icon-content-copy></icon-content-copy>
</button>
<a class="workspace-entry__button button" :href="workspace.url" target="_blank" v-title="'Open workspace'">
<icon-open-in-new></icon-open-in-new>
</a>
</div>
</div>
<div class="workspace-entry__row flex flex--row flex--align-center" v-if="workspace.locationUrl">
<div class="workspace-entry__url">
{{workspace.locationUrl}}
</div>
<div class="workspace-entry__buttons flex flex--row">
<button class="workspace-entry__button button" v-clipboard="workspace.locationUrl" @click="info('Workspace URL copied to clipboard!')" v-title="'Copy URL'">
<icon-content-copy></icon-content-copy>
</button>
<a class="workspace-entry__button button" :href="workspace.locationUrl" target="_blank" v-title="'Open workspace location'">
<icon-open-in-new></icon-open-in-new>
</a>
</div>
</div> </div>
</div> </div>
<div class="workspace-entry__buttons flex flex--row flex--center"> </div>
<button class="workspace-entry__button button" @click="edit(id)"> <div class="modal__info">
<icon-pen></icon-pen> <b>ProTip:</b> Workspaces are accessible <b>offline</b> after their first use.
</button>
<button class="workspace-entry__button button" v-if="id !== currentWorkspace.id && id !== mainWorkspace.id" @click="remove(id)">
<icon-delete></icon-delete>
</button>
</div>
</div> </div>
</div> </div>
<div class="modal__button-bar"> <div class="modal__button-bar">
<button class="button" @click="config.resolve()">Close</button> <button class="button button--resolve" @click="config.resolve()">Close</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import ModalInner from './common/ModalInner'; import ModalInner from './common/ModalInner';
import localDbSvc from '../../services/localDbSvc'; import workspaceSvc from '../../services/workspaceSvc';
export default { export default {
components: { components: {
@ -47,40 +77,46 @@ export default {
...mapGetters('modal', [ ...mapGetters('modal', [
'config', 'config',
]), ]),
...mapGetters('data', [
'workspacesById',
'sanitizedWorkspacesById',
]),
...mapGetters('workspace', [ ...mapGetters('workspace', [
'workspacesById',
'mainWorkspace', 'mainWorkspace',
'currentWorkspace', 'currentWorkspace',
]), ]),
}, },
methods: { methods: {
...mapActions('notification', [
'info',
]),
edit(id) { edit(id) {
this.editedId = id; this.editedId = id;
this.editingName = this.workspacesById[id].name; this.editingName = this.workspacesById[id].name;
}, },
submitEdit(cancel) { submitEdit(cancel) {
const workspace = this.workspacesById[this.editedId]; const workspace = this.workspacesById[this.editedId];
if (workspace && !cancel && this.editingName) { if (workspace) {
this.$store.dispatch('data/patchWorkspacesById', { if (!cancel && this.editingName) {
[this.editedId]: { this.$store.dispatch('workspace/patchWorkspacesById', {
...workspace, [this.editedId]: {
name: this.editingName, ...workspace,
}, name: this.editingName,
}); },
} else { });
this.editingName = workspace.name; } else {
this.editingName = workspace.name;
}
} }
this.editedId = null; this.editedId = null;
}, },
async remove(id) { async remove(id) {
try { if (id === this.mainWorkspace.id) {
await this.$store.dispatch('modal/open', 'removeWorkspace'); this.info('Your main workspace can not be removed.');
localDbSvc.removeWorkspace(id); } else if (id === this.currentWorkspace.id) {
} catch (e) { this.info('Please close the workspace before removing it.');
// Cancel } else {
try {
await this.$store.dispatch('modal/open', 'removeWorkspace');
workspaceSvc.removeWorkspace(id);
} catch (e) { /* Cancel */ }
} }
}, },
}, },
@ -95,58 +131,78 @@ export default {
} }
.workspace-entry { .workspace-entry {
text-align: left; margin: 1.75em 0;
padding-left: 10px;
margin: 15px 0;
height: auto; height: auto;
font-size: 17px; font-size: 17px;
line-height: 1.5; line-height: 1.5;
text-transform: none; }
&:last-child { $button-size: 30px;
border-bottom: none; $small-button-size: 22px;
}
span { .workspace-entry__header {
text-overflow: ellipsis; line-height: $button-size;
overflow: hidden;
.text-input {
border: 1px solid $link-color;
padding: 0 5px;
line-height: $button-size;
height: $button-size;
} }
} }
.workspace-entry__row {
margin-top: 1px;
padding-top: 1px;
border-top: 1px solid rgba(128, 128, 128, 0.15);
line-height: $small-button-size;
}
.workspace-entry__icon { .workspace-entry__icon {
height: 20px; height: 22px;
width: 20px; width: 22px;
margin-right: 12px; margin-right: 0.75rem;
flex: none; flex: none;
} }
.workspace-entry__description {
width: 100%;
word-wrap: break-word;
overflow: hidden;
}
.workspace-entry__name { .workspace-entry__name {
width: 100%;
overflow: hidden; overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: bold; font-weight: bold;
} }
.workspace-entry__url { .workspace-entry__url {
width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
opacity: 0.5; opacity: 0.5;
font-size: 0.75em; font-size: 0.67em;
} }
.workspace-entry__buttons { .workspace-entry__buttons {
margin-left: 0.75rem; margin-left: 0.75rem;
.workspace-entry__row & {
margin-left: 0.5rem;
}
} }
.workspace-entry__button { .workspace-entry__button {
width: 36px; width: $button-size;
height: 36px; height: $button-size;
padding: 6px; padding: 4px;
background-color: transparent; background-color: transparent;
opacity: 0.75; opacity: 0.75;
.workspace-entry__row & {
width: $small-button-size;
height: $small-button-size;
padding: 4px;
}
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {

View File

@ -30,7 +30,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -31,7 +31,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -14,7 +14,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -4,20 +4,20 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="couchdb"></icon-provider> <icon-provider provider-id="couchdb"></icon-provider>
</div> </div>
<p>Create a workspace synchronized with a <b>CouchDB</b> database.</p> <p>Create a workspace synced with a <b>CouchDB</b> database.</p>
<form-entry label="Database URL" error="dbUrl"> <form-entry label="Database URL" error="dbUrl">
<input slot="field" class="textfield" type="text" v-model.trim="dbUrl" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="dbUrl" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
<b>Example:</b> https://instance.smileupps.com/stackedit-workspace <b>Example:</b> https://instance.smileupps.com/stackedit-workspace
</div> </div>
<div class="form-entry__actions"> <div class="form-entry__actions">
<a href="https://community.stackedit.io/t/couchdb-workspace-setup/" target="_blank">More info</a> <a href="https://community.stackedit.io/t/couchdb-workspace-setup/" target="_blank">How to setup?</a>
</div> </div>
</form-entry> </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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -18,7 +18,7 @@
</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>
<button class="button" @click="config.resolve()">Ok</button> <button class="button button--resolve" @click="config.resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -25,7 +25,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="dropbox"></icon-provider> <icon-provider provider-id="dropbox"></icon-provider>
</div> </div>
<p>Save <b>{{currentFileName}}</b> to your <b>Dropbox</b> and keep it synchronized.</p> <p>Save <b>{{currentFileName}}</b> to your <b>Dropbox</b> and keep it synced.</p>
<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">
@ -15,7 +15,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -37,7 +37,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="gist"></icon-provider> <icon-provider provider-id="gist"></icon-provider>
</div> </div>
<p>Save <b>{{currentFileName}}</b> to a <b>Gist</b> and keep it synchronized.</p> <p>Save <b>{{currentFileName}}</b> to a <b>Gist</b> and keep it synced.</p>
<form-entry label="Filename" error="filename"> <form-entry label="Filename" error="filename">
<input slot="field" class="textfield" type="text" v-model.trim="filename" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="filename" @keydown.enter="resolve()">
</form-entry> </form-entry>
@ -24,7 +24,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -15,7 +15,7 @@
</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>
<button class="button" @click="config.resolve()">Ok</button> <button class="button button--resolve" @click="config.resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="github"></icon-provider> <icon-provider provider-id="github"></icon-provider>
</div> </div>
<p>Open a file from your <b>GitHub</b> repository and keep it synchronized.</p> <p>Open a file from your <b>GitHub</b> repository and keep it synced.</p>
<form-entry label="Repository URL" error="repoUrl"> <form-entry label="Repository URL" error="repoUrl">
<input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
@ -26,7 +26,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>
@ -34,6 +34,7 @@
<script> <script>
import githubProvider from '../../../services/providers/githubProvider'; import githubProvider from '../../../services/providers/githubProvider';
import modalTemplate from '../common/modalTemplate'; import modalTemplate from '../common/modalTemplate';
import utils from '../../../services/utils';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({
@ -52,7 +53,7 @@ export default modalTemplate({
this.setError('path'); this.setError('path');
} }
if (this.repoUrl && this.path) { if (this.repoUrl && this.path) {
const parsedRepo = githubProvider.parseRepoUrl(this.repoUrl); const parsedRepo = utils.parseGithubRepoUrl(this.repoUrl);
if (!parsedRepo) { if (!parsedRepo) {
this.setError('repoUrl'); this.setError('repoUrl');
} else { } else {

View File

@ -37,7 +37,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="github"></icon-provider> <icon-provider provider-id="github"></icon-provider>
</div> </div>
<p>Save <b>{{currentFileName}}</b> to your <b>GitHub</b> repository and keep it synchronized.</p> <p>Save <b>{{currentFileName}}</b> to your <b>GitHub</b> repository and keep it synced.</p>
<form-entry label="Repository URL" error="repoUrl"> <form-entry label="Repository URL" error="repoUrl">
<input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
@ -27,7 +27,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>
@ -35,6 +35,7 @@
<script> <script>
import githubProvider from '../../../services/providers/githubProvider'; import githubProvider from '../../../services/providers/githubProvider';
import modalTemplate from '../common/modalTemplate'; import modalTemplate from '../common/modalTemplate';
import utils from '../../../services/utils';
export default modalTemplate({ export default modalTemplate({
data: () => ({ data: () => ({
@ -49,7 +50,7 @@ export default modalTemplate({
}, },
methods: { methods: {
resolve() { resolve() {
const parsedRepo = githubProvider.parseRepoUrl(this.repoUrl); const parsedRepo = utils.parseGithubRepoUrl(this.repoUrl);
if (!parsedRepo) { if (!parsedRepo) {
this.setError('repoUrl'); this.setError('repoUrl');
} }

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="github"></icon-provider> <icon-provider provider-id="github"></icon-provider>
</div> </div>
<p>Create a workspace synchronized with a <b>GitHub</b> repository folder.</p> <p>Create a workspace synced with a <b>GitHub</b> repository folder.</p>
<form-entry label="Repository URL" error="repoUrl"> <form-entry label="Repository URL" error="repoUrl">
<input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
@ -26,13 +26,12 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>
<script> <script>
import githubProvider from '../../../services/providers/githubProvider';
import utils from '../../../services/utils'; import utils from '../../../services/utils';
import modalTemplate from '../common/modalTemplate'; import modalTemplate from '../common/modalTemplate';
@ -46,14 +45,14 @@ export default modalTemplate({
}, },
methods: { methods: {
resolve() { resolve() {
const parsedRepo = githubProvider.parseRepoUrl(this.repoUrl); const parsedRepo = utils.parseGithubRepoUrl(this.repoUrl);
if (!parsedRepo) { if (!parsedRepo) {
this.setError('repoUrl'); this.setError('repoUrl');
} else { } else {
const path = this.path && this.path.replace(/^\//, ''); const path = this.path && this.path.replace(/^\//, '');
const url = utils.addQueryParams('app', { const url = utils.addQueryParams('app', {
...parsedRepo,
providerId: 'githubWorkspace', providerId: 'githubWorkspace',
repo: `${parsedRepo.owner}/${parsedRepo.repo}`,
branch: this.branch || 'master', branch: this.branch || 'master',
path: path || undefined, path: path || undefined,
}, true); }, true);

View File

@ -18,7 +18,7 @@
</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>
<button class="button" @click="config.resolve()">Ok</button> <button class="button button--resolve" @click="config.resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -48,7 +48,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>
@ -73,9 +73,11 @@ export default modalTemplate({
'modal/hideUntil', 'modal/hideUntil',
googleHelper.openPicker(this.config.token, 'folder') googleHelper.openPicker(this.config.token, 'folder')
.then((folders) => { .then((folders) => {
this.$store.dispatch('data/patchLocalSettings', { if (folders[0]) {
googleDriveFolderId: folders[0].id, this.$store.dispatch('data/patchLocalSettings', {
}); googleDriveFolderId: folders[0].id,
});
}
}), }),
); );
}, },

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider> <icon-provider provider-id="googleDrive"></icon-provider>
</div> </div>
<p>Save <b>{{currentFileName}}</b> to your <b>Google Drive</b> account and keep it synchronized.</p> <p>Save <b>{{currentFileName}}</b> to your <b>Google Drive</b> account and keep it synced.</p>
<form-entry label="Folder ID" info="optional"> <form-entry label="Folder ID" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="folderId" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="folderId" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
@ -23,7 +23,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>
@ -46,9 +46,11 @@ export default modalTemplate({
'modal/hideUntil', 'modal/hideUntil',
googleHelper.openPicker(this.config.token, 'folder') googleHelper.openPicker(this.config.token, 'folder')
.then((folders) => { .then((folders) => {
this.$store.dispatch('data/patchLocalSettings', { if (folders[0]) {
googleDriveFolderId: folders[0].id, this.$store.dispatch('data/patchLocalSettings', {
}); googleDriveFolderId: folders[0].id,
});
}
}), }),
); );
}, },

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider> <icon-provider provider-id="googleDrive"></icon-provider>
</div> </div>
<p>Create a workspace synchronized with a <b>Google Drive</b> folder.</p> <p>Create a workspace synced with a <b>Google Drive</b> folder.</p>
<form-entry label="Folder ID" info="optional"> <form-entry label="Folder ID" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="folderId" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="folderId" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
@ -17,7 +17,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>
@ -37,9 +37,11 @@ export default modalTemplate({
'modal/hideUntil', 'modal/hideUntil',
googleHelper.openPicker(this.config.token, 'folder') googleHelper.openPicker(this.config.token, 'folder')
.then((folders) => { .then((folders) => {
this.$store.dispatch('data/patchLocalSettings', { if (folders[0]) {
googleDriveWorkspaceFolderId: folders[0].id, this.$store.dispatch('data/patchLocalSettings', {
}); googleDriveWorkspaceFolderId: folders[0].id,
});
}
}), }),
); );
}, },

View File

@ -11,7 +11,7 @@
</div> </div>
<div class="modal__button-bar"> <div class="modal__button-bar">
<button class="button" @click="reject()">Cancel</button> <button class="button" @click="reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -33,7 +33,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -21,7 +21,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -37,7 +37,7 @@
</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>
<button class="button" @click="resolve()">Ok</button> <button class="button button--resolve" @click="resolve()">Ok</button>
</div> </div>
</modal-inner> </modal-inner>
</template> </template>

View File

@ -1,6 +1,6 @@
export default () => ({ export default () => ({
main: { main: {
name: 'Main workspace', name: 'Main workspace',
// The rest will be filled by the data/sanitizedWorkspacesById getter // The rest will be filled by the workspace/workspacesById getter
}, },
}); });

View File

@ -4,14 +4,6 @@ If your workspace is not synced, your files are only stored inside your browser
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.
**Where is my data stored once I sync my workspace?**
If you sign in with Google, your main workspace will be stored in Google Drive, in your [app data folder](https://developers.google.com/drive/v3/web/appdata).
If you open a Google Drive workspace, the files in the workspace will be stored inside a Google Drive folder which you can share with other users.
If you open a CouchDB workspace, the files in the workspace will be stored in the CouchDB database which can be hosted on premises for privacy concerns.
**Can StackEdit access my data without telling me?** **Can StackEdit access my data without telling me?**
StackEdit is a frontend application. The access tokens issued by Google, Dropbox, GitHub... are stored in your browser and are not sent to any backend or 3^rd^ parties so your data won't be accessed by anyone. StackEdit is a frontend application. The access tokens issued by Google, Dropbox, GitHub... are stored in your browser and are not sent to any backend or 3^rd^ parties so your data won't be accessed by anyone.

View File

@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24">
<path d="M 19,21L 8,21L 8,7L 19,7M 19,5L 8,5C 6.9,5 6,5.9 6,7L 6,21C 6,22.1 6.9,23 8,23L 19,23C 20.1,23 21,22.1 21,21L 21,7C 21,5.9 20.1,5 19,5 Z M 16,1L 4,1C 2.9,1 2,1.9 2,3L 2,17L 4,17L 4,3L 16,3L 16,1 Z " />
</svg>
</template>

View File

@ -50,6 +50,7 @@ import Database from './Database';
import Magnify from './Magnify'; import Magnify from './Magnify';
import FormatListChecks from './FormatListChecks'; import FormatListChecks from './FormatListChecks';
import CloseCircle from './CloseCircle'; import CloseCircle from './CloseCircle';
import ContentCopy from './ContentCopy';
Vue.component('iconProvider', Provider); Vue.component('iconProvider', Provider);
Vue.component('iconFormatBold', FormatBold); Vue.component('iconFormatBold', FormatBold);
@ -102,3 +103,4 @@ Vue.component('iconDatabase', Database);
Vue.component('iconMagnify', Magnify); Vue.component('iconMagnify', Magnify);
Vue.component('iconFormatListChecks', FormatListChecks); Vue.component('iconFormatListChecks', FormatListChecks);
Vue.component('iconCloseCircle', CloseCircle); Vue.component('iconCloseCircle', CloseCircle);
Vue.component('iconContentCopy', ContentCopy);

View File

@ -1,4 +1,4 @@
import fileSvc from './fileSvc'; import workspaceSvc from './workspaceSvc';
import utils from './utils'; import utils from './utils';
export default { export default {
@ -51,7 +51,7 @@ export default {
await utils.awaitSequence( await utils.awaitSequence(
Object.keys(folderNameMap), Object.keys(folderNameMap),
async externalId => fileSvc.setOrPatchItem({ async externalId => workspaceSvc.setOrPatchItem({
id: folderIdMap[externalId], id: folderIdMap[externalId],
type: 'folder', type: 'folder',
name: folderNameMap[externalId], name: folderNameMap[externalId],
@ -61,7 +61,7 @@ export default {
await utils.awaitSequence( await utils.awaitSequence(
Object.keys(fileNameMap), Object.keys(fileNameMap),
async externalId => fileSvc.createFile({ async externalId => workspaceSvc.createFile({
name: fileNameMap[externalId], name: fileNameMap[externalId],
parentId: folderIdMap[parentIdMap[externalId]], parentId: folderIdMap[parentIdMap[externalId]],
text: textMap[externalId], text: textMap[externalId],

View File

@ -1,7 +1,7 @@
import DiffMatchPatch from 'diff-match-patch'; import DiffMatchPatch from 'diff-match-patch';
import TurndownService from 'turndown/lib/turndown.browser.umd'; import TurndownService from 'turndown/lib/turndown.browser.umd';
import htmlSanitizer from '../../libs/htmlSanitizer'; import htmlSanitizer from '../../../libs/htmlSanitizer';
import store from '../../store'; import store from '../../../store';
function cledit(contentElt, scrollEltOpt, isMarkdown = false) { function cledit(contentElt, scrollEltOpt, isMarkdown = false) {
const scrollElt = scrollEltOpt || contentElt; const scrollElt = scrollEltOpt || contentElt;

View File

@ -29,10 +29,14 @@ function SelectionMgr(editor) {
this.createRange = (start, end) => { this.createRange = (start, end) => {
const range = document.createRange(); const range = document.createRange();
const startContainer = Number.isNaN(start) ? start : this.findContainer(start < 0 ? 0 : start); const startContainer = typeof start === 'number'
? this.findContainer(start < 0 ? 0 : start)
: start;
let endContainer = startContainer; let endContainer = startContainer;
if (start !== end) { if (start !== end) {
endContainer = Number.isNaN(end) ? end : this.findContainer(end < 0 ? 0 : end); endContainer = typeof end === 'number'
? this.findContainer(end < 0 ? 0 : end)
: end;
} }
range.setStart(startContainer.container, startContainer.offsetInContainer); range.setStart(startContainer.container, startContainer.offsetInContainer);
range.setEnd(endContainer.container, endContainer.offsetInContainer); range.setEnd(endContainer.container, endContainer.offsetInContainer);

View File

@ -1,4 +1,4 @@
import '../../libs/clunderscore'; import '../../../libs/clunderscore';
import cledit from './cleditCore'; import cledit from './cleditCore';
import './cleditHighlighter'; import './cleditHighlighter';
import './cleditKeystroke'; import './cleditKeystroke';

View File

@ -1,10 +1,10 @@
import DiffMatchPatch from 'diff-match-patch'; import DiffMatchPatch from 'diff-match-patch';
import cledit from './cledit'; import cledit from './cledit';
import utils from './utils'; import utils from '../utils';
import diffUtils from './diffUtils'; import diffUtils from '../diffUtils';
import store from '../store'; import store from '../../store';
import EditorClassApplier from '../components/common/EditorClassApplier'; import EditorClassApplier from '../../components/common/EditorClassApplier';
import PreviewClassApplier from '../components/common/PreviewClassApplier'; import PreviewClassApplier from '../../components/common/PreviewClassApplier';
let clEditor; let clEditor;
// let discussionIds = {}; // let discussionIds = {};

View File

@ -1,7 +1,7 @@
import DiffMatchPatch from 'diff-match-patch'; import DiffMatchPatch from 'diff-match-patch';
import cledit from './cledit'; import cledit from './cledit';
import animationSvc from './animationSvc'; import animationSvc from '../animationSvc';
import store from '../store'; import store from '../../store';
const diffMatchPatch = new DiffMatchPatch(); const diffMatchPatch = new DiffMatchPatch();

View File

@ -0,0 +1,114 @@
class SectionDimension {
constructor(startOffset, endOffset) {
this.startOffset = startOffset;
this.endOffset = endOffset;
this.height = endOffset - startOffset;
}
}
const dimensionNormalizer = dimensionName => (editorSvc) => {
const dimensionList = editorSvc.previewCtx.sectionDescList
.map(sectionDesc => sectionDesc[dimensionName]);
let dimension;
let i;
let j;
for (i = 0; i < dimensionList.length; i += 1) {
dimension = dimensionList[i];
if (dimension.height) {
for (j = i + 1; j < dimensionList.length && dimensionList[j].height === 0; j += 1) {
// Loop
}
const normalizeFactor = j - i;
if (normalizeFactor !== 1) {
const normalizedHeight = dimension.height / normalizeFactor;
dimension.height = normalizedHeight;
dimension.endOffset = dimension.startOffset + dimension.height;
for (j = i + 1; j < i + normalizeFactor; j += 1) {
const startOffset = dimension.endOffset;
dimension = dimensionList[j];
dimension.startOffset = startOffset;
dimension.height = normalizedHeight;
dimension.endOffset = dimension.startOffset + dimension.height;
}
i = j - 1;
}
}
}
};
const normalizeEditorDimensions = dimensionNormalizer('editorDimension');
const normalizePreviewDimensions = dimensionNormalizer('previewDimension');
const normalizeTocDimensions = dimensionNormalizer('tocDimension');
export default {
measureSectionDimensions(editorSvc) {
let editorSectionOffset = 0;
let previewSectionOffset = 0;
let tocSectionOffset = 0;
let sectionDesc = editorSvc.previewCtx.sectionDescList[0];
let nextSectionDesc;
let i = 1;
for (; i < editorSvc.previewCtx.sectionDescList.length; i += 1) {
nextSectionDesc = editorSvc.previewCtx.sectionDescList[i];
// Measure editor section
let newEditorSectionOffset = nextSectionDesc.editorElt
? nextSectionDesc.editorElt.offsetTop
: editorSectionOffset;
newEditorSectionOffset = newEditorSectionOffset > editorSectionOffset
? newEditorSectionOffset
: editorSectionOffset;
sectionDesc.editorDimension = new SectionDimension(
editorSectionOffset,
newEditorSectionOffset,
);
editorSectionOffset = newEditorSectionOffset;
// Measure preview section
let newPreviewSectionOffset = nextSectionDesc.previewElt
? nextSectionDesc.previewElt.offsetTop
: previewSectionOffset;
newPreviewSectionOffset = newPreviewSectionOffset > previewSectionOffset
? newPreviewSectionOffset
: previewSectionOffset;
sectionDesc.previewDimension = new SectionDimension(
previewSectionOffset,
newPreviewSectionOffset,
);
previewSectionOffset = newPreviewSectionOffset;
// Measure TOC section
let newTocSectionOffset = nextSectionDesc.tocElt
? nextSectionDesc.tocElt.offsetTop + (nextSectionDesc.tocElt.offsetHeight / 2)
: tocSectionOffset;
newTocSectionOffset = newTocSectionOffset > tocSectionOffset
? newTocSectionOffset
: tocSectionOffset;
sectionDesc.tocDimension = new SectionDimension(tocSectionOffset, newTocSectionOffset);
tocSectionOffset = newTocSectionOffset;
sectionDesc = nextSectionDesc;
}
// Last section
sectionDesc = editorSvc.previewCtx.sectionDescList[i - 1];
if (sectionDesc) {
sectionDesc.editorDimension = new SectionDimension(
editorSectionOffset,
editorSvc.editorElt.scrollHeight,
);
sectionDesc.previewDimension = new SectionDimension(
previewSectionOffset,
editorSvc.previewElt.scrollHeight,
);
sectionDesc.tocDimension = new SectionDimension(
tocSectionOffset,
editorSvc.tocElt.scrollHeight,
);
}
normalizeEditorDimensions(editorSvc);
normalizePreviewDimensions(editorSvc);
normalizeTocDimensions(editorSvc);
},
};

View File

@ -2,15 +2,15 @@ import Vue from 'vue';
import DiffMatchPatch from 'diff-match-patch'; import DiffMatchPatch from 'diff-match-patch';
import Prism from 'prismjs'; import Prism from 'prismjs';
import markdownItPandocRenderer from 'markdown-it-pandoc-renderer'; import markdownItPandocRenderer from 'markdown-it-pandoc-renderer';
import cledit from './cledit'; import cledit from './editor/cledit';
import pagedown from '../libs/pagedown'; import pagedown from '../libs/pagedown';
import htmlSanitizer from '../libs/htmlSanitizer'; import htmlSanitizer from '../libs/htmlSanitizer';
import markdownConversionSvc from './markdownConversionSvc'; import markdownConversionSvc from './markdownConversionSvc';
import markdownGrammarSvc from './markdownGrammarSvc'; import markdownGrammarSvc from './markdownGrammarSvc';
import sectionUtils from './sectionUtils'; import sectionUtils from './editor/sectionUtils';
import extensionSvc from './extensionSvc'; import extensionSvc from './extensionSvc';
import editorSvcDiscussions from './editorSvcDiscussions'; import editorSvcDiscussions from './editor/editorSvcDiscussions';
import editorSvcUtils from './editorSvcUtils'; import editorSvcUtils from './editor/editorSvcUtils';
import utils from './utils'; import utils from './utils';
import store from '../store'; import store from '../store';

View File

@ -1,5 +1,5 @@
import store from '../store'; import store from '../store';
import fileSvc from './fileSvc'; import workspaceSvc from './workspaceSvc';
export default { export default {
newItem(isFolder = false) { newItem(isFolder = false) {
@ -59,7 +59,7 @@ export default {
parentId: 'trash', parentId: 'trash',
}); });
} else { } else {
fileSvc.deleteFile(id); workspaceSvc.deleteFile(id);
} }
}; };

View File

@ -2,7 +2,7 @@ import FileSaver from 'file-saver';
import utils from './utils'; 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 fileSvc from './fileSvc'; import workspaceSvc from './workspaceSvc';
const dbVersion = 1; const dbVersion = 1;
const dbStoreName = 'objects'; const dbStoreName = 'objects';
@ -12,21 +12,13 @@ const resetApp = utils.queryParams.reset;
const deleteMarkerMaxAge = 1000; const deleteMarkerMaxAge = 1000;
const checkSponsorshipAfter = (5 * 60 * 1000) + (30 * 1000); // tokenExpirationMargin + 30 sec const checkSponsorshipAfter = (5 * 60 * 1000) + (30 * 1000); // tokenExpirationMargin + 30 sec
const getDbName = (workspaceId) => {
let dbName = 'stackedit-db';
if (workspaceId !== 'main') {
dbName += `-${workspaceId}`;
}
return dbName;
};
class Connection { class Connection {
constructor() { constructor() {
this.getTxCbs = []; this.getTxCbs = [];
// Make the DB name // Make the DB name
const workspaceId = store.getters['workspace/currentWorkspace'].id; const workspaceId = store.getters['workspace/currentWorkspace'].id;
this.dbName = getDbName(workspaceId); this.dbName = utils.getDbName(workspaceId);
// Init connection // Init connection
const request = indexedDB.open(this.dbName, dbVersion); const request = indexedDB.open(this.dbName, dbVersion);
@ -142,9 +134,12 @@ const localDbSvc = {
this.connection.createTx((tx) => { this.connection.createTx((tx) => {
// Look for DB changes and apply them to the store // Look for DB changes and apply them to the store
this.readAll(tx, (storeItemMap) => { this.readAll(tx, (storeItemMap) => {
// Sanitize the workspace
workspaceSvc.ensureUniquePaths();
workspaceSvc.ensureUniqueLocations();
// Persist all the store changes into the DB // Persist all the store changes into the DB
this.writeAll(storeItemMap, tx); this.writeAll(storeItemMap, tx);
// Sync localStorage // Sync the localStorage
this.syncLocalStorage(); this.syncLocalStorage();
// Done // Done
resolve(); resolve();
@ -177,19 +172,21 @@ const localDbSvc = {
// Collect change // Collect change
changes.push(item); changes.push(item);
cursor.continue(); cursor.continue();
} else { return;
const storeItemMap = { ...store.getters.allItemsById };
changes.forEach((item) => {
this.readDbItem(item, storeItemMap);
// If item is an old delete marker, remove it from the DB
if (!item.hash && lastTx - item.tx > deleteMarkerMaxAge) {
dbStore.delete(item.id);
}
});
fileSvc.ensureUniquePaths();
this.lastTx = lastTx;
cb(storeItemMap);
} }
// Read the collected changes
const storeItemMap = { ...store.getters.allItemsById };
changes.forEach((item) => {
this.readDbItem(item, storeItemMap);
// If item is an old delete marker, remove it from the DB
if (!item.hash && lastTx - item.tx > deleteMarkerMaxAge) {
dbStore.delete(item.id);
}
});
this.lastTx = lastTx;
cb(storeItemMap);
}; };
}, },
@ -322,40 +319,20 @@ const localDbSvc = {
}); });
}, },
/**
* Drop the database and clean the localStorage for the specified workspaceId.
*/
async removeWorkspace(id) {
const workspacesById = {
...store.getters['data/workspacesById'],
};
delete workspacesById[id];
store.dispatch('data/setWorkspacesById', workspacesById);
this.syncLocalStorage();
await new Promise((resolve, reject) => {
const dbName = getDbName(id);
const request = indexedDB.deleteDatabase(dbName);
request.onerror = reject;
request.onsuccess = resolve;
});
localStorage.removeItem(`${id}/lastSyncActivity`);
localStorage.removeItem(`${id}/lastWindowFocus`);
},
/** /**
* Create the connection and start syncing. * Create the connection and start syncing.
*/ */
async init() { async init() {
// Reset the app if reset flag was passed // Reset the app if reset flag was passed
if (resetApp) { if (resetApp) {
await Promise.all(Object.keys(store.getters['data/workspacesById']) await Promise.all(Object.keys(store.getters['workspace/workspacesById'])
.map(workspaceId => localDbSvc.removeWorkspace(workspaceId))); .map(workspaceId => workspaceSvc.removeWorkspace(workspaceId)));
utils.localStorageDataIds.forEach((id) => { utils.localStorageDataIds.forEach((id) => {
// Clean data stored in localStorage // Clean data stored in localStorage
localStorage.removeItem(`data/${id}`); localStorage.removeItem(`data/${id}`);
}); });
window.location.reload(); window.location.reload();
throw new Error('reload'); throw new Error('RELOAD');
} }
// Create the connection // Create the connection
@ -374,6 +351,13 @@ const localDbSvc = {
return; return;
} }
// Watch workspace deletions and persist them as soon as possible
// to make the changes available to reloading workspace tabs.
store.watch(
() => store.getters['data/workspaces'],
() => this.syncLocalStorage(),
);
// Save welcome file content hash if not done already // Save welcome file content hash if not done already
const hash = utils.hash(welcomeFile); const hash = utils.hash(welcomeFile);
const { welcomeFileHashes } = store.getters['data/localSettings']; const { welcomeFileHashes } = store.getters['data/localSettings'];
@ -393,7 +377,7 @@ const localDbSvc = {
// Clean files // Clean files
store.getters['file/items'] store.getters['file/items']
.filter(file => file.parentId === 'trash') // If file is in the trash .filter(file => file.parentId === 'trash') // If file is in the trash
.forEach(file => fileSvc.deleteFile(file.id)); .forEach(file => workspaceSvc.deleteFile(file.id));
} }
// Enable sponsorship // Enable sponsorship
@ -429,7 +413,7 @@ const localDbSvc = {
store.commit('file/setCurrentId', recentFile.id); store.commit('file/setCurrentId', recentFile.id);
} else { } else {
// If still no ID, create a new file // If still no ID, create a new file
const newFile = await fileSvc.createFile({ const newFile = await workspaceSvc.createFile({
name: 'Welcome file', name: 'Welcome file',
text: welcomeFile, text: welcomeFile,
}, true); }, true);

View File

@ -1,4 +1,4 @@
import cledit from '../cledit'; import cledit from '../editor/cledit';
import editorSvc from '../editorSvc'; import editorSvc from '../editorSvc';
import store from '../../store'; import store from '../../store';

View File

@ -4,16 +4,15 @@ import Provider from './common/Provider';
export default new Provider({ export default new Provider({
id: 'bloggerPage', id: 'bloggerPage',
getToken(location) { getToken({ sub }) {
const token = store.getters['data/googleTokensBySub'][location.sub]; const token = store.getters['data/googleTokensBySub'][sub];
return token && token.isBlogger ? token : null; return token && token.isBlogger ? token : null;
}, },
getUrl(location) { getLocationUrl({ blogId, pageId }) {
return `https://www.blogger.com/blogger.g?blogID=${location.blogId}#editor/target=page;pageID=${location.pageId}`; return `https://www.blogger.com/blogger.g?blogID=${blogId}#editor/target=page;pageID=${pageId}`;
}, },
getDescription(location) { getLocationDescription({ pageId }) {
const token = this.getToken(location); return pageId;
return `${location.pageId}${location.blogUrl}${token.name}`;
}, },
async publish(token, html, metadata, publishLocation) { async publish(token, html, metadata, publishLocation) {
const page = await googleHelper.uploadBlogger({ const page = await googleHelper.uploadBlogger({

View File

@ -4,16 +4,15 @@ import Provider from './common/Provider';
export default new Provider({ export default new Provider({
id: 'blogger', id: 'blogger',
getToken(location) { getToken({ sub }) {
const token = store.getters['data/googleTokensBySub'][location.sub]; const token = store.getters['data/googleTokensBySub'][sub];
return token && token.isBlogger ? token : null; return token && token.isBlogger ? token : null;
}, },
getUrl(location) { getLocationUrl({ blogId, postId }) {
return `https://www.blogger.com/blogger.g?blogID=${location.blogId}#editor/target=post;postID=${location.postId}`; return `https://www.blogger.com/blogger.g?blogID=${blogId}#editor/target=post;postID=${postId}`;
}, },
getDescription(location) { getLocationDescription({ postId }) {
const token = this.getToken(location); return postId;
return `${location.postId}${location.blogUrl}${token.name}`;
}, },
async publish(token, html, metadata, publishLocation) { async publish(token, html, metadata, publishLocation) {
const post = await googleHelper.uploadBlogger({ const post = await googleHelper.uploadBlogger({

View File

@ -2,7 +2,7 @@ import providerRegistry from './providerRegistry';
import emptyContent from '../../../data/emptyContent'; import emptyContent from '../../../data/emptyContent';
import utils from '../../utils'; import utils from '../../utils';
import store from '../../../store'; import store from '../../../store';
import fileSvc from '../../fileSvc'; import workspaceSvc from '../../workspaceSvc';
const dataExtractor = /<!--stackedit_data:([A-Za-z0-9+/=\s]+)-->$/; const dataExtractor = /<!--stackedit_data:([A-Za-z0-9+/=\s]+)-->$/;
@ -81,8 +81,8 @@ export default class Provider {
/** /**
* Find and open a file with location that meets the criteria * Find and open a file with location that meets the criteria
*/ */
static openFileWithLocation(allLocations, criteria) { static openFileWithLocation(criteria) {
const location = utils.search(allLocations, criteria); const location = utils.search(store.getters['syncLocation/items'], criteria);
if (location) { if (location) {
// Found one, open it if it exists // Found one, open it if it exists
const item = store.state.file.itemsById[location.fileId]; const item = store.state.file.itemsById[location.fileId];
@ -90,7 +90,7 @@ export default class Provider {
store.commit('file/setCurrentId', item.id); store.commit('file/setCurrentId', item.id);
// If file is in the trash, restore it // If file is in the trash, restore it
if (item.parentId === 'trash') { if (item.parentId === 'trash') {
fileSvc.setOrPatchItem({ workspaceSvc.setOrPatchItem({
...item, ...item,
parentId: null, parentId: null,
}); });

View File

@ -1,7 +1,7 @@
export default { export default {
providers: {}, providersById: {},
register(provider) { register(provider) {
this.providers[provider.id] = provider; this.providersById[provider.id] = provider;
return provider; return provider;
}, },
}; };

View File

@ -10,56 +10,54 @@ export default new Provider({
getToken() { getToken() {
return store.getters['workspace/syncToken']; return store.getters['workspace/syncToken'];
}, },
async initWorkspace() { getWorkspaceParams({ dbUrl }) {
const dbUrl = (utils.queryParams.dbUrl || '').replace(/\/?$/, ''); // Remove trailing / return {
const workspaceParams = {
providerId: this.id, providerId: this.id,
dbUrl, dbUrl,
}; };
},
getWorkspaceLocationUrl({ dbUrl }) {
return dbUrl;
},
getSyncDataUrl(fileSyncData, { id }) {
const { dbUrl } = this.getToken();
return `${dbUrl}/${id}/data`;
},
getSyncDataDescription(fileSyncData, { id }) {
return id;
},
async initWorkspace() {
const dbUrl = (utils.queryParams.dbUrl || '').replace(/\/?$/, ''); // Remove trailing /
const workspaceParams = this.getWorkspaceParams({ dbUrl });
const workspaceId = utils.makeWorkspaceId(workspaceParams); const workspaceId = utils.makeWorkspaceId(workspaceParams);
const getToken = () => store.getters['data/couchdbTokensBySub'][workspaceId];
const getWorkspace = () => store.getters['data/sanitizedWorkspacesById'][workspaceId];
if (!getToken()) { // Create the token if it doesn't exist
// Create token if (!store.getters['data/couchdbTokensBySub'][workspaceId]) {
store.dispatch('data/addCouchdbToken', { store.dispatch('data/addCouchdbToken', {
sub: workspaceId, sub: workspaceId,
dbUrl, dbUrl,
}); });
} }
// Create the workspace // Create the workspace if it doesn't exist
let workspace = getWorkspace(); if (!store.getters['workspace/workspacesById'][workspaceId]) {
if (!workspace) {
// Make sure the database exists and retrieve its name
let db;
try { try {
db = await couchdbHelper.getDb(getToken()); // Make sure the database exists and retrieve its name
const db = await couchdbHelper.getDb(store.getters['data/couchdbTokensBySub'][workspaceId]);
store.dispatch('workspace/patchWorkspacesById', {
[workspaceId]: {
id: workspaceId,
name: db.db_name,
providerId: this.id,
dbUrl,
},
});
} catch (e) { } catch (e) {
throw new Error(`${dbUrl} is not accessible. Make sure you have the proper permissions.`); throw new Error(`${dbUrl} is not accessible. Make sure you have the proper permissions.`);
} }
store.dispatch('data/patchWorkspacesById', {
[workspaceId]: {
id: workspaceId,
name: db.db_name,
providerId: this.id,
dbUrl,
},
});
workspace = getWorkspace();
} }
// Fix the URL hash return store.getters['workspace/workspacesById'][workspaceId];
utils.setQueryParams(workspaceParams);
if (workspace.url !== window.location.href) {
store.dispatch('data/patchWorkspacesById', {
[workspace.id]: {
...workspace,
url: window.location.href,
},
});
}
return getWorkspace();
}, },
async getChanges() { async getChanges() {
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];

View File

@ -2,7 +2,7 @@ import store from '../../store';
import dropboxHelper from './helpers/dropboxHelper'; import dropboxHelper from './helpers/dropboxHelper';
import Provider from './common/Provider'; import Provider from './common/Provider';
import utils from '../utils'; import utils from '../utils';
import fileSvc from '../fileSvc'; import workspaceSvc from '../workspaceSvc';
const makePathAbsolute = (token, path) => { const makePathAbsolute = (token, path) => {
if (!token.fullAccess) { if (!token.fullAccess) {
@ -19,17 +19,16 @@ const makePathRelative = (token, path) => {
export default new Provider({ export default new Provider({
id: 'dropbox', id: 'dropbox',
getToken(location) { getToken({ sub }) {
return store.getters['data/dropboxTokensBySub'][location.sub]; return store.getters['data/dropboxTokensBySub'][sub];
}, },
getUrl(location) { getLocationUrl({ path }) {
const pathComponents = location.path.split('/').map(encodeURIComponent); const pathComponents = path.split('/').map(encodeURIComponent);
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}`;
}, },
getDescription(location) { getLocationDescription({ path }) {
const token = this.getToken(location); return path;
return `${location.path}${location.dropboxFileId}${token.name}`;
}, },
checkPath(path) { checkPath(path) {
return path && path.match(/^\/[^\\<>:"|?*]+$/); return path && path.match(/^\/[^\\<>:"|?*]+$/);
@ -71,7 +70,7 @@ export default new Provider({
async openFiles(token, paths) { async openFiles(token, paths) {
await utils.awaitSequence(paths, async (path) => { await utils.awaitSequence(paths, async (path) => {
// Check if the file exists and open it // Check if the file exists and open it
if (!Provider.openFileWithLocation(store.getters['syncLocation/items'], { if (!Provider.openFileWithLocation({
providerId: this.id, providerId: this.id,
path, path,
})) { })) {
@ -99,7 +98,7 @@ export default new Provider({
if (dotPos > 0 && slashPos < name.length) { if (dotPos > 0 && slashPos < name.length) {
name = name.slice(0, dotPos); name = name.slice(0, dotPos);
} }
const item = await fileSvc.createFile({ const item = await workspaceSvc.createFile({
name, name,
parentId: store.getters['file/current'].parentId, parentId: store.getters['file/current'].parentId,
text: content.text, text: content.text,
@ -108,9 +107,8 @@ export default new Provider({
comments: content.comments, comments: content.comments,
}, true); }, true);
store.commit('file/setCurrentId', item.id); store.commit('file/setCurrentId', item.id);
store.commit('syncLocation/setItem', { workspaceSvc.addSyncLocation({
...syncLocation, ...syncLocation,
id: utils.uid(),
fileId: item.id, fileId: item.id,
}); });
store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Dropbox.`); store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Dropbox.`);

View File

@ -5,15 +5,14 @@ import utils from '../utils';
export default new Provider({ export default new Provider({
id: 'gist', id: 'gist',
getToken(location) { getToken({ sub }) {
return store.getters['data/githubTokensBySub'][location.sub]; return store.getters['data/githubTokensBySub'][sub];
}, },
getUrl(location) { getLocationUrl({ gistId }) {
return `https://gist.github.com/${location.gistId}`; return `https://gist.github.com/${gistId}`;
}, },
getDescription(location) { getLocationDescription({ filename }) {
const token = this.getToken(location); return filename;
return `${location.filename}${location.gistId}${token.name}`;
}, },
async downloadContent(token, syncLocation) { async downloadContent(token, syncLocation) {
const content = await githubHelper.downloadGist({ const content = await githubHelper.downloadGist({

View File

@ -2,21 +2,25 @@ 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 fileSvc from '../fileSvc'; import workspaceSvc from '../workspaceSvc';
const savedSha = {}; const savedSha = {};
export default new Provider({ export default new Provider({
id: 'github', id: 'github',
getToken(location) { getToken({ sub }) {
return store.getters['data/githubTokensBySub'][location.sub]; return store.getters['data/githubTokensBySub'][sub];
}, },
getUrl(location) { getLocationUrl({
return `https://github.com/${encodeURIComponent(location.owner)}/${encodeURIComponent(location.repo)}/blob/${encodeURIComponent(location.branch)}/${encodeURIComponent(location.path)}`; owner,
repo,
branch,
path,
}) {
return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`;
}, },
getDescription(location) { getLocationDescription({ path }) {
const token = this.getToken(location); return path;
return `${location.path}${location.owner}/${location.repo}${token.name}`;
}, },
async downloadContent(token, syncLocation) { async downloadContent(token, syncLocation) {
try { try {
@ -59,7 +63,7 @@ export default new Provider({
}, },
async openFile(token, syncLocation) { async openFile(token, syncLocation) {
// Check if the file exists and open it // Check if the file exists and open it
if (!Provider.openFileWithLocation(store.getters['syncLocation/items'], syncLocation)) { if (!Provider.openFileWithLocation(syncLocation)) {
// Download content from GitHub // Download content from GitHub
let content; let content;
try { try {
@ -79,7 +83,7 @@ export default new Provider({
if (dotPos > 0 && slashPos < name.length) { if (dotPos > 0 && slashPos < name.length) {
name = name.slice(0, dotPos); name = name.slice(0, dotPos);
} }
const item = await fileSvc.createFile({ const item = await workspaceSvc.createFile({
name, name,
parentId: store.getters['file/current'].parentId, parentId: store.getters['file/current'].parentId,
text: content.text, text: content.text,
@ -88,21 +92,13 @@ export default new Provider({
comments: content.comments, comments: content.comments,
}, true); }, true);
store.commit('file/setCurrentId', item.id); store.commit('file/setCurrentId', item.id);
store.commit('syncLocation/setItem', { workspaceSvc.addSyncLocation({
...syncLocation, ...syncLocation,
id: utils.uid(),
fileId: item.id, fileId: item.id,
}); });
store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from GitHub.`); store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from GitHub.`);
} }
}, },
parseRepoUrl(url) {
const parsedRepo = url && url.match(/([^/:]+)\/([^/]+?)(?:\.git|\/)?$/);
return parsedRepo && {
owner: parsedRepo[1],
repo: parsedRepo[2],
};
},
makeLocation(token, owner, repo, branch, path) { makeLocation(token, owner, repo, branch, path) {
return { return {
providerId: this.id, providerId: this.id,

View File

@ -4,18 +4,8 @@ import Provider from './common/Provider';
import utils from '../utils'; import utils from '../utils';
import userSvc from '../userSvc'; import userSvc from '../userSvc';
const getAbsolutePath = syncData => const getAbsolutePath = ({ id }) =>
`${store.getters['workspace/currentWorkspace'].path || ''}${syncData.id}`; `${store.getters['workspace/currentWorkspace'].path || ''}${id}`;
const getWorkspaceWithOwner = () => {
const workspace = store.getters['workspace/currentWorkspace'];
const [owner, repo] = workspace.repo.split('/');
return {
...workspace,
owner,
repo,
};
};
let treeShaMap; let treeShaMap;
let treeFolderMap; let treeFolderMap;
@ -31,14 +21,38 @@ export default new Provider({
getToken() { getToken() {
return store.getters['workspace/syncToken']; return store.getters['workspace/syncToken'];
}, },
async initWorkspace() { getWorkspaceParams({
const [owner, repo] = (utils.queryParams.repo || '').split('/'); owner,
const { branch } = utils.queryParams; repo,
const workspaceParams = { branch,
path,
}) {
return {
providerId: this.id, providerId: this.id,
repo: `${owner}/${repo}`, owner,
repo,
branch, branch,
path,
}; };
},
getWorkspaceLocationUrl({
owner,
repo,
branch,
path,
}) {
return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`;
},
getSyncDataUrl({ id }) {
const { owner, repo, branch } = store.getters['workspace/currentWorkspace'];
return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(getAbsolutePath({ id }))}`;
},
getSyncDataDescription({ id }) {
return getAbsolutePath({ id });
},
async initWorkspace() {
const { owner, repo, branch } = utils.queryParams;
const workspaceParams = this.getWorkspaceParams({ owner, repo, branch });
const path = (utils.queryParams.path || '') const path = (utils.queryParams.path || '')
.replace(/^\/*/, '') // Remove leading `/` .replace(/^\/*/, '') // Remove leading `/`
.replace(/\/*$/, '/'); // Add trailing `/` .replace(/\/*$/, '/'); // Add trailing `/`
@ -46,7 +60,7 @@ export default new Provider({
workspaceParams.path = path; workspaceParams.path = path;
} }
const workspaceId = utils.makeWorkspaceId(workspaceParams); const workspaceId = utils.makeWorkspaceId(workspaceParams);
let workspace = store.getters['data/sanitizedWorkspacesById'][workspaceId]; const workspace = store.getters['workspace/workspacesById'][workspaceId];
// See if we already have a token // See if we already have a token
let token; let token;
@ -62,34 +76,23 @@ export default new Provider({
if (!workspace) { if (!workspace) {
const pathEntries = (path || '').split('/'); const pathEntries = (path || '').split('/');
const name = pathEntries[pathEntries.length - 2] || repo; // path ends with `/` const name = pathEntries[pathEntries.length - 2] || repo; // path ends with `/`
workspace = { store.dispatch('workspace/patchWorkspacesById', {
...workspaceParams,
id: workspaceId,
sub: token.sub,
name,
};
}
// Fix the URL hash
utils.setQueryParams(workspaceParams);
if (workspace.url !== window.location.href) {
store.dispatch('data/patchWorkspacesById', {
[workspaceId]: { [workspaceId]: {
...workspace, ...workspaceParams,
url: window.location.href, id: workspaceId,
sub: token.sub,
name,
}, },
}); });
} }
return store.getters['data/sanitizedWorkspacesById'][workspaceId];
return store.getters['workspace/workspacesById'][workspaceId];
}, },
getChanges() { getChanges() {
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];
const { owner, repo, branch } = getWorkspaceWithOwner();
return githubHelper.getTree({ return githubHelper.getTree({
...store.getters['workspace/currentWorkspace'],
token: syncToken, token: syncToken,
owner,
repo,
branch,
}); });
}, },
prepareChanges(tree) { prepareChanges(tree) {
@ -322,7 +325,7 @@ export default new Provider({
// locations are stored as paths, so we upload an empty file // locations are stored as paths, so we upload an empty file
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];
await githubHelper.uploadFile({ await githubHelper.uploadFile({
...getWorkspaceWithOwner(), ...store.getters['workspace/currentWorkspace'],
token: syncToken, token: syncToken,
path: getAbsolutePath(syncData), path: getAbsolutePath(syncData),
content: '', content: '',
@ -334,7 +337,7 @@ export default new Provider({
if (treeShaMap[syncData.id]) { if (treeShaMap[syncData.id]) {
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];
await githubHelper.removeFile({ await githubHelper.removeFile({
...getWorkspaceWithOwner(), ...store.getters['workspace/currentWorkspace'],
token: syncToken, token: syncToken,
path: getAbsolutePath(syncData), path: getAbsolutePath(syncData),
sha: treeShaMap[syncData.id], sha: treeShaMap[syncData.id],
@ -348,7 +351,7 @@ export default new Provider({
fileSyncData, fileSyncData,
}) { }) {
const { sha, data } = await githubHelper.downloadFile({ const { sha, data } = await githubHelper.downloadFile({
...getWorkspaceWithOwner(), ...store.getters['workspace/currentWorkspace'],
token, token,
path: getAbsolutePath(fileSyncData), path: getAbsolutePath(fileSyncData),
}); });
@ -369,7 +372,7 @@ export default new Provider({
} }
const { sha, data } = await githubHelper.downloadFile({ const { sha, data } = await githubHelper.downloadFile({
...getWorkspaceWithOwner(), ...store.getters['workspace/currentWorkspace'],
token, token,
path: getAbsolutePath(syncData), path: getAbsolutePath(syncData),
}); });
@ -388,7 +391,7 @@ export default new Provider({
const path = store.getters.gitPathsByItemId[file.id]; const path = store.getters.gitPathsByItemId[file.id];
const absolutePath = `${store.getters['workspace/currentWorkspace'].path || ''}${path}`; const absolutePath = `${store.getters['workspace/currentWorkspace'].path || ''}${path}`;
const res = await githubHelper.uploadFile({ const res = await githubHelper.uploadFile({
...getWorkspaceWithOwner(), ...store.getters['workspace/currentWorkspace'],
token, token,
path: absolutePath, path: absolutePath,
content: Provider.serializeContent(content), content: Provider.serializeContent(content),
@ -418,7 +421,7 @@ export default new Provider({
hash: item.hash, hash: item.hash,
}; };
const res = await githubHelper.uploadFile({ const res = await githubHelper.uploadFile({
...getWorkspaceWithOwner(), ...store.getters['workspace/currentWorkspace'],
token, token,
path: getAbsolutePath(syncData), path: getAbsolutePath(syncData),
content: JSON.stringify(item), content: JSON.stringify(item),
@ -432,17 +435,8 @@ export default new Provider({
}, },
}; };
}, },
onSyncEnd() {
// Clean up
treeShaMap = null;
treeFolderMap = null;
treeFileMap = null;
treeDataMap = null;
treeSyncLocationMap = null;
treePublishLocationMap = null;
},
async listRevisions(token, fileId) { async listRevisions(token, fileId) {
const { owner, repo, branch } = getWorkspaceWithOwner(); const { owner, repo, branch } = store.getters['workspace/currentWorkspace'];
const syncData = Provider.getContentSyncData(fileId); const syncData = Provider.getContentSyncData(fileId);
const entries = await githubHelper.getCommits({ const entries = await githubHelper.getCommits({
token, token,
@ -478,7 +472,7 @@ export default new Provider({
async getRevisionContent(token, fileId, revisionId) { async getRevisionContent(token, fileId, revisionId) {
const syncData = Provider.getContentSyncData(fileId); const syncData = Provider.getContentSyncData(fileId);
const { data } = await githubHelper.downloadFile({ const { data } = await githubHelper.downloadFile({
...getWorkspaceWithOwner(), ...store.getters['workspace/currentWorkspace'],
token, token,
branch: revisionId, branch: revisionId,
path: getAbsolutePath(syncData), path: getAbsolutePath(syncData),

View File

@ -10,12 +10,25 @@ export default new Provider({
getToken() { getToken() {
return store.getters['workspace/syncToken']; return store.getters['workspace/syncToken'];
}, },
getWorkspaceParams() {
// No param as it's the main workspace
return {};
},
getWorkspaceLocationUrl() {
// No direct link to app data
return null;
},
getSyncDataUrl() {
// No direct link to app data
return null;
},
getSyncDataDescription({ id }) {
return id;
},
async initWorkspace() { async initWorkspace() {
// Nothing much to do since the main workspace isn't necessarily synchronized // Nothing much to do since the main workspace isn't necessarily synchronized
// Remove the URL hash
utils.setQueryParams();
// Return the main workspace // Return the main workspace
return store.getters['data/workspacesById'].main; return store.getters['workspace/workspacesById'].main;
}, },
async getChanges() { async getChanges() {
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];
@ -155,7 +168,7 @@ export default new Provider({
const revisions = await googleHelper.getAppDataFileRevisions(token, syncData.id); const revisions = await googleHelper.getAppDataFileRevisions(token, syncData.id);
return revisions.map(revision => ({ return revisions.map(revision => ({
id: revision.id, id: revision.id,
sub: revision.lastModifyingUser && `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); .sort((revision1, revision2) => revision2.created - revision1.created);

View File

@ -2,20 +2,19 @@ import store from '../../store';
import googleHelper from './helpers/googleHelper'; import googleHelper from './helpers/googleHelper';
import Provider from './common/Provider'; import Provider from './common/Provider';
import utils from '../utils'; import utils from '../utils';
import fileSvc from '../fileSvc'; import workspaceSvc from '../workspaceSvc';
export default new Provider({ export default new Provider({
id: 'googleDrive', id: 'googleDrive',
getToken(location) { getToken({ sub }) {
const token = store.getters['data/googleTokensBySub'][location.sub]; const token = store.getters['data/googleTokensBySub'][sub];
return token && token.isDrive ? token : null; return token && token.isDrive ? token : null;
}, },
getUrl(location) { getLocationUrl({ driveFileId }) {
return `https://docs.google.com/file/d/${location.driveFileId}/edit`; return `https://docs.google.com/file/d/${driveFileId}/edit`;
}, },
getDescription(location) { getLocationDescription({ driveFileId }) {
const token = this.getToken(location); return driveFileId;
return `${location.driveFileId}${token.name}`;
}, },
async initAction() { async initAction() {
const state = googleHelper.driveState || {}; const state = googleHelper.driveState || {};
@ -42,7 +41,7 @@ export default new Provider({
folderId, folderId,
}; };
const workspaceId = utils.makeWorkspaceId(workspaceParams); const workspaceId = utils.makeWorkspaceId(workspaceParams);
const workspace = store.getters['data/sanitizedWorkspacesById'][workspaceId]; const workspace = store.getters['workspace/workspacesById'][workspaceId];
// If we have the workspace, open it by changing the current URL // If we have the workspace, open it by changing the current URL
if (workspace) { if (workspace) {
utils.setQueryParams(workspaceParams); utils.setQueryParams(workspaceParams);
@ -86,7 +85,7 @@ export default new Provider({
const token = store.getters['data/googleTokensBySub'][state.userId]; const token = store.getters['data/googleTokensBySub'][state.userId];
switch (token && state.action) { switch (token && state.action) {
case 'create': { case 'create': {
const file = await fileSvc.createFile({}, true); const file = await workspaceSvc.createFile({}, true);
store.commit('file/setCurrentId', file.id); store.commit('file/setCurrentId', file.id);
// Return a new syncLocation // Return a new syncLocation
return this.makeLocation(token, null, googleHelper.driveActionFolder.id); return this.makeLocation(token, null, googleHelper.driveActionFolder.id);
@ -142,7 +141,7 @@ export default new Provider({
async openFiles(token, driveFiles) { async openFiles(token, driveFiles) {
return utils.awaitSequence(driveFiles, async (driveFile) => { return utils.awaitSequence(driveFiles, async (driveFile) => {
// Check if the file exists and open it // Check if the file exists and open it
if (!Provider.openFileWithLocation(store.getters['syncLocation/items'], { if (!Provider.openFileWithLocation({
providerId: this.id, providerId: this.id,
driveFileId: driveFile.id, driveFileId: driveFile.id,
})) { })) {
@ -161,7 +160,7 @@ export default new Provider({
} }
// Create the file // Create the file
const item = await fileSvc.createFile({ const item = await workspaceSvc.createFile({
name: driveFile.name, name: driveFile.name,
parentId: store.getters['file/current'].parentId, parentId: store.getters['file/current'].parentId,
text: content.text, text: content.text,
@ -170,9 +169,8 @@ export default new Provider({
comments: content.comments, comments: content.comments,
}, true); }, true);
store.commit('file/setCurrentId', item.id); store.commit('file/setCurrentId', item.id);
store.commit('syncLocation/setItem', { workspaceSvc.addSyncLocation({
...syncLocation, ...syncLocation,
id: utils.uid(),
fileId: item.id, fileId: item.id,
}); });
store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Google Drive.`); store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Google Drive.`);

View File

@ -2,7 +2,7 @@ import store from '../../store';
import googleHelper from './helpers/googleHelper'; import googleHelper from './helpers/googleHelper';
import Provider from './common/Provider'; import Provider from './common/Provider';
import utils from '../utils'; import utils from '../utils';
import fileSvc from '../fileSvc'; import workspaceSvc from '../workspaceSvc';
let fileIdToOpen; let fileIdToOpen;
let syncStartPageToken; let syncStartPageToken;
@ -12,17 +12,27 @@ export default new Provider({
getToken() { getToken() {
return store.getters['workspace/syncToken']; return store.getters['workspace/syncToken'];
}, },
async initWorkspace() { getWorkspaceParams({ folderId }) {
const makeWorkspaceParams = folderId => ({ return {
providerId: this.id, providerId: this.id,
folderId, folderId,
}); };
},
getWorkspaceLocationUrl({ folderId }) {
return `https://docs.google.com/folder/d/${folderId}`;
},
getSyncDataUrl({ id }) {
return `https://docs.google.com/file/d/${id}/edit`;
},
getSyncDataDescription({ id }) {
return id;
},
async initWorkspace() {
const makeWorkspaceId = folderId => folderId const makeWorkspaceId = folderId => folderId
&& utils.makeWorkspaceId(makeWorkspaceParams(folderId)); && utils.makeWorkspaceId(this.getWorkspaceParams({ folderId }));
const getWorkspace = folderId => const getWorkspace = folderId =>
store.getters['data/sanitizedWorkspacesById'][makeWorkspaceId(folderId)]; store.getters['workspace/workspacesById'][makeWorkspaceId(folderId)];
const initFolder = async (token, folder) => { const initFolder = async (token, folder) => {
const appProperties = { const appProperties = {
@ -33,24 +43,26 @@ export default new Provider({
// Make sure data folder exists // Make sure data folder exists
if (!appProperties.dataFolderId) { if (!appProperties.dataFolderId) {
appProperties.dataFolderId = (await googleHelper.uploadFile({ const dataFolder = await googleHelper.uploadFile({
token, token,
name: '.stackedit-data', name: '.stackedit-data',
parents: [folder.id], parents: [folder.id],
appProperties: { folderId: folder.id }, appProperties: { folderId: folder.id },
mediaType: googleHelper.folderMimeType, mediaType: googleHelper.folderMimeType,
})).id; });
appProperties.dataFolderId = dataFolder.id;
} }
// Make sure trash folder exists // Make sure trash folder exists
if (!appProperties.trashFolderId) { if (!appProperties.trashFolderId) {
appProperties.trashFolderId = (await googleHelper.uploadFile({ const trashFolder = await googleHelper.uploadFile({
token, token,
name: '.stackedit-trash', name: '.stackedit-trash',
parents: [folder.id], parents: [folder.id],
appProperties: { folderId: folder.id }, appProperties: { folderId: folder.id },
mediaType: googleHelper.folderMimeType, mediaType: googleHelper.folderMimeType,
})).id; });
appProperties.trashFolderId = trashFolder.id;
} }
// Update workspace if some properties are missing // Update workspace if some properties are missing
@ -68,13 +80,12 @@ export default new Provider({
// Update workspace in the store // Update workspace in the store
const workspaceId = makeWorkspaceId(folder.id); const workspaceId = makeWorkspaceId(folder.id);
store.dispatch('data/patchWorkspacesById', { store.dispatch('workspace/patchWorkspacesById', {
[workspaceId]: { [workspaceId]: {
id: workspaceId, id: workspaceId,
sub: token.sub, sub: token.sub,
name: folder.name, name: folder.name,
providerId: this.id, providerId: this.id,
url: window.location.href,
folderId: folder.id, folderId: folder.id,
teamDriveId: folder.teamDriveId, teamDriveId: folder.teamDriveId,
dataFolderId: appProperties.dataFolderId, dataFolderId: appProperties.dataFolderId,
@ -110,11 +121,10 @@ export default new Provider({
} }
// Init workspace // Init workspace
let workspace = getWorkspace(folderId); if (!getWorkspace(folderId)) {
if (!workspace) {
let folder; let folder;
try { try {
folder = googleHelper.getFile(token, folderId); folder = await googleHelper.getFile(token, folderId);
} catch (err) { } catch (err) {
throw new Error(`Folder ${folderId} is not accessible. Make sure you have the right permissions.`); throw new Error(`Folder ${folderId} is not accessible. Make sure you have the right permissions.`);
} }
@ -124,20 +134,9 @@ export default new Provider({
throw new Error(`Folder ${folderId} is part of another workspace.`); throw new Error(`Folder ${folderId} is part of another workspace.`);
} }
await initFolder(token, folder); await initFolder(token, folder);
workspace = getWorkspace(folderId);
} }
// Fix the URL hash return getWorkspace(folderId);
utils.setQueryParams(makeWorkspaceParams(workspace.folderId));
if (workspace.url !== window.location.href) {
store.dispatch('data/patchWorkspacesById', {
[workspace.id]: {
...workspace,
url: window.location.href,
},
});
}
return store.getters['data/sanitizedWorkspacesById'][workspace.id];
}, },
async performAction() { async performAction() {
const state = googleHelper.driveState || {}; const state = googleHelper.driveState || {};
@ -163,7 +162,7 @@ export default new Provider({
[syncData.id]: syncData, [syncData.id]: syncData,
}); });
} }
const file = await fileSvc.createFile({ const file = await workspaceSvc.createFile({
parentId: syncData && syncData.itemId, parentId: syncData && syncData.itemId,
}, true); }, true);
store.commit('file/setCurrentId', file.id); store.commit('file/setCurrentId', file.id);
@ -486,6 +485,7 @@ export default new Provider({
folderId: workspace.folderId, folderId: workspace.folderId,
}, },
media: JSON.stringify(item), media: JSON.stringify(item),
mediaType: 'application/json',
fileId: syncData && syncData.id, fileId: syncData && syncData.id,
oldParents: syncData && syncData.parentIds, oldParents: syncData && syncData.parentIds,
ifNotTooLate, ifNotTooLate,

View File

@ -8,13 +8,16 @@ const getAppKey = (fullAccess) => {
return 'sw0hlixhr8q1xk0'; return 'sw0hlixhr8q1xk0';
}; };
const httpHeaderSafeJson = args => args && JSON.stringify(args)
.replace(/[\u007f-\uffff]/g, c => `\\u${`000${c.charCodeAt(0).toString(16)}`.slice(-4)}`);
const request = (token, options, args) => networkSvc.request({ const request = (token, 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': args && JSON.stringify(args), 'Dropbox-API-Arg': httpHeaderSafeJson(args),
Authorization: `Bearer ${token.accessToken}`, Authorization: `Bearer ${token.accessToken}`,
}, },
}); });

View File

@ -413,7 +413,7 @@ export default {
}); });
revisions.forEach((revision) => { revisions.forEach((revision) => {
store.commit('userInfo/addItem', { store.commit('userInfo/addItem', {
id: revision.lastModifyingUser.permissionId, id: `go:${revision.lastModifyingUser.permissionId}`,
name: revision.lastModifyingUser.displayName, name: revision.lastModifyingUser.displayName,
imageUrl: revision.lastModifyingUser.photoLink, imageUrl: revision.lastModifyingUser.photoLink,
}); });

View File

@ -7,12 +7,11 @@ export default new Provider({
getToken(location) { getToken(location) {
return store.getters['data/wordpressTokensBySub'][location.sub]; return store.getters['data/wordpressTokensBySub'][location.sub];
}, },
getUrl(location) { getLocationUrl(location) {
return `https://wordpress.com/post/${location.siteId}/${location.postId}`; return `https://wordpress.com/post/${location.siteId}/${location.postId}`;
}, },
getDescription(location) { getLocationDescription({ postId }) {
const token = this.getToken(location); return postId;
return `${location.postId}${location.domain}${token.name}`;
}, },
async publish(token, html, metadata, publishLocation) { async publish(token, html, metadata, publishLocation) {
const post = await wordpressHelper.uploadPost({ const post = await wordpressHelper.uploadPost({

View File

@ -7,13 +7,12 @@ export default new Provider({
getToken(location) { getToken(location) {
return store.getters['data/zendeskTokensBySub'][location.sub]; return store.getters['data/zendeskTokensBySub'][location.sub];
}, },
getUrl(location) { getLocationUrl(location) {
const token = this.getToken(location); const token = this.getToken(location);
return `https://${token.subdomain}.zendesk.com/hc/${location.locale}/articles/${location.articleId}`; return `https://${token.subdomain}.zendesk.com/hc/${location.locale}/articles/${location.articleId}`;
}, },
getDescription(location) { getLocationDescription({ articleId }) {
const token = this.getToken(location); return articleId;
return `${location.articleId}${token.name}${token.subdomain}`;
}, },
async publish(token, html, metadata, publishLocation) { async publish(token, html, metadata, publishLocation) {
const articleId = await zendeskHelper.uploadArticle({ const articleId = await zendeskHelper.uploadArticle({

View File

@ -4,6 +4,7 @@ import utils from './utils';
import networkSvc from './networkSvc'; import networkSvc from './networkSvc';
import exportSvc from './exportSvc'; import exportSvc from './exportSvc';
import providerRegistry from './providers/common/providerRegistry'; import providerRegistry from './providers/common/providerRegistry';
import workspaceSvc from './workspaceSvc';
const hasCurrentFilePublishLocations = () => !!store.getters['publishLocation/current'].length; const hasCurrentFilePublishLocations = () => !!store.getters['publishLocation/current'].length;
@ -45,7 +46,7 @@ const publish = async (publishLocation) => {
const content = await localDbSvc.loadItem(`${fileId}/content`); const content = await localDbSvc.loadItem(`${fileId}/content`);
const file = store.state.file.itemsById[fileId]; const file = store.state.file.itemsById[fileId];
const properties = utils.computeProperties(content.properties); const properties = utils.computeProperties(content.properties);
const provider = providerRegistry.providers[publishLocation.providerId]; const provider = providerRegistry.providersById[publishLocation.providerId];
const token = provider.getToken(publishLocation); const token = provider.getToken(publishLocation);
const metadata = { const metadata = {
title: ensureString(properties.title, file.name), title: ensureString(properties.title, file.name),
@ -122,14 +123,12 @@ const requestPublish = () => {
}; };
const createPublishLocation = (publishLocation) => { const createPublishLocation = (publishLocation) => {
publishLocation.id = utils.uid();
const currentFile = store.getters['file/current']; const currentFile = store.getters['file/current'];
publishLocation.fileId = currentFile.id; publishLocation.fileId = currentFile.id;
store.dispatch( store.dispatch(
'queue/enqueue', 'queue/enqueue',
async () => { async () => {
const publishLocationToStore = await publish(publishLocation); workspaceSvc.addPublishLocation(await publish(publishLocation));
store.commit('publishLocation/setItem', publishLocationToStore);
store.dispatch('notification/info', `A new publication location was added to "${currentFile.name}".`); store.dispatch('notification/info', `A new publication location was added to "${currentFile.name}".`);
}, },
); );

View File

@ -1,113 +0,0 @@
class SectionDimension {
constructor(startOffset, endOffset) {
this.startOffset = startOffset;
this.endOffset = endOffset;
this.height = endOffset - startOffset;
}
}
const dimensionNormalizer = dimensionName => (editorSvc) => {
const dimensionList = editorSvc.previewCtx.sectionDescList
.map(sectionDesc => sectionDesc[dimensionName]);
let dimension;
let i;
let j;
for (i = 0; i < dimensionList.length; i += 1) {
dimension = dimensionList[i];
if (dimension.height) {
for (j = i + 1; j < dimensionList.length && dimensionList[j].height === 0; j += 1) {
// Loop
}
const normalizeFactor = j - i;
if (normalizeFactor !== 1) {
const normalizedHeight = dimension.height / normalizeFactor;
dimension.height = normalizedHeight;
dimension.endOffset = dimension.startOffset + dimension.height;
for (j = i + 1; j < i + normalizeFactor; j += 1) {
const startOffset = dimension.endOffset;
dimension = dimensionList[j];
dimension.startOffset = startOffset;
dimension.height = normalizedHeight;
dimension.endOffset = dimension.startOffset + dimension.height;
}
i = j - 1;
}
}
}
};
const normalizeEditorDimensions = dimensionNormalizer('editorDimension');
const normalizePreviewDimensions = dimensionNormalizer('previewDimension');
const normalizeTocDimensions = dimensionNormalizer('tocDimension');
const measureSectionDimensions = (editorSvc) => {
let editorSectionOffset = 0;
let previewSectionOffset = 0;
let tocSectionOffset = 0;
let sectionDesc = editorSvc.previewCtx.sectionDescList[0];
let nextSectionDesc;
let i = 1;
for (; i < editorSvc.previewCtx.sectionDescList.length; i += 1) {
nextSectionDesc = editorSvc.previewCtx.sectionDescList[i];
// Measure editor section
let newEditorSectionOffset = nextSectionDesc.editorElt
? nextSectionDesc.editorElt.offsetTop
: editorSectionOffset;
newEditorSectionOffset = newEditorSectionOffset > editorSectionOffset
? newEditorSectionOffset
: editorSectionOffset;
sectionDesc.editorDimension = new SectionDimension(editorSectionOffset, newEditorSectionOffset);
editorSectionOffset = newEditorSectionOffset;
// Measure preview section
let newPreviewSectionOffset = nextSectionDesc.previewElt
? nextSectionDesc.previewElt.offsetTop
: previewSectionOffset;
newPreviewSectionOffset = newPreviewSectionOffset > previewSectionOffset
? newPreviewSectionOffset
: previewSectionOffset;
sectionDesc.previewDimension = new SectionDimension(
previewSectionOffset,
newPreviewSectionOffset,
);
previewSectionOffset = newPreviewSectionOffset;
// Measure TOC section
let newTocSectionOffset = nextSectionDesc.tocElt
? nextSectionDesc.tocElt.offsetTop + (nextSectionDesc.tocElt.offsetHeight / 2)
: tocSectionOffset;
newTocSectionOffset = newTocSectionOffset > tocSectionOffset
? newTocSectionOffset
: tocSectionOffset;
sectionDesc.tocDimension = new SectionDimension(tocSectionOffset, newTocSectionOffset);
tocSectionOffset = newTocSectionOffset;
sectionDesc = nextSectionDesc;
}
// Last section
sectionDesc = editorSvc.previewCtx.sectionDescList[i - 1];
if (sectionDesc) {
sectionDesc.editorDimension = new SectionDimension(
editorSectionOffset,
editorSvc.editorElt.scrollHeight,
);
sectionDesc.previewDimension = new SectionDimension(
previewSectionOffset,
editorSvc.previewElt.scrollHeight,
);
sectionDesc.tocDimension = new SectionDimension(
tocSectionOffset,
editorSvc.tocElt.scrollHeight,
);
}
normalizeEditorDimensions(editorSvc);
normalizePreviewDimensions(editorSvc);
normalizeTocDimensions(editorSvc);
};
export default {
measureSectionDimensions,
};

View File

@ -9,12 +9,12 @@ import './providers/couchdbWorkspaceProvider';
import './providers/githubWorkspaceProvider'; import './providers/githubWorkspaceProvider';
import './providers/googleDriveWorkspaceProvider'; import './providers/googleDriveWorkspaceProvider';
import tempFileSvc from './tempFileSvc'; import tempFileSvc from './tempFileSvc';
import fileSvc from './fileSvc'; import workspaceSvc from './workspaceSvc';
const minAutoSyncEvery = 60 * 1000; // 60 sec const minAutoSyncEvery = 60 * 1000; // 60 sec
const inactivityThreshold = 3 * 1000; // 3 sec const inactivityThreshold = 3 * 1000; // 3 sec
const restartSyncAfter = 30 * 1000; // 30 sec const restartSyncAfter = 30 * 1000; // 30 sec
const restartContentSyncAfter = 500; // Restart if an authorize window pops up const restartContentSyncAfter = 1000; // Enough to detect an authorize pop up
const maxContentHistory = 20; const maxContentHistory = 20;
const LAST_SEEN = 0; const LAST_SEEN = 0;
@ -112,9 +112,11 @@ const cleanSyncedContent = (syncedContent) => {
delete syncedContent.syncHistory[syncLocationId]; delete syncedContent.syncHistory[syncLocationId];
} }
}); });
const allSyncLocationHashSet = new Set([] const allSyncLocationHashSet = new Set([]
.concat(...Object.keys(syncedContent.syncHistory) .concat(...Object.keys(syncedContent.syncHistory)
.map(id => syncedContent.syncHistory[id]))); .map(id => syncedContent.syncHistory[id])));
// Clean historyData from unused contents // Clean historyData from unused contents
Object.keys(syncedContent.historyData) Object.keys(syncedContent.historyData)
.map(hash => parseInt(hash, 10)) .map(hash => parseInt(hash, 10))
@ -131,9 +133,11 @@ const cleanSyncedContent = (syncedContent) => {
const applyChanges = (changes) => { const applyChanges = (changes) => {
const allItemsById = { ...store.getters.allItemsById }; const allItemsById = { ...store.getters.allItemsById };
const syncDataById = { ...store.getters['data/syncDataById'] }; const syncDataById = { ...store.getters['data/syncDataById'] };
const idsToKeep = {};
let saveSyncData = false;
let getExistingItem; let getExistingItem;
if (workspaceProvider.isGit) { if (store.getters['workspace/currentWorkspaceIsGit']) {
const { itemsByGitPath } = store.getters; const itemsByGitPath = { ...store.getters.itemsByGitPath };
getExistingItem = (existingSyncData) => { getExistingItem = (existingSyncData) => {
const items = existingSyncData && itemsByGitPath[existingSyncData.id]; const items = existingSyncData && itemsByGitPath[existingSyncData.id];
return items ? items[0] : null; return items ? items[0] : null;
@ -142,9 +146,6 @@ const applyChanges = (changes) => {
getExistingItem = existingSyncData => existingSyncData && allItemsById[existingSyncData.itemId]; getExistingItem = existingSyncData => existingSyncData && allItemsById[existingSyncData.itemId];
} }
const idsToKeep = {};
let saveSyncData = false;
// Process each change // Process each change
changes.forEach((change) => { changes.forEach((change) => {
const existingSyncData = syncDataById[change.syncDataId]; const existingSyncData = syncDataById[change.syncDataId];
@ -184,7 +185,10 @@ const applyChanges = (changes) => {
if (saveSyncData) { if (saveSyncData) {
store.dispatch('data/setSyncDataById', syncDataById); store.dispatch('data/setSyncDataById', syncDataById);
fileSvc.ensureUniquePaths(idsToKeep);
// Sanitize the workspace
workspaceSvc.ensureUniquePaths(idsToKeep);
workspaceSvc.ensureUniqueLocations(idsToKeep);
} }
}; };
@ -192,18 +196,17 @@ const applyChanges = (changes) => {
* Create a sync location by uploading the current file content. * Create a sync location by uploading the current file content.
*/ */
const createSyncLocation = (syncLocation) => { const createSyncLocation = (syncLocation) => {
syncLocation.id = utils.uid();
const currentFile = store.getters['file/current']; const currentFile = store.getters['file/current'];
const fileId = currentFile.id; const fileId = currentFile.id;
syncLocation.fileId = fileId; syncLocation.fileId = fileId;
// Use deepCopy to freeze item // Use deepCopy to freeze the item
const content = utils.deepCopy(store.getters['content/current']); const content = utils.deepCopy(store.getters['content/current']);
store.dispatch( store.dispatch(
'queue/enqueue', 'queue/enqueue',
async () => { async () => {
const provider = providerRegistry.providers[syncLocation.providerId]; const provider = providerRegistry.providersById[syncLocation.providerId];
const token = provider.getToken(syncLocation); const token = provider.getToken(syncLocation);
const syncLocationToStore = await provider.uploadContent(token, { const updatedSyncLocation = await provider.uploadContent(token, {
...content, ...content,
history: [content.hash], history: [content.hash],
}, syncLocation); }, syncLocation);
@ -216,7 +219,7 @@ const createSyncLocation = (syncLocation) => {
newSyncedContent.historyData[content.hash] = content; newSyncedContent.historyData[content.hash] = content;
store.commit('syncedContent/patchItem', newSyncedContent); store.commit('syncedContent/patchItem', newSyncedContent);
store.commit('syncLocation/setItem', syncLocationToStore); workspaceSvc.addSyncLocation(updatedSyncLocation);
store.dispatch('notification/info', `A new synchronized location was added to "${currentFile.name}".`); store.dispatch('notification/info', `A new synchronized location was added to "${currentFile.name}".`);
}, },
); );
@ -241,7 +244,7 @@ const tooLateChecker = (timeout) => {
const isTempFile = (fileId) => { const isTempFile = (fileId) => {
const contentId = `${fileId}/content`; const contentId = `${fileId}/content`;
if (store.getters['data/syncDataByItemId'][contentId]) { if (store.getters['data/syncDataByItemId'][contentId]) {
// If file has already been synced, it's not a temp file // If file has already been synced, let's not consider it a temp file
return false; return false;
} }
const file = store.state.file.itemsById[fileId]; const file = store.state.file.itemsById[fileId];
@ -271,8 +274,11 @@ const isTempFile = (fileId) => {
* Patch sync data if some have changed in the result. * Patch sync data if some have changed in the result.
*/ */
const updateSyncData = (result) => { const updateSyncData = (result) => {
['syncData', 'contentSyncData', 'fileSyncData'].forEach((field) => { [
const syncData = result[field]; result.syncData,
result.contentSyncData,
result.fileSyncData,
].forEach((syncData) => {
if (syncData) { if (syncData) {
const oldSyncData = store.getters['data/syncDataById'][syncData.id]; const oldSyncData = store.getters['data/syncDataById'][syncData.id];
if (utils.serializeObject(oldSyncData) !== utils.serializeObject(syncData)) { if (utils.serializeObject(oldSyncData) !== utils.serializeObject(syncData)) {
@ -286,7 +292,7 @@ const updateSyncData = (result) => {
}; };
class SyncContext { class SyncContext {
restart = false; restartSkipContents = false;
attempted = {}; attempted = {};
} }
@ -320,7 +326,7 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
} }
await utils.awaitSequence(syncLocations, async (syncLocation) => { await utils.awaitSequence(syncLocations, async (syncLocation) => {
const provider = providerRegistry.providers[syncLocation.providerId]; const provider = providerRegistry.providersById[syncLocation.providerId];
if (!provider) { if (!provider) {
return; return;
} }
@ -491,7 +497,7 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
if (provider === workspaceProvider && if (provider === workspaceProvider &&
!store.getters['data/syncDataByItemId'][fileId] !store.getters['data/syncDataByItemId'][fileId]
) { ) {
syncContext.restart = true; syncContext.restartSkipContents = true;
} }
}; };
@ -514,15 +520,16 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
if (err && err.message === 'TOO_LATE') { if (err && err.message === 'TOO_LATE') {
// Restart sync // Restart sync
await syncFile(fileId, syncContext); await syncFile(fileId, syncContext);
} else {
throw err;
} }
throw err;
} finally { } finally {
await localDbSvc.unloadContents(); await localDbSvc.unloadContents();
} }
}; };
/** /**
* Sync a data item, typically settings, workspaces and templates. * Sync a data item, typically settings, templates or workspaces.
*/ */
const syncDataItem = async (dataId) => { const syncDataItem = async (dataId) => {
const getItem = () => store.state.data.itemsById[dataId] const getItem = () => store.state.data.itemsById[dataId]
@ -543,8 +550,8 @@ const syncDataItem = async (dataId) => {
const serverItem = item; const serverItem = item;
const dataSyncData = store.getters['data/dataSyncDataById'][dataId]; const dataSyncData = store.getters['data/dataSyncDataById'][dataId];
const clientItem = utils.deepCopy(getItem());
let mergedItem = (() => { let mergedItem = (() => {
const clientItem = utils.deepCopy(getItem());
if (!clientItem) { if (!clientItem) {
return serverItem; return serverItem;
} }
@ -572,6 +579,13 @@ const syncDataItem = async (dataId) => {
return; return;
} }
if (clientItem && dataId === 'workspaces') {
// Clean deleted workspaces
await Promise.all(Object.keys(clientItem.data)
.filter(id => !mergedItem.data[id])
.map(id => workspaceSvc.removeWorkspace(id)));
}
// Update item in store // Update item in store
store.commit('data/setItem', { store.commit('data/setItem', {
id: dataId, id: dataId,
@ -581,19 +595,17 @@ const syncDataItem = async (dataId) => {
// Retrieve item with new `hash` and freeze it // Retrieve item with new `hash` and freeze it
mergedItem = utils.deepCopy(getItem()); mergedItem = utils.deepCopy(getItem());
if (serverItem && serverItem.hash === mergedItem.hash) { // Upload merged data item if out of sync
return; if (!serverItem || serverItem.hash !== mergedItem.hash) {
updateSyncData(await workspaceProvider.uploadWorkspaceData({
token,
item: mergedItem,
syncData: store.getters['data/syncDataByItemId'][dataId],
ifNotTooLate: tooLateChecker(restartContentSyncAfter),
}));
} }
// Upload merged data item // Copy sync data into data sync data
updateSyncData(await workspaceProvider.uploadWorkspaceData({
token,
item: mergedItem,
syncData: store.getters['data/syncDataByItemId'][dataId],
ifNotTooLate: tooLateChecker(restartContentSyncAfter),
}));
// Update data sync data
store.dispatch('data/patchDataSyncDataById', { store.dispatch('data/patchDataSyncDataById', {
[dataId]: utils.deepCopy(store.getters['data/syncDataByItemId'][dataId]), [dataId]: utils.deepCopy(store.getters['data/syncDataByItemId'][dataId]),
}); });
@ -602,7 +614,7 @@ const syncDataItem = async (dataId) => {
/** /**
* Sync the whole workspace with the main provider and the current file explicit locations. * Sync the whole workspace with the main provider and the current file explicit locations.
*/ */
const syncWorkspace = async () => { const syncWorkspace = async (skipContents = false) => {
try { try {
const workspace = store.getters['workspace/currentWorkspace']; const workspace = store.getters['workspace/currentWorkspace'];
const syncContext = new SyncContext(); const syncContext = new SyncContext();
@ -627,8 +639,8 @@ const syncWorkspace = async () => {
// Prevent from sending items too long after changes have been retrieved // Prevent from sending items too long after changes have been retrieved
const ifNotTooLate = tooLateChecker(restartSyncAfter); const ifNotTooLate = tooLateChecker(restartSyncAfter);
// Called until no item to save // Find and save one item to save
const saveNextItem = () => ifNotTooLate(async () => { await utils.awaitSome(() => ifNotTooLate(async () => {
const storeItemMap = { const storeItemMap = {
...store.state.file.itemsById, ...store.state.file.itemsById,
...store.state.folder.itemsById, ...store.state.folder.itemsById,
@ -654,27 +666,26 @@ const syncWorkspace = async () => {
}, },
) || []; ) || [];
if (changedItem) { if (!changedItem) return false;
const resultSyncData = await workspaceProvider
.saveWorkspaceItem({
// Use deepCopy to freeze objects
item: utils.deepCopy(changedItem),
syncData: utils.deepCopy(syncDataToUpdate),
ifNotTooLate,
});
store.dispatch('data/patchSyncDataById', {
[resultSyncData.id]: resultSyncData,
});
await saveNextItem();
}
});
await saveNextItem();
// Called until no item to remove const resultSyncData = await workspaceProvider
const removeNextItem = () => ifNotTooLate(async () => { .saveWorkspaceItem({
// Use deepCopy to freeze objects
item: utils.deepCopy(changedItem),
syncData: utils.deepCopy(syncDataToUpdate),
ifNotTooLate,
});
store.dispatch('data/patchSyncDataById', {
[resultSyncData.id]: resultSyncData,
});
return true;
}));
// Find and remove one item to remove
await utils.awaitSome(() => ifNotTooLate(async () => {
let getItem; let getItem;
let getFileItem; let getFileItem;
if (workspaceProvider.isGit) { if (store.getters['workspace/currentWorkspaceIsGit']) {
const { itemsByGitPath } = store.getters; const { itemsByGitPath } = store.getters;
getItem = syncData => itemsByGitPath[syncData.id]; getItem = syncData => itemsByGitPath[syncData.id];
getFileItem = syncData => itemsByGitPath[syncData.id.slice(1)]; // Remove leading / getFileItem = syncData => itemsByGitPath[syncData.id.slice(1)]; // Remove leading /
@ -701,18 +712,17 @@ const syncWorkspace = async () => {
}, },
)); ));
if (syncDataToRemove) { if (!syncDataToRemove) return false;
await workspaceProvider.removeWorkspaceItem({
syncData: syncDataToRemove, await workspaceProvider.removeWorkspaceItem({
ifNotTooLate, syncData: syncDataToRemove,
}); ifNotTooLate,
const syncDataCopy = { ...store.getters['data/syncDataById'] }; });
delete syncDataCopy[syncDataToRemove.id]; const syncDataCopy = { ...store.getters['data/syncDataById'] };
store.dispatch('data/setSyncDataById', syncDataCopy); delete syncDataCopy[syncDataToRemove.id];
await removeNextItem(); store.dispatch('data/setSyncDataById', syncDataCopy);
} return true;
}); }));
await removeNextItem();
// Sync settings and workspaces only in the main workspace // Sync settings and workspaces only in the main workspace
if (workspace.id === 'main') { if (workspace.id === 'main') {
@ -721,63 +731,61 @@ const syncWorkspace = async () => {
} }
await syncDataItem('templates'); await syncDataItem('templates');
const getOneFileIdToSync = () => { if (!skipContents) {
let getSyncData; const currentFileId = store.getters['file/current'].id;
if (workspaceProvider.isGit) { if (currentFileId) {
const { gitPathsByItemId } = store.getters; // Sync current file first
const syncDataById = store.getters['data/syncDataById']; await syncFile(currentFileId, syncContext);
// Use file git path as content may not exist or not be loaded
getSyncData = fileId => syncDataById[`/${gitPathsByItemId[fileId]}`];
} else {
const syncDataByItemId = store.getters['data/syncDataByItemId'];
getSyncData = (fileId, contentId) => syncDataByItemId[contentId];
} }
// Collect all [fileId, contentId] // Find and sync one file out of sync
const ids = [ await utils.awaitSome(async () => {
...Object.keys(localDbSvc.hashMap.content) let getSyncData;
.map(contentId => [contentId.split('/')[0], contentId]), if (store.getters['workspace/currentWorkspaceIsGit']) {
...store.getters['file/items'] const { gitPathsByItemId } = store.getters;
.map(file => [file.id, `${file.id}/content`]), const syncDataById = store.getters['data/syncDataById'];
]; getSyncData = contentId => syncDataById[gitPathsByItemId[contentId]];
} else {
// Find the first content out of sync const syncDataByItemId = store.getters['data/syncDataByItemId'];
const contentMap = store.state.content.itemsById; getSyncData = contentId => syncDataByItemId[contentId];
return utils.someResult(ids, ([fileId, contentId]) => {
// Get content hash from itemsById or from localDbSvc if not loaded
const loadedContent = contentMap[contentId];
const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId];
const syncData = getSyncData(fileId, contentId);
if (
// Sync if content syncing was not attempted yet
!syncContext.attempted[contentId] &&
// And if syncData does not exist or if content hash and syncData hash are inconsistent
(!syncData || syncData.hash !== hash)
) {
return fileId;
} }
return null;
// Collect all [fileId, contentId]
const ids = [
...Object.keys(localDbSvc.hashMap.content)
.map(contentId => [contentId.split('/')[0], contentId]),
...store.getters['file/items']
.map(file => [file.id, `${file.id}/content`]),
];
// Find the first content out of sync
const contentMap = store.state.content.itemsById;
const fileIdToSync = utils.someResult(ids, ([fileId, contentId]) => {
// Get the content hash from itemsById or from localDbSvc if not loaded
const loadedContent = contentMap[contentId];
const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId];
const syncData = getSyncData(contentId);
if (
// Sync if content syncing was not attempted yet
!syncContext.attempted[contentId] &&
// And if syncData does not exist or if content hash and syncData hash are inconsistent
(!syncData || syncData.hash !== hash)
) {
return fileId;
}
return null;
});
if (!fileIdToSync) return false;
await syncFile(fileIdToSync, syncContext);
return true;
}); });
};
const syncNextFile = async () => {
const fileId = getOneFileIdToSync();
if (fileId) {
await syncFile(fileId, syncContext);
await syncNextFile();
}
};
const currentFileId = store.getters['file/current'].id;
if (currentFileId) {
// Sync current file first
await syncFile(currentFileId, syncContext);
} }
await syncNextFile();
if (syncContext.restart) { // Restart sync if requested
// Restart sync if (syncContext.restartSkipContents) {
await syncWorkspace(); await syncWorkspace(true);
} }
} catch (err) { } catch (err) {
if (err && err.message === 'TOO_LATE') { if (err && err.message === 'TOO_LATE') {
@ -786,10 +794,6 @@ const syncWorkspace = async () => {
} else { } else {
throw err; throw err;
} }
} finally {
if (workspaceProvider.onSyncEnd) {
workspaceProvider.onSyncEnd();
}
} }
}; };
@ -835,9 +839,9 @@ const requestSync = () => {
if (isWorkspaceSyncPossible()) { if (isWorkspaceSyncPossible()) {
await syncWorkspace(); await syncWorkspace();
} else if (hasCurrentFileSyncLocations()) { } else if (hasCurrentFileSyncLocations()) {
// Only sync current file if workspace sync is unavailable. // Only sync the current file if workspace sync is unavailable
// We could sync all files that are out-of-sync but it would // as we don't want to look for out-of-sync files by loading
// require to load all the syncedContent objects from the DB. // all the syncedContent objects.
await syncFile(store.getters['file/current'].id); await syncFile(store.getters['file/current'].id);
} }
@ -845,7 +849,7 @@ const requestSync = () => {
Object.entries(fileHashesToClean).forEach(([fileId, fileHash]) => { Object.entries(fileHashesToClean).forEach(([fileId, fileHash]) => {
const file = store.state.file.itemsById[fileId]; const file = store.state.file.itemsById[fileId];
if (file && file.hash === fileHash) { if (file && file.hash === fileHash) {
fileSvc.deleteFile(fileId); workspaceSvc.deleteFile(fileId);
} }
}); });
} finally { } finally {
@ -865,22 +869,25 @@ export default {
localDbSvc.syncLocalStorage(); localDbSvc.syncLocalStorage();
// Try to find a suitable action provider // Try to find a suitable action provider
actionProvider = providerRegistry.providers[utils.queryParams.providerId]; actionProvider = providerRegistry.providersById[utils.queryParams.providerId];
if (actionProvider && actionProvider.initAction) { if (actionProvider && actionProvider.initAction) {
await actionProvider.initAction(); await actionProvider.initAction();
} }
// Try to find a suitable workspace sync provider // Try to find a suitable workspace sync provider
workspaceProvider = providerRegistry.providers[utils.queryParams.providerId]; workspaceProvider = providerRegistry.providersById[utils.queryParams.providerId];
if (!workspaceProvider || !workspaceProvider.initWorkspace) { if (!workspaceProvider || !workspaceProvider.initWorkspace) {
workspaceProvider = googleDriveAppDataProvider; workspaceProvider = googleDriveAppDataProvider;
} }
const workspace = await workspaceProvider.initWorkspace(); const workspace = await workspaceProvider.initWorkspace();
// Fix the URL hash
utils.setQueryParams(workspaceProvider.getWorkspaceParams(workspace));
store.dispatch('workspace/setCurrentWorkspaceId', workspace.id); store.dispatch('workspace/setCurrentWorkspaceId', workspace.id);
await localDbSvc.init(); await localDbSvc.init();
// Try to find a suitable action provider // Try to find a suitable action provider
actionProvider = providerRegistry.providers[utils.queryParams.providerId] || actionProvider; actionProvider = providerRegistry.providersById[utils.queryParams.providerId] || actionProvider;
if (actionProvider && actionProvider.performAction) { if (actionProvider && actionProvider.performAction) {
const newSyncLocation = await actionProvider.performAction(); const newSyncLocation = await actionProvider.performAction();
if (newSyncLocation) { if (newSyncLocation) {

View File

@ -1,8 +1,8 @@
import cledit from './cledit'; import cledit from './editor/cledit';
import store from '../store'; import store from '../store';
import utils from './utils'; import utils from './utils';
import editorSvc from './editorSvc'; import editorSvc from './editorSvc';
import fileSvc from './fileSvc'; import workspaceSvc from './workspaceSvc';
const { const {
origin, origin,
@ -31,7 +31,7 @@ export default {
} }
store.commit('setLight', true); store.commit('setLight', true);
const file = await fileSvc.createFile({ const file = await workspaceSvc.createFile({
name: fileName || utils.getHostname(origin), name: fileName || utils.getHostname(origin),
text: contentText || '\n', text: contentText || '\n',
properties: contentProperties, properties: contentProperties,
@ -58,7 +58,7 @@ export default {
.splice(10) .splice(10)
.forEach(([id]) => { .forEach(([id]) => {
delete lastCreated[id]; delete lastCreated[id];
fileSvc.deleteFile(id); workspaceSvc.deleteFile(id);
}); });
// Store file creations and open the file // Store file creations and open the file

View File

@ -170,6 +170,13 @@ export default {
makeWorkspaceId(params) { makeWorkspaceId(params) {
return Math.abs(this.hash(this.serializeObject(params))).toString(36); return Math.abs(this.hash(this.serializeObject(params))).toString(36);
}, },
getDbName(workspaceId) {
let dbName = 'stackedit-db';
if (workspaceId !== 'main') {
dbName += `-${workspaceId}`;
}
return dbName;
},
encodeBase64(str, urlSafe = false) { encodeBase64(str, urlSafe = false) {
const uriEncodedStr = encodeURIComponent(str); const uriEncodedStr = encodeURIComponent(str);
const utf8Str = uriEncodedStr.replace( const utf8Str = uriEncodedStr.replace(
@ -227,6 +234,12 @@ export default {
}; };
return runWithNextValue(); return runWithNextValue();
}, },
async awaitSome(asyncFunc) {
if (await asyncFunc()) {
return this.awaitSome(asyncFunc);
}
return null;
},
someResult(values, func) { someResult(values, func) {
let result; let result;
values.some((value) => { values.some((value) => {
@ -279,6 +292,16 @@ export default {
urlParser.href = url; urlParser.href = url;
return urlParser.hostname; return urlParser.hostname;
}, },
encodeUrlPath(path) {
return path ? path.split('/').map(encodeURIComponent).join('/') : '';
},
parseGithubRepoUrl(url) {
const parsedRepo = url && url.match(/([^/:]+)\/([^/]+?)(?:\.git|\/)?$/);
return parsedRepo && {
owner: parsedRepo[1],
repo: parsedRepo[2],
};
},
createHiddenIframe(url) { createHiddenIframe(url) {
const iframeElt = document.createElement('iframe'); const iframeElt = document.createElement('iframe');
iframeElt.style.position = 'absolute'; iframeElt.style.position = 'absolute';

View File

@ -4,6 +4,7 @@ import utils from './utils';
const forbiddenFolderNameMatcher = /^\.stackedit-data$|^\.stackedit-trash$|\.md$|\.sync$|\.publish$/; const forbiddenFolderNameMatcher = /^\.stackedit-data$|^\.stackedit-trash$|\.md$|\.sync$|\.publish$/;
export default { export default {
/** /**
* Create a file in the store with the specified fields. * Create a file in the store with the specified fields.
*/ */
@ -29,7 +30,7 @@ export default {
discussions: discussions || {}, discussions: discussions || {},
comments: comments || {}, comments: comments || {},
}; };
const workspaceUniquePaths = store.getters['workspace/hasUniquePaths']; const workspaceUniquePaths = store.getters['workspace/currentWorkspaceHasUniquePaths'];
// Show warning dialogs // Show warning dialogs
if (!background) { if (!background) {
@ -90,7 +91,7 @@ export default {
} }
// Check if there is a path conflict // Check if there is a path conflict
if (store.getters['workspace/hasUniquePaths']) { if (store.getters['workspace/currentWorkspaceHasUniquePaths']) {
const parentPath = store.getters.pathsByItemId[item.parentId] || ''; const parentPath = store.getters.pathsByItemId[item.parentId] || '';
const path = parentPath + sanitizedName; const path = parentPath + sanitizedName;
const items = store.getters.itemsByPath[path] || []; const items = store.getters.itemsByPath[path] || [];
@ -133,7 +134,7 @@ export default {
store.commit(`${item.type}/setItem`, item); store.commit(`${item.type}/setItem`, item);
// Ensure path uniqueness // Ensure path uniqueness
if (store.getters['workspace/hasUniquePaths']) { if (store.getters['workspace/currentWorkspaceHasUniquePaths']) {
this.makePathUnique(item.id); this.makePathUnique(item.id);
} }
@ -164,7 +165,7 @@ export default {
* Ensure two files/folders don't have the same path if the workspace doesn't allow it. * Ensure two files/folders don't have the same path if the workspace doesn't allow it.
*/ */
ensureUniquePaths(idsToKeep = {}) { ensureUniquePaths(idsToKeep = {}) {
if (store.getters['workspace/hasUniquePaths']) { if (store.getters['workspace/currentWorkspaceHasUniquePaths']) {
if (Object.keys(store.getters.pathsByItemId) if (Object.keys(store.getters.pathsByItemId)
.some(id => !idsToKeep[id] && this.makePathUnique(id)) .some(id => !idsToKeep[id] && this.makePathUnique(id))
) { ) {
@ -207,4 +208,59 @@ export default {
} }
} }
}, },
addSyncLocation(location) {
store.commit('syncLocation/setItem', {
...location,
id: utils.uid(),
});
// Sanitize the workspace
this.ensureUniqueLocations();
},
addPublishLocation(location) {
store.commit('publishLocation/setItem', {
...location,
id: utils.uid(),
});
// Sanitize the workspace
this.ensureUniqueLocations();
},
/**
* Ensure two sync/publish locations of the same file don't have the same hash.
*/
ensureUniqueLocations(idsToKeep = {}) {
['syncLocation', 'publishLocation'].forEach((type) => {
store.getters[`${type}/items`].forEach((item) => {
if (!idsToKeep[item.id]
&& store.getters[`${type}/groupedByFileIdAndHash`][item.fileId][item.hash].length > 1
) {
store.commit(`${item.type}/deleteItem`, item.id);
}
});
});
},
/**
* Drop the database and clean the localStorage for the specified workspaceId.
*/
async removeWorkspace(id) {
// Remove from the store first as workspace tabs will reload.
// Workspace deletion will be persisted as soon as possible
// by the store.getters['data/workspaces'] watcher in localDbSvc.
store.dispatch('workspace/removeWorkspace', id);
// Drop the database
await new Promise((resolve) => {
const dbName = utils.getDbName(id);
const request = indexedDB.deleteDatabase(dbName);
request.onerror = resolve; // Ignore errors
request.onsuccess = resolve;
});
// Clean the local storage
localStorage.removeItem(`${id}/lastSyncActivity`);
localStorage.removeItem(`${id}/lastWindowFocus`);
},
}; };

View File

@ -2,7 +2,7 @@ import DiffMatchPatch from 'diff-match-patch';
import moduleTemplate from './moduleTemplate'; import moduleTemplate from './moduleTemplate';
import empty from '../data/emptyContent'; import empty from '../data/emptyContent';
import utils from '../services/utils'; import utils from '../services/utils';
import cledit from '../services/cledit'; import cledit from '../services/editor/cledit';
const diffMatchPatch = new DiffMatchPatch(); const diffMatchPatch = new DiffMatchPatch();

View File

@ -92,9 +92,6 @@ const tokenAdder = providerId => ({ getters, dispatch }, token) => {
}); });
}; };
// For workspaces
const urlParser = window.document.createElement('a');
export default { export default {
namespaced: true, namespaced: true,
state: { state: {
@ -126,25 +123,7 @@ export default {
}, },
}, },
getters: { getters: {
workspacesById: getter('workspaces'), workspaces: getter('workspaces'), // Not to be used, prefer workspace/workspacesById
sanitizedWorkspacesById: (state, { workspacesById }, rootState, rootGetters) => {
const sanitizedWorkspacesById = {};
const mainWorkspaceToken = rootGetters['workspace/mainWorkspaceToken'];
Object.entries(workspacesById).forEach(([id, workspace]) => {
const sanitizedWorkspace = {
id,
providerId: mainWorkspaceToken && 'googleDriveAppData',
sub: mainWorkspaceToken && mainWorkspaceToken.sub,
...workspace,
};
// Rebuild the url with current hostname
urlParser.href = workspace.url || 'app';
const params = utils.parseQueryParams(urlParser.hash.slice(1));
sanitizedWorkspace.url = utils.addQueryParams('app', params, true);
sanitizedWorkspacesById[id] = sanitizedWorkspace;
});
return sanitizedWorkspacesById;
},
settings: getter('settings'), settings: getter('settings'),
computedSettings: (state, { settings }) => { computedSettings: (state, { settings }) => {
const customSettings = yaml.safeLoad(settings); const customSettings = yaml.safeLoad(settings);
@ -207,18 +186,6 @@ export default {
} }
return result; return result;
}, },
syncDataByType: (state, { syncDataById }) => {
const result = {};
utils.types.forEach((type) => {
result[type] = {};
});
Object.entries(syncDataById).forEach(([, item]) => {
if (result[item.type]) {
result[item.type][item.itemId] = item;
}
});
return result;
},
dataSyncDataById: getter('dataSyncData'), dataSyncDataById: getter('dataSyncData'),
tokensByProviderId: getter('tokens'), tokensByProviderId: getter('tokens'),
googleTokensBySub: (state, { tokensByProviderId }) => tokensByProviderId.google || {}, googleTokensBySub: (state, { tokensByProviderId }) => tokensByProviderId.google || {},
@ -229,8 +196,6 @@ export default {
zendeskTokensBySub: (state, { tokensByProviderId }) => tokensByProviderId.zendesk || {}, zendeskTokensBySub: (state, { tokensByProviderId }) => tokensByProviderId.zendesk || {},
}, },
actions: { actions: {
setWorkspacesById: setter('workspaces'),
patchWorkspacesById: patcher('workspaces'),
setSettings: setter('settings'), setSettings: setter('settings'),
patchLocalSettings: patcher('localSettings'), patchLocalSettings: patcher('localSettings'),
patchLayoutSettings: patcher('layoutSettings'), patchLayoutSettings: patcher('layoutSettings'),

Some files were not shown because too many files have changed in this diff Show More