Workspaces (part 2)

This commit is contained in:
Benoit Schweblin 2017-12-17 16:08:52 +01:00
parent 8263e14bcc
commit abbe1804e2
40 changed files with 532 additions and 407 deletions

View File

@ -82,6 +82,12 @@ export default {
networkSvc.init(); networkSvc.init();
sponsorSvc.init(); sponsorSvc.init();
this.ready = true; this.ready = true;
})
.catch((err) => {
if (err && err.message !== 'reload') {
console.error(err); // eslint-disable-line no-console
this.$store.dispatch('notification/error', err);
}
}); });
}, },
}; };

View File

@ -8,12 +8,12 @@
<button class="side-title__button button" @click="newItem(true)" v-title="'New folder'"> <button class="side-title__button button" @click="newItem(true)" v-title="'New folder'">
<icon-folder-plus></icon-folder-plus> <icon-folder-plus></icon-folder-plus>
</button> </button>
<button class="side-title__button button" @click="editItem()" v-title="'Rename'">
<icon-pen></icon-pen>
</button>
<button class="side-title__button button" @click="deleteItem()" v-title="'Remove'"> <button class="side-title__button button" @click="deleteItem()" v-title="'Remove'">
<icon-delete></icon-delete> <icon-delete></icon-delete>
</button> </button>
<button class="side-title__button button" @click="editItem()" v-title="'Rename'">
<icon-pen></icon-pen>
</button>
</div> </div>
<button class="side-title__button button" @click="toggleExplorer(false)" v-title="'Close explorer'"> <button class="side-title__button button" @click="toggleExplorer(false)" v-title="'Close explorer'">
<icon-close></icon-close> <icon-close></icon-close>

View File

@ -141,7 +141,7 @@ export default {
} }
.layout__panel--navigation-bar { .layout__panel--navigation-bar {
background-color: #2c2c2c; background-color: $navbar-bg;
} }
.layout__panel--status-bar { .layout__panel--status-bar {

View File

@ -92,7 +92,7 @@ export default {
publishLocations: 'current', publishLocations: 'current',
}), }),
isSyncPossible() { isSyncPossible() {
return this.$store.getters['data/loginToken'] || return this.$store.getters['workspace/syncToken'] ||
this.$store.getters['syncLocation/current'].length; this.$store.getters['syncLocation/current'].length;
}, },
showSpinner() { showSpinner() {

View File

@ -22,7 +22,7 @@ import { mapGetters, mapActions } from 'vuex';
import CommentList from './gutters/CommentList'; import CommentList from './gutters/CommentList';
import PreviewNewDiscussionButton from './gutters/PreviewNewDiscussionButton'; import PreviewNewDiscussionButton from './gutters/PreviewNewDiscussionButton';
const appUri = `${window.location.protocol}//${window.location.host}`; const appUri = `${location.protocol}//${location.host}`;
export default { export default {
components: { components: {

View File

@ -0,0 +1,22 @@
<template>
<span class="provider-name">{{name}}</span>
</template>
<script>
import userSvc from '../services/userSvc';
export default {
props: ['providerId'],
computed: {
name() {
switch (this.userId) {
default:
return 'Google Drive';
}
},
},
created() {
userSvc.getInfo(this.userId);
},
};
</script>

View File

@ -13,7 +13,8 @@ $link-color: #0c93e4;
$error-color: #f31; $error-color: #f31;
$border-radius-base: 2px; $border-radius-base: 2px;
$hr-color: rgba(128, 128, 128, 0.2); $hr-color: rgba(128, 128, 128, 0.2);
$navbar-color: rgba(255, 255, 255, 0.67); $navbar-bg: #2c2c2c;
$navbar-color: mix($navbar-bg, #fff, 33%);
$navbar-hover-color: #fff; $navbar-hover-color: #fff;
$navbar-hover-background: rgba(255, 255, 255, 0.1); $navbar-hover-background: rgba(255, 255, 255, 0.1);

View File

@ -33,7 +33,7 @@ export default {
components: { components: {
UserImage, UserImage,
}, },
computed: mapGetters('data', [ computed: mapGetters('workspace', [
'loginToken', 'loginToken',
]), ]),
methods: { methods: {

View File

@ -41,5 +41,9 @@ export default {
font-size: 15px; font-size: 15px;
padding-top: 10px; padding-top: 10px;
border-bottom: 2px solid; border-bottom: 2px solid;
.current-discussion & {
width: auto !important;
}
} }
</style> </style>

View File

@ -12,12 +12,12 @@
</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__sponsor">sponsor</div> Export as PDF</div> <div><div class="menu-entry__label">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__sponsor">sponsor</div> Export with Pandoc</div> <div><div class="menu-entry__label">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>

View File

@ -12,7 +12,7 @@
</div> </div>
</a> </a>
</div> </div>
<div class="history__spacer history__spacer--last"></div> <div class="history__spacer history__spacer--last" v-if="revisions.length"></div>
<div class="flex flex--row flex--end" v-if="showMoreButton"> <div class="flex flex--row flex--end" v-if="showMoreButton">
<button class="history__button button" @click="showMore">More</button> <button class="history__button button" @click="showMore">More</button>
</div> </div>
@ -79,7 +79,7 @@ export default {
let revisionContentPromise = revisionContentPromises[revision.id]; let revisionContentPromise = revisionContentPromises[revision.id];
if (!revisionContentPromise) { if (!revisionContentPromise) {
revisionContentPromise = new Promise((resolve, reject) => { revisionContentPromise = new Promise((resolve, reject) => {
const loginToken = this.$store.getters['data/loginToken']; const loginToken = this.$store.getters['workspace/loginToken'];
const currentFile = this.$store.getters['file/current']; const currentFile = this.$store.getters['file/current'];
this.$store.dispatch('queue/enqueue', this.$store.dispatch('queue/enqueue',
() => Promise.resolve() () => Promise.resolve()
@ -133,7 +133,7 @@ export default {
this.setRevisionContent(); this.setRevisionContent();
cachedFileId = id; cachedFileId = id;
revisionContentPromises = {}; revisionContentPromises = {};
const loginToken = this.$store.getters['data/loginToken']; const loginToken = this.$store.getters['workspace/loginToken'];
const currentFile = this.$store.getters['file/current']; const currentFile = this.$store.getters['file/current'];
revisionsPromise = new Promise((resolve, reject) => { revisionsPromise = new Promise((resolve, reject) => {
this.$store.dispatch('queue/enqueue', this.$store.dispatch('queue/enqueue',

View File

@ -1,16 +1,32 @@
<template> <template>
<div class="side-bar__panel side-bar__panel--menu"> <div class="side-bar__panel side-bar__panel--menu">
<menu-entry v-if="!loginToken" @click.native="signin"> <div v-if="!loginToken">
<div class="menu-entry menu-entry--info flex flex--row flex--align-center">
<div class="menu-entry__icon menu-entry__icon--disabled">
<icon-sync-off></icon-sync-off>
</div>
<span><b>{{currentWorkspace.name}}</b> not synced.</span>
</div>
<menu-entry @click.native="signin">
<icon-login slot="icon"></icon-login> <icon-login slot="icon"></icon-login>
<div>Sign in with Google</div> <div>Sign in with Google</div>
<span>Back up and sync all your files, folders and settings.</span> <span>Back up and sync all your files, folders and settings.</span>
</menu-entry> </menu-entry>
<div v-else class="menu-entry flex flex--row flex--align-center"> </div>
<div v-else>
<div class="menu-entry menu-entry--info flex flex--row flex--align-center">
<div class="menu-entry__icon menu-entry__icon--image"> <div class="menu-entry__icon menu-entry__icon--image">
<user-image :user-id="loginToken.sub"></user-image> <user-image :user-id="loginToken.sub"></user-image>
</div> </div>
<span>Signed in as <b>{{loginToken.name}}</b>.</span> <span>Signed in as <b>{{loginToken.name}}</b>.</span>
</div> </div>
<div class="menu-entry menu-entry--info flex flex--row flex--align-center">
<div class="menu-entry__icon menu-entry__icon--image">
<icon-provider :provider-id="currentWorkspace.providerId"></icon-provider>
</div>
<span><b>{{currentWorkspace.name}}</b> synced.</span>
</div>
</div>
<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>Workspaces</div>
@ -46,9 +62,10 @@
<icon-help-circle slot="icon"></icon-help-circle> <icon-help-circle slot="icon"></icon-help-circle>
Markdown cheat sheet Markdown cheat sheet
</menu-entry> </menu-entry>
<menu-entry @click.native="print"> <hr>
<icon-printer slot="icon"></icon-printer> <menu-entry @click.native="setPanel('export')">
Print <icon-content-save slot="icon"></icon-content-save>
Export to disk
</menu-entry> </menu-entry>
<input class="hidden-file" id="import-disk-file-input" type="file" @change="onImportFile"> <input class="hidden-file" id="import-disk-file-input" type="file" @change="onImportFile">
<label class="menu-entry button flex flex--row flex--align-center" for="import-disk-file-input"> <label class="menu-entry button flex flex--row flex--align-center" for="import-disk-file-input">
@ -59,9 +76,9 @@
Import from disk Import from disk
</div> </div>
</label> </label>
<menu-entry @click.native="setPanel('export')"> <menu-entry @click.native="print">
<icon-content-save slot="icon"></icon-content-save> <icon-printer slot="icon"></icon-printer>
Export to disk Print
</menu-entry> </menu-entry>
<hr> <hr>
<menu-entry @click.native="setPanel('more')"> <menu-entry @click.native="setPanel('more')">
@ -84,7 +101,8 @@ export default {
UserImage, UserImage,
}, },
computed: { computed: {
...mapGetters('data', [ ...mapGetters('workspace', [
'currentWorkspace',
'loginToken', 'loginToken',
]), ]),
}, },
@ -123,8 +141,7 @@ export default {
.catch(() => {}); // Cancel .catch(() => {}); // Cancel
}, },
history() { history() {
const loginToken = this.$store.getters['data/loginToken']; if (!this.loginToken) {
if (!loginToken) {
this.$store.dispatch('modal/signInForHistory', { this.$store.dispatch('modal/signInForHistory', {
onResolve: () => googleHelper.signin() onResolve: () => googleHelper.signin()
.then(() => syncSvc.requestSync()), .then(() => syncSvc.requestSync()),

View File

@ -13,22 +13,22 @@
<menu-entry @click.native="reset"> <menu-entry @click.native="reset">
<icon-logout slot="icon"></icon-logout> <icon-logout slot="icon"></icon-logout>
<div>Reset application</div> <div>Reset application</div>
<span>Sign out and clean local data.</span> <span>Sign out and clean all workspaces.</span>
</menu-entry> </menu-entry>
<hr> <hr>
<menu-entry @click.native="exportWorkspace">
<icon-content-save slot="icon"></icon-content-save>
Export workspace backup
</menu-entry>
<input class="hidden-file" id="import-backup-file-input" type="file" @change="onImportBackup"> <input class="hidden-file" id="import-backup-file-input" type="file" @change="onImportBackup">
<label class="menu-entry button flex flex--row flex--align-center" for="import-backup-file-input"> <label class="menu-entry button flex flex--row flex--align-center" for="import-backup-file-input">
<div class="menu-entry__icon flex flex--column flex--center"> <div class="menu-entry__icon flex flex--column flex--center">
<icon-content-save></icon-content-save> <icon-content-save></icon-content-save>
</div> </div>
<div class="flex flex--column"> <div class="flex flex--column">
Import backup Import workspace backup
</div> </div>
</label> </label>
<menu-entry href="#exportBackup=true" target="_blank">
<icon-content-save slot="icon"></icon-content-save>
Export backup
</menu-entry>
<hr> <hr>
<menu-entry @click.native="about"> <menu-entry @click.native="about">
<icon-help-circle slot="icon"></icon-help-circle> <icon-help-circle slot="icon"></icon-help-circle>
@ -47,8 +47,8 @@
<script> <script>
import MenuEntry from './common/MenuEntry'; import MenuEntry from './common/MenuEntry';
import localDbSvc from '../../services/localDbSvc';
import backupSvc from '../../services/backupSvc'; import backupSvc from '../../services/backupSvc';
import utils from '../../services/utils';
import welcomeFile from '../../data/welcomeFile.md'; import welcomeFile from '../../data/welcomeFile.md';
export default { export default {
@ -72,6 +72,17 @@ export default {
reader.readAsText(blob); reader.readAsText(blob);
} }
}, },
exportWorkspace() {
const url = utils.addQueryParams('app', {
...utils.queryParams,
exportWorkspace: true,
}, true);
const iframeElt = utils.createHiddenIframe(url);
document.body.appendChild(iframeElt);
setTimeout(() => {
document.body.removeChild(iframeElt);
}, 60000);
},
settings() { settings() {
return this.$store.dispatch('modal/open', 'settings') return this.$store.dispatch('modal/open', 'settings')
.then( .then(
@ -88,10 +99,10 @@ export default {
}, },
reset() { reset() {
return this.$store.dispatch('modal/reset') return this.$store.dispatch('modal/reset')
.then( .then(() => {
() => localDbSvc.removeDb(), location.href = '#reset=true';
() => {}, // Cancel location.reload();
); });
}, },
welcomeFile() { welcomeFile() {
return this.$store.dispatch('createFile', { return this.$store.dispatch('createFile', {

View File

@ -106,8 +106,8 @@ export default {
...mapState('queue', [ ...mapState('queue', [
'isSyncRequested', 'isSyncRequested',
]), ]),
...mapGetters('data', [ ...mapGetters('workspace', [
'loginToken', 'syncToken',
]), ]),
...mapGetters('syncLocation', { ...mapGetters('syncLocation', {
syncLocations: 'current', syncLocations: 'current',
@ -116,7 +116,7 @@ export default {
return this.$store.getters['file/current'].name; return this.$store.getters['file/current'].name;
}, },
isSyncPossible() { isSyncPossible() {
return this.$store.getters['data/loginToken'] || return this.syncToken ||
this.$store.getters['syncLocation/current'].length; this.$store.getters['syncLocation/current'].length;
}, },
googleDriveTokens() { googleDriveTokens() {

View File

@ -2,9 +2,8 @@
<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 workspaces" :key="id"> <div class="workspace" v-for="(workspace, id) in workspaces" :key="id">
<menu-entry :href="workspace.url" target="_blank"> <menu-entry :href="workspace.url" target="_blank">
<icon-provider slot="icon" provider-id="googleDrive"></icon-provider> <icon-provider slot="icon" :provider-id="workspace.providerId"></icon-provider>
<div class="workspace__name">{{workspace.name}}</div> <div class="workspace__name"><div class="menu-entry__label" v-if="currentWorkspace === workspace">current</div>{{workspace.name}}</div>
<span>{{workspace.url}}</span>
</menu-entry> </menu-entry>
</div> </div>
<hr> <hr>
@ -32,6 +31,9 @@ export default {
...mapGetters('data', [ ...mapGetters('data', [
'workspaces', 'workspaces',
]), ]),
...mapGetters('workspace', [
'currentWorkspace',
]),
}, },
methods: { methods: {
addGoogleDriveWorkspace() { addGoogleDriveWorkspace() {
@ -50,8 +52,16 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
@import '../common/variables.scss';
.workspace .menu-entry {
padding-top: 12px;
padding-bottom: 12px;
}
.workspace__name { .workspace__name {
font-weight: bold; font-weight: bold;
line-height: 1.2;
.menu-entry div & { .menu-entry div & {
text-decoration: none; text-decoration: none;

View File

@ -19,12 +19,13 @@
font-size: 17px; font-size: 17px;
line-height: 1.5; line-height: 1.5;
text-transform: none; text-transform: none;
white-space: normal;
div div { div div {
text-decoration: underline; text-decoration: underline;
text-decoration-skip: ink; text-decoration-skip: ink;
.menu-entry__sponsor { .menu-entry__label {
text-decoration: none; text-decoration: none;
} }
} }
@ -33,9 +34,19 @@
display: inline-block; display: inline-block;
font-size: 0.75rem; font-size: 0.75rem;
opacity: 0.5; opacity: 0.5;
white-space: normal;
span {
display: inline;
opacity: 1;
} }
} }
}
.menu-entry--info {
padding-top: 0;
padding-bottom: 0;
margin: 10px 0;
}
.menu-entry__icon { .menu-entry__icon {
height: 20px; height: 20px;
@ -44,6 +55,10 @@
flex: none; flex: none;
} }
.menu-entry__icon--disabled {
opacity: 0.5;
}
.menu-entry__icon--image { .menu-entry__icon--image {
border-radius: $border-radius-base; border-radius: $border-radius-base;
overflow: hidden; overflow: hidden;
@ -54,7 +69,7 @@
top: -999px; top: -999px;
} }
.menu-entry__sponsor { .menu-entry__label {
float: right; float: right;
font-size: 0.6rem; font-size: 0.6rem;
font-weight: 600; font-weight: 600;
@ -66,5 +81,6 @@
.menu-entry__text { .menu-entry__text {
width: 100%; width: 100%;
overflow: hidden;
} }
</style> </style>

View File

@ -45,17 +45,17 @@ export default modalTemplate({
const selectedFormat = this.selectedFormat; const selectedFormat = this.selectedFormat;
this.$store.dispatch('queue/enqueue', () => Promise.all([ this.$store.dispatch('queue/enqueue', () => Promise.all([
Promise.resolve().then(() => { Promise.resolve().then(() => {
const loginToken = this.$store.getters['data/loginToken']; const sponsorToken = this.$store.getters['workspace/sponsorToken'];
return loginToken && googleHelper.refreshToken(loginToken); return sponsorToken && googleHelper.refreshToken(sponsorToken);
}), }),
sponsorSvc.getToken(), sponsorSvc.getToken(),
]) ])
.then(([loginToken, token]) => networkSvc.request({ .then(([sponsorToken, token]) => networkSvc.request({
method: 'POST', method: 'POST',
url: 'pandocExport', url: 'pandocExport',
params: { params: {
token, token,
idToken: loginToken && loginToken.idToken, idToken: sponsorToken && sponsorToken.idToken,
format: selectedFormat, format: selectedFormat,
options: JSON.stringify(this.$store.getters['data/computedSettings'].pandoc), options: JSON.stringify(this.$store.getters['data/computedSettings'].pandoc),
metadata: JSON.stringify(currentContent.properties), metadata: JSON.stringify(currentContent.properties),

View File

@ -38,19 +38,19 @@ export default modalTemplate({
const currentFile = this.$store.getters['file/current']; const currentFile = this.$store.getters['file/current'];
this.$store.dispatch('queue/enqueue', () => Promise.all([ this.$store.dispatch('queue/enqueue', () => Promise.all([
Promise.resolve().then(() => { Promise.resolve().then(() => {
const loginToken = this.$store.getters['data/loginToken']; const sponsorToken = this.$store.getters['workspace/sponsorToken'];
return loginToken && googleHelper.refreshToken(loginToken); return sponsorToken && googleHelper.refreshToken(sponsorToken);
}), }),
sponsorSvc.getToken(), sponsorSvc.getToken(),
exportSvc.applyTemplate( exportSvc.applyTemplate(
currentFile.id, this.allTemplates[this.selectedTemplate], true), currentFile.id, this.allTemplates[this.selectedTemplate], true),
]) ])
.then(([loginToken, token, html]) => networkSvc.request({ .then(([sponsorToken, token, html]) => networkSvc.request({
method: 'POST', method: 'POST',
url: 'pdfExport', url: 'pdfExport',
params: { params: {
token, token,
idToken: loginToken && loginToken.idToken, idToken: sponsorToken && sponsorToken.idToken,
options: JSON.stringify(this.$store.getters['data/computedSettings'].wkhtmltopdf), options: JSON.stringify(this.$store.getters['data/computedSettings'].wkhtmltopdf),
}, },
body: html, body: html,

View File

@ -25,12 +25,12 @@ export default {
ModalInner, ModalInner,
}, },
data() { data() {
const loginToken = this.$store.getters['data/loginToken']; const sponsorToken = this.$store.getters['workspace/sponsorToken'];
const makeButton = (id, price, description, offer) => { const makeButton = (id, price, description, offer) => {
const params = { const params = {
cmd: '_s-xclick', cmd: '_s-xclick',
hosted_button_id: id, hosted_button_id: id,
custom: loginToken.sub, custom: sponsorToken.sub,
}; };
return { return {
id, id,
@ -42,7 +42,7 @@ export default {
}; };
return { return {
buttons: loginToken ? [ buttons: sponsorToken ? [
makeButton('TDAPH47B3J2JW', '$5', '3 months sponsorship'), makeButton('TDAPH47B3J2JW', '$5', '3 months sponsorship'),
makeButton('6CTKPKF8868UA', '$15', '1 year sponsorship', '-25%'), makeButton('6CTKPKF8868UA', '$15', '1 year sponsorship', '-25%'),
makeButton('A5ZDYW6SYDLBE', '$25', '2 years sponsorship', '-37%'), makeButton('A5ZDYW6SYDLBE', '$25', '2 years sponsorship', '-37%'),

View File

@ -5,18 +5,20 @@
<div class="workspace-entry__icon flex flex--column flex--center"> <div class="workspace-entry__icon flex flex--column flex--center">
<icon-provider :provider-id="workspace.providerId"></icon-provider> <icon-provider :provider-id="workspace.providerId"></icon-provider>
</div> </div>
<div class="workspace-entry__description flex flex--column">
<input class="text-input" type="text" v-if="editedId === id" v-focus @blur="submitEdit()" @keyup.enter="submitEdit()" @keyup.esc.stop="submitEdit(true)" v-model="editingName"> <input class="text-input" type="text" v-if="editedId === id" v-focus @blur="submitEdit()" @keyup.enter="submitEdit()" @keyup.esc.stop="submitEdit(true)" v-model="editingName">
<div class="workspace-entry__name" v-else> <div class="workspace-entry__name" v-else>
{{workspace.name}} {{workspace.name}}
</div> </div>
<div class="workspace-entry__url">
{{workspace.url}}
</div>
</div>
<div class="workspace-entry__buttons flex flex--row flex--center"> <div class="workspace-entry__buttons flex flex--row flex--center">
<button class="workspace-entry__button button" @click="edit(id)"> <button class="workspace-entry__button button" @click="edit(id)">
<icon-pen></icon-pen> <icon-pen></icon-pen>
</button> </button>
<a class="workspace-entry__button button" :href="workspace.url" target="_blank"> <button class="workspace-entry__button button" v-if="workspace !== currentWorkspace && workspace !== mainWorkspace" @click="remove(id)">
<icon-open-in-new></icon-open-in-new>
</a>
<button class="workspace-entry__button button" @click="remove(id)">
<icon-delete></icon-delete> <icon-delete></icon-delete>
</button> </button>
</div> </div>
@ -31,6 +33,7 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import ModalInner from './common/ModalInner'; import ModalInner from './common/ModalInner';
import localDbSvc from '../../services/localDbSvc';
export default { export default {
components: { components: {
@ -47,6 +50,10 @@ export default {
...mapGetters('data', [ ...mapGetters('data', [
'workspaces', 'workspaces',
]), ]),
...mapGetters('workspace', [
'mainWorkspace',
'currentWorkspace',
]),
}, },
methods: { methods: {
edit(id) { edit(id) {
@ -68,11 +75,11 @@ export default {
this.editedId = null; this.editedId = null;
}, },
remove(id) { remove(id) {
const workspaces = { return this.$store.dispatch('modal/removeWorkspace')
...this.workspaces, .then(
}; () => localDbSvc.removeWorkspace(id),
delete workspaces[id]; () => {}, // Cancel
this.$store.dispatch('data/setWorkspaces', workspaces); );
}, },
}, },
}; };
@ -97,6 +104,11 @@ export default {
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
} }
span {
text-overflow: ellipsis;
overflow: hidden;
}
} }
.workspace-entry__icon { .workspace-entry__icon {
@ -106,12 +118,22 @@ export default {
flex: none; flex: none;
} }
.workspace-entry__name { .workspace-entry__description {
width: 100%; width: 100%;
word-wrap: break-word;
overflow: hidden;
}
.workspace-entry__name {
overflow: hidden; overflow: hidden;
font-weight: bold; font-weight: bold;
} }
.workspace-entry__url {
opacity: 0.5;
font-size: 0.75em;
}
.workspace-entry__buttons { .workspace-entry__buttons {
margin-left: 0.75rem; margin-left: 0.75rem;
} }

View File

@ -31,12 +31,12 @@ export default {
methods: { methods: {
sponsor() { sponsor() {
Promise.resolve() Promise.resolve()
.then(() => !this.$store.getters['data/loginToken'] && .then(() => !this.$store.getters['workspace/sponsorToken'] &&
// If user has to sign in // If user has to sign in
this.$store.dispatch('modal/signInForSponsorship', { this.$store.dispatch('modal/signInForSponsorship', {
onResolve: () => googleHelper.signin() onResolve: () => googleHelper.signin()
.then(() => syncSvc.requestSync()), .then(() => syncSvc.requestSync()),
}) }))
.then(() => { .then(() => {
if (!this.$store.getters.isSponsor) { if (!this.$store.getters.isSponsor) {
this.$store.dispatch('modal/open', 'sponsor'); this.$store.dispatch('modal/open', 'sponsor');

View File

@ -28,11 +28,8 @@ import modalTemplate from '../common/modalTemplate';
import utils from '../../../services/utils'; import utils from '../../../services/utils';
export default modalTemplate({ export default modalTemplate({
data: () => ({
fileId: '',
}),
computedLocalSettings: { computedLocalSettings: {
folderId: 'googleDriveFolderId', folderId: 'googleDriveWorkspaceFolderId',
}, },
methods: { methods: {
openFolder() { openFolder() {
@ -41,7 +38,7 @@ export default modalTemplate({
googleHelper.openPicker(this.config.token, 'folder') googleHelper.openPicker(this.config.token, 'folder')
.then((folders) => { .then((folders) => {
this.$store.dispatch('data/patchLocalSettings', { this.$store.dispatch('data/patchLocalSettings', {
googleDriveFolderId: folders[0].id, googleDriveWorkspaceFolderId: folders[0].id,
}); });
})); }));
}, },
@ -50,7 +47,7 @@ export default modalTemplate({
providerId: 'googleDriveWorkspace', providerId: 'googleDriveWorkspace',
folderId: this.folderId, folderId: this.folderId,
sub: this.config.token.sub, sub: this.config.token.sub,
}); }, true);
this.config.resolve(); this.config.resolve();
window.open(url); window.open(url);
}, },

View File

@ -4,6 +4,7 @@ export default () => ({
pdfExportTemplate: 'styledHtml', pdfExportTemplate: 'styledHtml',
pandocExportFormat: 'pdf', pandocExportFormat: 'pdf',
googleDriveFolderId: '', googleDriveFolderId: '',
googleDriveWorkspaceFolderId: '',
googleDrivePublishFormat: 'markdown', googleDrivePublishFormat: 'markdown',
googleDrivePublishTemplate: 'styledHtml', googleDrivePublishTemplate: 'styledHtml',
bloggerBlogUrl: '', bloggerBlogUrl: '',

View File

@ -1,3 +1,5 @@
# Auto-sync frequency (in ms). Minimum is 60000.
autoSyncEvery: 90000
# Adjust font size in editor and preview # Adjust font size in editor and preview
fontSizeFactor: 1 fontSizeFactor: 1
# Adjust maximum text width in editor and preview # Adjust maximum text width in editor and preview

View File

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

View File

@ -10,6 +10,7 @@ export default {
classState() { classState() {
switch (this.providerId) { switch (this.providerId) {
case 'googleDrive': case 'googleDrive':
case 'googleDriveAppData':
case 'googleDriveWorkspace': case 'googleDriveWorkspace':
return 'google-drive'; return 'google-drive';
case 'googlePhotos': case 'googlePhotos':

View File

@ -24,7 +24,7 @@ if (NODE_ENV === 'production') {
.then(() => { .then(() => {
localStorage.updated = true; localStorage.updated = true;
// Reload the webpage to load into the new version // Reload the webpage to load into the new version
window.location.reload(); location.reload();
}); });
}, },
}); });

View File

@ -5,24 +5,25 @@ import welcomeFile from '../data/welcomeFile.md';
const dbVersion = 1; const dbVersion = 1;
const dbStoreName = 'objects'; const dbStoreName = 'objects';
const exportBackup = utils.queryParams.exportBackup; const exportWorkspace = utils.queryParams.exportWorkspace;
if (exportBackup) {
location.hash = '';
}
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 = 'stackedit-db'; this.dbName = getDbName(workspaceId);
if (workspaceId !== 'main') {
this.dbName += `-${workspaceId}`;
}
// Init connection // Init connection
const request = indexedDB.open(this.dbName, dbVersion); const request = indexedDB.open(this.dbName, dbVersion);
@ -33,7 +34,7 @@ class Connection {
request.onsuccess = (event) => { request.onsuccess = (event) => {
this.db = event.target.result; this.db = event.target.result;
this.db.onversionchange = () => window.location.reload(); this.db.onversionchange = () => location.reload();
this.getTxCbs.forEach(({ onTx, onError }) => this.createTx(onTx, onError)); this.getTxCbs.forEach(({ onTx, onError }) => this.createTx(onTx, onError));
this.getTxCbs = null; this.getTxCbs = null;
@ -101,14 +102,33 @@ const localDbSvc = {
* Create the connection and start syncing. * Create the connection and start syncing.
*/ */
init() { init() {
return Promise.resolve()
.then(() => {
// Reset the app if reset flag was passed
if (utils.queryParams.reset) {
return Promise.all(
Object.keys(store.getters['data/workspaces'])
.map(workspaceId => localDbSvc.removeWorkspace(workspaceId)),
)
.then(() => utils.localStorageDataIds.forEach((id) => {
// Clean data stored in localStorage
localStorage.removeItem(`data/${id}`);
}))
.then(() => {
location.replace(utils.resolveUrl('app'));
throw new Error('reload');
});
}
// Create the connection // Create the connection
this.connection = new Connection(); this.connection = new Connection();
// Load the DB // Load the DB
return localDbSvc.sync() return localDbSvc.sync();
})
.then(() => { .then(() => {
// If exportBackup parameter was provided // If exportWorkspace parameter was provided
if (exportBackup) { if (exportWorkspace) {
const backup = JSON.stringify(store.getters.allItemMap); const backup = JSON.stringify(store.getters.allItemMap);
const blob = new Blob([backup], { const blob = new Blob([backup], {
type: 'text/plain;charset=utf-8', type: 'text/plain;charset=utf-8',
@ -130,8 +150,8 @@ const localDbSvc = {
} }
// If app was last opened 7 days ago and synchronization is off // If app was last opened 7 days ago and synchronization is off
if (!store.getters['data/loginToken'] && if (!store.getters['workspace/syncToken'] &&
(store.getters['workspace/lastFocus'] + utils.cleanTrashAfter < Date.now()) (store.state.workspace.lastFocus + utils.cleanTrashAfter < Date.now())
) { ) {
// Clean files // Clean files
store.getters['file/items'] store.getters['file/items']
@ -141,14 +161,14 @@ const localDbSvc = {
// Enable sponsorship // Enable sponsorship
if (utils.queryParams.paymentSuccess) { if (utils.queryParams.paymentSuccess) {
location.hash = ''; location.hash = ''; // PaymentSuccess param is always on its own
store.dispatch('modal/paymentSuccess'); store.dispatch('modal/paymentSuccess');
const loginToken = store.getters['data/loginToken']; const sponsorToken = store.getters['workspace/sponsorToken'];
// Force check sponsorship after a few seconds // Force check sponsorship after a few seconds
const currentDate = Date.now(); const currentDate = Date.now();
if (loginToken && loginToken.expiresOn > currentDate - checkSponsorshipAfter) { if (sponsorToken && sponsorToken.expiresOn > currentDate - checkSponsorshipAfter) {
store.dispatch('data/setGoogleToken', { store.dispatch('data/setGoogleToken', {
...loginToken, ...sponsorToken,
expiresOn: currentDate - checkSponsorshipAfter, expiresOn: currentDate - checkSponsorshipAfter,
}); });
} }
@ -288,7 +308,7 @@ const localDbSvc = {
lastTx = item.tx; lastTx = item.tx;
if (this.lastTx && item.tx - this.lastTx > deleteMarkerMaxAge) { if (this.lastTx && item.tx - this.lastTx > deleteMarkerMaxAge) {
// We may have missed some delete markers // We may have missed some delete markers
window.location.reload(); location.reload();
return; return;
} }
} }
@ -376,7 +396,7 @@ const localDbSvc = {
// DB item is different from the corresponding store item // DB item is different from the corresponding store item
this.hashMap[dbItem.type][dbItem.id] = dbItem.hash; this.hashMap[dbItem.type][dbItem.id] = dbItem.hash;
// Update content only if it exists in the store // Update content only if it exists in the store
if (existingStoreItem || !contentTypes[dbItem.type] || exportBackup) { if (existingStoreItem || !contentTypes[dbItem.type] || exportWorkspace) {
// Put item in the store // Put item in the store
dbItem.tx = undefined; dbItem.tx = undefined;
store.commit(`${dbItem.type}/setItem`, dbItem); store.commit(`${dbItem.type}/setItem`, dbItem);
@ -438,17 +458,25 @@ const localDbSvc = {
}, },
/** /**
* Drop the database * Drop the database and clean the localStorage for the specified workspaceId.
*/ */
removeDb() { removeWorkspace(id) {
const workspaces = {
...this.workspaces,
};
delete workspaces[id];
store.dispatch('data/setWorkspaces', workspaces);
this.syncLocalStorage();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase('stackedit-db'); const dbName = getDbName(id);
const request = indexedDB.deleteDatabase(dbName);
request.onerror = reject; request.onerror = reject;
request.onsuccess = resolve; request.onsuccess = resolve;
}) })
.then(() => { .then(() => {
window.location.reload(); localStorage.removeItem(`${id}/lastSyncActivity`);
}, () => store.dispatch('notification/error', 'Could not delete local database.')); localStorage.removeItem(`${id}/lastWindowFocus`);
});
}, },
}; };

View File

@ -7,7 +7,6 @@ const networkTimeout = 30 * 1000; // 30 sec
let isConnectionDown = false; let isConnectionDown = false;
const userInactiveAfter = 2 * 60 * 1000; // 2 minutes const userInactiveAfter = 2 * 60 * 1000; // 2 minutes
export default { export default {
init() { init() {
// Keep track of the last user activity // Keep track of the last user activity
@ -82,7 +81,9 @@ export default {
window.addEventListener('offline', checkOffline); window.addEventListener('offline', checkOffline);
}, },
isWindowFocused() { isWindowFocused() {
return parseInt(localStorage.getItem(this.lastFocusKey), 10) === this.lastFocus; // We don't use state.workspace.lastFocus as it's not reactive
const storedLastFocus = localStorage.getItem(store.getters['workspace/lastFocusKey']);
return parseInt(storedLastFocus, 10) === this.lastFocus;
}, },
isUserActive() { isUserActive() {
return this.lastActivity > Date.now() - userInactiveAfter && this.isWindowFocused(); return this.lastActivity > Date.now() - userInactiveAfter && this.isWindowFocused();
@ -113,10 +114,7 @@ export default {
let wnd; let wnd;
if (silent) { if (silent) {
// Use an iframe as wnd for silent mode // Use an iframe as wnd for silent mode
iframeElt = document.createElement('iframe'); iframeElt = utils.createHiddenIframe(authorizeUrl);
iframeElt.style.position = 'absolute';
iframeElt.style.left = '-9999px';
iframeElt.src = authorizeUrl;
document.body.appendChild(iframeElt); document.body.appendChild(iframeElt);
wnd = iframeElt.contentWindow; wnd = iframeElt.contentWindow;
} else { } else {

View File

@ -5,17 +5,19 @@ import providerRegistry from './providerRegistry';
export default providerRegistry.register({ export default providerRegistry.register({
id: 'googleDriveAppData', id: 'googleDriveAppData',
getToken() { getToken() {
return store.getters['data/loginToken']; return store.getters['workspace/syncToken'];
}, },
initWorkspace() { initWorkspace() {
// Nothing to do since the main workspace isn't necessarily synchronized // Nothing to do since the main workspace isn't necessarily synchronized
return Promise.resolve(); return Promise.resolve(store.getters['data/workspaces'].main);
}, },
getChanges(token) { getChanges(token) {
return googleHelper.getChanges(token) const startPageToken = store.getters['data/localSettings'].syncStartPageToken;
return googleHelper.getChanges(token, startPageToken, 'appDataFolder')
.then((result) => { .then((result) => {
const changes = result.changes.filter((change) => { const changes = result.changes.filter((change) => {
if (change.file) { if (change.file) {
// Parse item from file name
try { try {
change.item = JSON.parse(change.file.name); change.item = JSON.parse(change.file.name);
} catch (e) { } catch (e) {
@ -30,20 +32,17 @@ export default providerRegistry.register({
}; };
change.file = undefined; change.file = undefined;
} }
change.syncDataId = change.fileId;
return true; return true;
}); });
changes.nextPageToken = result.nextPageToken; changes.startPageToken = result.startPageToken;
return changes; return changes;
}); });
}, },
setAppliedChanges(token, changes) { setAppliedChanges(changes) {
const lastToken = store.getters['data/googleTokens'][token.sub]; store.dispatch('data/patchLocalSettings', {
if (changes.nextPageToken !== lastToken.nextPageToken) { workspaceSyncStartPageToken: changes.startPageToken,
store.dispatch('data/setGoogleToken', {
...lastToken,
nextPageToken: changes.nextPageToken,
}); });
}
}, },
saveItem(token, item, syncData, ifNotTooLate) { saveItem(token, item, syncData, ifNotTooLate) {
return googleHelper.uploadAppDataFile( return googleHelper.uploadAppDataFile(

View File

@ -3,20 +3,21 @@ import googleHelper from './helpers/googleHelper';
import providerRegistry from './providerRegistry'; import providerRegistry from './providerRegistry';
import utils from '../utils'; import utils from '../utils';
let workspaceFolderId;
const makeWorkspaceId = () => {
};
export default providerRegistry.register({ export default providerRegistry.register({
id: 'googleDriveWorkspace', id: 'googleDriveWorkspace',
getToken() { getToken() {
return store.getters['data/loginToken']; return store.getters['workspace/syncToken'];
}, },
initWorkspace() { initWorkspace() {
const makeWorkspaceId = folderId => folderId && Math.abs(utils.hash(utils.serializeObject({
providerId: this.id,
folderId,
}))).toString(36);
const getWorkspace = folderId => store.getters['data/workspaces'][makeWorkspaceId(folderId)];
const initFolder = (token, folder) => Promise.resolve({ const initFolder = (token, folder) => Promise.resolve({
workspaceId: this.makeWorkspaceId(folder.id), folderId: folder.id,
dataFolderId: folder.appProperties.dataFolderId, dataFolderId: folder.appProperties.dataFolderId,
trashFolderId: folder.appProperties.trashFolderId, trashFolderId: folder.appProperties.trashFolderId,
}) })
@ -29,7 +30,7 @@ export default providerRegistry.register({
token, token,
'.stackedit-data', '.stackedit-data',
[folder.id], [folder.id],
{ workspaceId: properties.workspaceId }, { folderId: folder.id },
undefined, undefined,
'application/vnd.google-apps.folder', 'application/vnd.google-apps.folder',
) )
@ -47,7 +48,7 @@ export default providerRegistry.register({
token, token,
'.stackedit-trash', '.stackedit-trash',
[folder.id], [folder.id],
{ workspaceId: properties.workspaceId }, { folderId: folder.id },
undefined, undefined,
'application/vnd.google-apps.folder', 'application/vnd.google-apps.folder',
) )
@ -58,7 +59,7 @@ export default providerRegistry.register({
}) })
.then((properties) => { .then((properties) => {
// Update workspace if some properties are missing // Update workspace if some properties are missing
if (properties.workspaceId === folder.appProperties.workspaceId if (properties.folderId === folder.appProperties.folderId
&& properties.dataFolderId === folder.appProperties.dataFolderId && properties.dataFolderId === folder.appProperties.dataFolderId
&& properties.trashFolderId === folder.appProperties.trashFolderId && properties.trashFolderId === folder.appProperties.trashFolderId
) { ) {
@ -76,26 +77,48 @@ export default providerRegistry.register({
.then(() => properties); .then(() => properties);
}) })
.then((properties) => { .then((properties) => {
// Fix the current url hash
const hash = `#providerId=${this.id}&folderId=${folder.id}`;
if (location.hash !== hash) {
location.hash = hash;
}
// Update workspace in the store // Update workspace in the store
const workspaceId = makeWorkspaceId(folder.id);
store.dispatch('data/patchWorkspaces', { store.dispatch('data/patchWorkspaces', {
[properties.workspaceId]: { [workspaceId]: {
id: properties.workspaceId, id: workspaceId,
sub: token.sub, sub: token.sub,
name: folder.name, name: folder.name,
providerId: this.id, providerId: this.id,
url: utils.resolveUrl(hash),
folderId: folder.id, folderId: folder.id,
dataFolderId: properties.dataFolderId, dataFolderId: properties.dataFolderId,
trashFolderId: properties.trashFolderId, trashFolderId: properties.trashFolderId,
}, },
}); });
return store.getters['data/workspaces'][properties.workspaceId];
// Return the workspace
return getWorkspace(folder.id);
}); });
return Promise.resolve(store.getters['data/googleTokens'][utils.queryParams.sub]) const workspace = getWorkspace(utils.queryParams.folderId);
.then(token => token || this.$store.dispatch('modal/workspaceGoogleRedirection', { return Promise.resolve()
.then(() => {
// See if we already have a token
const googleTokens = store.getters['data/googleTokens'];
// Token sub is in the workspace or in the url if workspace is about to be created
const token = workspace ? googleTokens[workspace.sub] : googleTokens[utils.queryParams.sub];
if (token && token.isDrive) {
return token;
}
// If no token has been found, popup an authorize window and get one
return store.dispatch('modal/workspaceGoogleRedirection', {
onResolve: () => googleHelper.addDriveAccount(), onResolve: () => googleHelper.addDriveAccount(),
})) });
})
.then(token => Promise.resolve() .then(token => Promise.resolve()
// If no folderId is provided, create one
.then(() => utils.queryParams.folderId || googleHelper.uploadFile( .then(() => utils.queryParams.folderId || googleHelper.uploadFile(
token, token,
'StackEdit workspace', 'StackEdit workspace',
@ -103,25 +126,31 @@ export default providerRegistry.register({
undefined, undefined,
undefined, undefined,
'application/vnd.google-apps.folder', 'application/vnd.google-apps.folder',
).then(folder => initFolder(token, folder).then(() => folder.id))) )
.then((folderId) => { .then(folder => initFolder(token, {
const workspaceId = this.makeWorkspaceId(folderId); ...folder,
const workspace = store.getters['data/workspaces'][workspaceId]; appProperties: {},
return workspace || googleHelper.getFile(token, folderId) })
.then(() => folder.id)))
// If workspace does not exist, initialize one
.then(folderId => getWorkspace(folderId) || googleHelper.getFile(token, folderId)
.then((folder) => { .then((folder) => {
const folderWorkspaceId = folder.appProperties.workspaceId; const folderIdProperty = folder.appProperties.folderId;
if (folderWorkspaceId && folderWorkspaceId !== workspaceId) { if (folderIdProperty && folderIdProperty !== folderId) {
throw new Error(`Google Drive folder ${folderId} is part of another workspace.`); throw new Error(`Google Drive folder ${folderId} is part of another workspace.`);
} }
return initFolder(token, folder); return initFolder(token, folder);
}); }, () => {
})); throw new Error(`Folder ${folderId} is not accessible. Make sure it's a valid StackEdit workspace folder and you have the right permissions.`);
})));
}, },
getChanges(token) { getChanges(token) {
return googleHelper.getChanges(token) const startPageToken = store.getters['data/localSettings'].syncStartPageToken;
return googleHelper.getChanges(token, startPageToken, 'appDataFolder')
.then((result) => { .then((result) => {
const changes = result.changes.filter((change) => { const changes = result.changes.filter((change) => {
if (change.file) { if (change.file) {
// Parse item from file name
try { try {
change.item = JSON.parse(change.file.name); change.item = JSON.parse(change.file.name);
} catch (e) { } catch (e) {
@ -136,121 +165,11 @@ export default providerRegistry.register({
}; };
change.file = undefined; change.file = undefined;
} }
change.syncDataId = change.fileId;
return true; return true;
}); });
changes.nextPageToken = result.nextPageToken; changes.startPageToken = result.startPageToken;
return changes; return changes;
}); });
}, },
setAppliedChanges(token, changes) {
const lastToken = store.getters['data/googleTokens'][token.sub];
if (changes.nextPageToken !== lastToken.nextPageToken) {
store.dispatch('data/setGoogleToken', {
...lastToken,
nextPageToken: changes.nextPageToken,
});
}
},
saveItem(token, item, syncData, ifNotTooLate) {
return googleHelper.uploadAppDataFile(
token,
JSON.stringify(item),
['appDataFolder'],
undefined,
undefined,
syncData && syncData.id,
ifNotTooLate,
)
.then(file => ({
// Build sync data
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
}));
},
removeItem(token, syncData, ifNotTooLate) {
return googleHelper.removeAppDataFile(token, syncData.id, ifNotTooLate)
.then(() => syncData);
},
downloadContent(token, syncLocation) {
return this.downloadData(token, `${syncLocation.fileId}/content`);
},
downloadData(token, dataId) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (!syncData) {
return Promise.resolve();
}
return googleHelper.downloadAppDataFile(token, syncData.id)
.then((content) => {
const item = JSON.parse(content);
if (item.hash !== syncData.hash) {
store.dispatch('data/patchSyncData', {
[syncData.id]: {
...syncData,
hash: item.hash,
},
});
}
return item;
});
},
uploadContent(token, content, syncLocation, ifNotTooLate) {
return this.uploadData(token, content, `${syncLocation.fileId}/content`, ifNotTooLate)
.then(() => syncLocation);
},
uploadData(token, item, dataId, ifNotTooLate) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (syncData && syncData.hash === item.hash) {
return Promise.resolve();
}
return googleHelper.uploadAppDataFile(
token,
JSON.stringify({
id: item.id,
type: item.type,
hash: item.hash,
}),
['appDataFolder'],
undefined,
JSON.stringify(item),
syncData && syncData.id,
ifNotTooLate,
)
.then(file => store.dispatch('data/patchSyncData', {
[file.id]: {
// Build sync data
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
},
}));
},
listRevisions(token, fileId) {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
if (!syncData) {
return Promise.reject(); // No need for a proper error message.
}
return googleHelper.getFileRevisions(token, syncData.id)
.then(revisions => revisions.map(revision => ({
id: revision.id,
sub: revision.lastModifyingUser && revision.lastModifyingUser.permissionId,
created: new Date(revision.modifiedTime).getTime(),
})));
},
getRevisionContent(token, fileId, revisionId) {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
if (!syncData) {
return Promise.reject(); // No need for a proper error message.
}
return googleHelper.downloadFileRevision(token, syncData.id, revisionId)
.then(content => JSON.parse(content));
},
makeWorkspaceId(folderId) {
return Math.abs(utils.hash(utils.serializeObject({
providerId: this.id,
folderId: folderId,
}))).toString(36);
},
}); });

View File

@ -168,7 +168,7 @@ export default {
expiresOn: Date.now() + (data.expiresIn * 1000), expiresOn: Date.now() + (data.expiresIn * 1000),
idToken: data.idToken, idToken: data.idToken,
sub: `${res.body.sub}`, sub: `${res.body.sub}`,
isLogin: !store.getters['data/loginToken'] && isLogin: !store.getters['workspace/loginToken'] &&
scopes.indexOf('https://www.googleapis.com/auth/drive.appdata') !== -1, scopes.indexOf('https://www.googleapis.com/auth/drive.appdata') !== -1,
isSponsor: false, isSponsor: false,
isDrive: scopes.indexOf('https://www.googleapis.com/auth/drive') !== -1 || isDrive: scopes.indexOf('https://www.googleapis.com/auth/drive') !== -1 ||
@ -195,13 +195,14 @@ export default {
// We probably retrieved a new token with restricted scopes. // We probably retrieved a new token with restricted scopes.
// That's no problem, token will be refreshed later with merged scopes. // That's no problem, token will be refreshed later with merged scopes.
// Restore flags // Restore flags
token.isLogin = existingToken.isLogin || token.isLogin; Object.assign(token, {
token.isSponsor = existingToken.isSponsor; isLogin: existingToken.isLogin || token.isLogin,
token.isDrive = existingToken.isDrive || token.isDrive; isSponsor: existingToken.isSponsor,
token.isBlogger = existingToken.isBlogger || token.isBlogger; isDrive: existingToken.isDrive || token.isDrive,
token.isPhotos = existingToken.isPhotos || token.isPhotos; isBlogger: existingToken.isBlogger || token.isBlogger,
token.driveFullAccess = existingToken.driveFullAccess || token.driveFullAccess; isPhotos: existingToken.isPhotos || token.isPhotos,
token.nextPageToken = existingToken.nextPageToken; driveFullAccess: existingToken.driveFullAccess || token.driveFullAccess,
});
} }
return token.isLogin && networkSvc.request({ return token.isLogin && networkSvc.request({
method: 'GET', method: 'GET',
@ -296,7 +297,7 @@ export default {
addPhotosAccount() { addPhotosAccount() {
return this.startOauth2(photosScopes); return this.startOauth2(photosScopes);
}, },
getChanges(token) { getChanges(token, startPageToken, spaces) {
const result = { const result = {
changes: [], changes: [],
}; };
@ -307,20 +308,21 @@ export default {
url: 'https://www.googleapis.com/drive/v3/changes', url: 'https://www.googleapis.com/drive/v3/changes',
params: { params: {
pageToken, pageToken,
spaces: 'appDataFolder', spaces,
pageSize: 1000, pageSize: 1000,
fields: 'nextPageToken,newStartPageToken,changes(fileId,removed,file/name,file/properties)', fields: 'nextPageToken,newStartPageToken,changes(fileId,removed,file/name,file/appProperties)',
}, },
}).then((res) => { })
.then((res) => {
result.changes = result.changes.concat(res.body.changes.filter(item => item.fileId)); result.changes = result.changes.concat(res.body.changes.filter(item => item.fileId));
if (res.body.nextPageToken) { if (res.body.nextPageToken) {
return getPage(res.body.nextPageToken); return getPage(res.body.nextPageToken);
} }
result.nextPageToken = res.body.newStartPageToken; result.startPageToken = res.body.newStartPageToken;
return result; return result;
}); });
return getPage(refreshedToken.nextPageToken); return getPage(startPageToken);
}); });
}, },
uploadFile(token, name, parents, appProperties, media, mediaType, fileId, ifNotTooLate) { uploadFile(token, name, parents, appProperties, media, mediaType, fileId, ifNotTooLate) {
@ -338,6 +340,9 @@ export default {
.then(refreshedToken => this.request(refreshedToken, { .then(refreshedToken => this.request(refreshedToken, {
method: 'GET', method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${id}`, url: `https://www.googleapis.com/drive/v3/files/${id}`,
params: {
fields: 'id,name,mimeType,appProperties',
},
}) })
.then(res => res.body)); .then(res => res.body));
}, },
@ -369,7 +374,8 @@ export default {
pageSize: 1000, pageSize: 1000,
fields: 'nextPageToken,revisions(id,modifiedTime,lastModifyingUser/permissionId,lastModifyingUser/displayName,lastModifyingUser/photoLink)', fields: 'nextPageToken,revisions(id,modifiedTime,lastModifyingUser/permissionId,lastModifyingUser/displayName,lastModifyingUser/photoLink)',
}, },
}).then((res) => { })
.then((res) => {
res.body.revisions.forEach((revision) => { res.body.revisions.forEach((revision) => {
store.commit('userInfo/addItem', { store.commit('userInfo/addItem', {
id: revision.lastModifyingUser.permissionId, id: revision.lastModifyingUser.permissionId,

View File

@ -17,8 +17,8 @@ const getMonetize = () => Promise.resolve()
}); });
const isGoogleSponsor = () => { const isGoogleSponsor = () => {
const loginToken = store.getters['data/loginToken']; const sponsorToken = store.getters['workspace/sponsorToken'];
return loginToken && loginToken.isSponsor; return sponsorToken && sponsorToken.isSponsor;
}; };
const checkPayment = () => { const checkPayment = () => {

View File

@ -5,12 +5,13 @@ import diffUtils from './diffUtils';
import networkSvc from './networkSvc'; import networkSvc from './networkSvc';
import providerRegistry from './providers/providerRegistry'; import providerRegistry from './providers/providerRegistry';
import googleDriveAppDataProvider from './providers/googleDriveAppDataProvider'; import googleDriveAppDataProvider from './providers/googleDriveAppDataProvider';
import './providers/googleDriveWorkspaceProvider';
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 autoSyncAfter = utils.randomize(60 * 1000); // 60 sec const minAutoSyncEvery = 60 * 1000; // 60 sec
let workspaceProvider; let syncProvider;
/** /**
* Use a lock in the local storage to prevent multiple windows concurrency. * Use a lock in the local storage to prevent multiple windows concurrency.
@ -22,14 +23,7 @@ const getLastStoredSyncActivity = () =>
/** /**
* Return true if workspace sync is possible. * Return true if workspace sync is possible.
*/ */
const isWorkspaceSyncPossible = () => { const isWorkspaceSyncPossible = () => !!store.getters['workspace/syncToken'];
const loginToken = store.getters['data/loginToken'];
if (!loginToken && Object.keys(store.getters['data/syncData']).length) {
// Reset sync data if token was removed
store.dispatch('data/setSyncData', {});
}
return !!loginToken;
};
/** /**
* Return true if file has at least one explicit sync location. * Return true if file has at least one explicit sync location.
@ -55,8 +49,11 @@ function isSyncWindow() {
* Return true if auto sync can start, ie if lastSyncActivity is old enough. * Return true if auto sync can start, ie if lastSyncActivity is old enough.
*/ */
function isAutoSyncReady() { function isAutoSyncReady() {
const storedLastSyncActivity = getLastStoredSyncActivity(); let autoSyncEvery = store.getters['data/computedSettings'].autoSyncEvery;
return Date.now() > autoSyncAfter + storedLastSyncActivity; if (autoSyncEvery < minAutoSyncEvery) {
autoSyncEvery = minAutoSyncEvery;
}
return Date.now() > autoSyncEvery + getLastStoredSyncActivity();
} }
/** /**
@ -91,7 +88,6 @@ function cleanSyncedContent(syncedContent) {
/** /**
* Apply changes retrieved from the main provider. Update sync data accordingly. * Apply changes retrieved from the main provider. Update sync data accordingly.
* @param {*} changes The changes to apply.
*/ */
function applyChanges(changes) { function applyChanges(changes) {
const storeItemMap = { ...store.getters.allItemMap }; const storeItemMap = { ...store.getters.allItemMap };
@ -99,7 +95,7 @@ function applyChanges(changes) {
let syncDataChanged = false; let syncDataChanged = false;
changes.forEach((change) => { changes.forEach((change) => {
const existingSyncData = syncData[change.fileId]; const existingSyncData = syncData[change.syncDataId];
const existingItem = existingSyncData && storeItemMap[existingSyncData.itemId]; const existingItem = existingSyncData && storeItemMap[existingSyncData.itemId];
if (change.removed && existingSyncData) { if (change.removed && existingSyncData) {
if (existingItem) { if (existingItem) {
@ -107,19 +103,19 @@ function applyChanges(changes) {
store.commit(`${existingItem.type}/deleteItem`, existingItem.id); store.commit(`${existingItem.type}/deleteItem`, existingItem.id);
delete storeItemMap[existingItem.id]; delete storeItemMap[existingItem.id];
} }
delete syncData[change.fileId]; delete syncData[change.syncDataId];
syncDataChanged = true; syncDataChanged = true;
} else if (!change.removed && change.item && change.item.hash) { } else if (!change.removed && change.item && change.item.hash) {
if (!existingSyncData || (existingSyncData.hash !== change.item.hash && ( if (!existingSyncData || (existingSyncData.hash !== change.item.hash && (
!existingItem || existingItem.hash !== change.item.hash !existingItem || existingItem.hash !== change.item.hash
))) { ))) {
// Put object in the store // Put object in the store, except for content and data which will be merge later
if (change.item.type !== 'content') { // Merge contents later if (change.item.type !== 'content' && change.item.type !== 'data') {
store.commit(`${change.item.type}/setItem`, change.item); store.commit(`${change.item.type}/setItem`, change.item);
storeItemMap[change.item.id] = change.item; storeItemMap[change.item.id] = change.item;
} }
} }
syncData[change.fileId] = change.syncData; syncData[change.syncDataId] = change.syncData;
syncDataChanged = true; syncDataChanged = true;
} }
}); });
@ -221,7 +217,7 @@ function syncFile(fileId, syncContext = new SyncContext()) {
...store.getters['syncLocation/groupedByFileId'][fileId] || [], ...store.getters['syncLocation/groupedByFileId'][fileId] || [],
]; ];
if (isWorkspaceSyncPossible()) { if (isWorkspaceSyncPossible()) {
syncLocations.unshift({ id: 'main', providerId: mainProvider.id, fileId }); syncLocations.unshift({ id: 'main', providerId: syncProvider.id, fileId });
} }
let result; let result;
syncLocations.some((syncLocation) => { syncLocations.some((syncLocation) => {
@ -355,7 +351,7 @@ function syncFile(fileId, syncContext = new SyncContext()) {
} }
// If content was just created, restart sync to create the file as well // If content was just created, restart sync to create the file as well
if (provider === mainProvider && if (provider === syncProvider &&
!store.getters['data/syncDataByItemId'][fileId] !store.getters['data/syncDataByItemId'][fileId]
) { ) {
syncContext.restart = true; syncContext.restart = true;
@ -396,21 +392,25 @@ function syncFile(fileId, syncContext = new SyncContext()) {
} }
/** /**
* Sync a data item, typically settings and templates. * Sync a data item, typically settings, workspaces and templates.
*/ */
function syncDataItem(dataId) { function syncDataItem(dataId) {
const item = store.state.data.itemMap[dataId]; const getItem = () => store.state.data.itemMap[dataId]
|| store.state.data.lsItemMap[dataId];
const item = getItem();
const syncData = store.getters['data/syncDataByItemId'][dataId]; const syncData = store.getters['data/syncDataByItemId'][dataId];
// Sync if item hash and syncData hash are inconsistent // Sync if item hash and syncData hash are inconsistent
if (syncData && item && item.hash === syncData.hash) { if (syncData && item && item.hash === syncData.hash) {
return null; return null;
} }
const token = mainProvider.getToken();
return token && mainProvider.downloadData(token, dataId) const syncToken = store.getters['workspace/syncToken'];
return syncToken && syncProvider.downloadData(syncToken, dataId)
.then((serverItem = null) => { .then((serverItem = null) => {
const dataSyncData = store.getters['data/dataSyncData'][dataId]; const dataSyncData = store.getters['data/dataSyncData'][dataId];
let mergedItem = (() => { let mergedItem = (() => {
const clientItem = utils.deepCopy(store.state.data.itemMap[dataId]); const clientItem = utils.deepCopy(getItem());
if (!clientItem) { if (!clientItem) {
return serverItem; return serverItem;
} }
@ -445,15 +445,15 @@ function syncDataItem(dataId) {
}); });
// Retrieve item with new `hash` and freeze it // Retrieve item with new `hash` and freeze it
mergedItem = utils.deepCopy(store.state.data.itemMap[dataId]); mergedItem = utils.deepCopy(getItem());
return Promise.resolve() return Promise.resolve()
.then(() => { .then(() => {
if (serverItem && serverItem.hash === mergedItem.hash) { if (serverItem && serverItem.hash === mergedItem.hash) {
return null; return null;
} }
return mainProvider.uploadData( return syncProvider.uploadData(
token, syncToken,
mergedItem, mergedItem,
dataId, dataId,
); );
@ -469,14 +469,26 @@ function syncDataItem(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.
*/ */
function syncWorkspace() { function syncWorkspace(workspace, syncToken) {
const syncContext = new SyncContext(); const syncContext = new SyncContext();
const mainToken = store.getters['data/loginToken'];
return mainProvider.getChanges(mainToken) return Promise.resolve()
.then(() => {
// Store the sub in the DB since it's not safely stored in the token
const localSettings = store.getters['data/localSettings'];
if (!localSettings.syncSub) {
store.dispatch('data/patchLocalSettings', {
workspaceSyncSub: syncToken.sub,
});
} else if (localSettings.syncSub !== syncToken.sub) {
throw new Error('Synchronization failed due to token inconsistency.');
}
})
.then(() => syncProvider.getChanges(syncToken))
.then((changes) => { .then((changes) => {
// Apply changes // Apply changes
applyChanges(changes); applyChanges(changes);
mainProvider.setAppliedChanges(mainToken, changes); syncProvider.setAppliedChanges(changes);
// Prevent from sending items too long after changes have been retrieved // Prevent from sending items too long after changes have been retrieved
const syncStartTime = Date.now(); const syncStartTime = Date.now();
@ -504,8 +516,8 @@ function syncWorkspace() {
// Add file if content has been uploaded // Add file if content has been uploaded
(item.type !== 'file' || syncDataByItemId[`${id}/content`]) (item.type !== 'file' || syncDataByItemId[`${id}/content`])
) { ) {
result = mainProvider.saveItem( result = syncProvider.saveItem(
mainToken, syncToken,
// Use deepCopy to freeze objects // Use deepCopy to freeze objects
utils.deepCopy(item), utils.deepCopy(item),
utils.deepCopy(existingSyncData), utils.deepCopy(existingSyncData),
@ -529,19 +541,20 @@ function syncWorkspace() {
...store.state.syncLocation.itemMap, ...store.state.syncLocation.itemMap,
...store.state.publishLocation.itemMap, ...store.state.publishLocation.itemMap,
...store.state.content.itemMap, ...store.state.content.itemMap,
...store.state.data.itemMap,
}; };
const syncData = store.getters['data/syncData']; const syncData = store.getters['data/syncData'];
let result; let result;
Object.entries(syncData).some(([, existingSyncData]) => { Object.entries(syncData).some(([, existingSyncData]) => {
if (!storeItemMap[existingSyncData.itemId] && if (!storeItemMap[existingSyncData.itemId] &&
// We don't want to delete data items, especially on first sync
existingSyncData.type !== 'data' &&
// Remove content only if file has been removed // Remove content only if file has been removed
(existingSyncData.type !== 'content' || !storeItemMap[existingSyncData.itemId.split('/')[0]]) (existingSyncData.type !== 'content' || !storeItemMap[existingSyncData.itemId.split('/')[0]])
) { ) {
// Use deepCopy to freeze objects // Use deepCopy to freeze objects
const syncDataToRemove = utils.deepCopy(existingSyncData); const syncDataToRemove = utils.deepCopy(existingSyncData);
result = mainProvider result = syncProvider
.removeItem(mainToken, syncDataToRemove, ifNotTooLate) .removeItem(syncToken, syncDataToRemove, ifNotTooLate)
.then(() => { .then(() => {
const syncDataCopy = { ...store.getters['data/syncData'] }; const syncDataCopy = { ...store.getters['data/syncData'] };
delete syncDataCopy[syncDataToRemove.id]; delete syncDataCopy[syncDataToRemove.id];
@ -590,7 +603,10 @@ function syncWorkspace() {
return Promise.resolve() return Promise.resolve()
.then(() => saveNextItem()) .then(() => saveNextItem())
.then(() => removeNextItem()) .then(() => removeNextItem())
.then(() => syncDataItem('settings')) // Sync settings only in the main workspace
.then(() => workspace.id === 'main' && syncDataItem('settings'))
// Sync workspaces only in the main workspace
.then(() => workspace.id === 'main' && syncDataItem('workspaces'))
.then(() => syncDataItem('templates')) .then(() => syncDataItem('templates'))
.then(() => { .then(() => {
const currentFileId = store.getters['file/current'].id; const currentFileId = store.getters['file/current'].id;
@ -605,14 +621,14 @@ function syncWorkspace() {
() => { () => {
if (syncContext.restart) { if (syncContext.restart) {
// Restart sync // Restart sync
return syncWorkspace(); return syncWorkspace(workspace, syncToken);
} }
return null; return null;
}, },
(err) => { (err) => {
if (err && err.message === 'TOO_LATE') { if (err && err.message === 'TOO_LATE') {
// Restart sync // Restart sync
return syncWorkspace(); return syncWorkspace(workspace, syncToken);
} }
throw err; throw err;
}); });
@ -660,12 +676,14 @@ function requestSync() {
Promise.resolve() Promise.resolve()
.then(() => { .then(() => {
if (isWorkspaceSyncPossible()) { if (isWorkspaceSyncPossible()) {
return syncWorkspace(); return syncWorkspace(
store.getters['workspace/currentWorkspace'],
store.getters['workspace/syncToken']);
} }
if (hasCurrentFileSyncLocations()) { if (hasCurrentFileSyncLocations()) {
// Only sync current file if data sync is unavailable. // Only sync current file if workspace sync is unavailable.
// We also could sync files that are out-of-sync but it would // We could also sync files that are out-of-sync but it would
// require to load the syncedContent objects of all files. // require to load all the syncedContent objects from the DB.
return syncFile(store.getters['file/current'].id); return syncFile(store.getters['file/current'].id);
} }
return null; return null;
@ -692,14 +710,14 @@ export default {
// Load workspaces and tokens from localStorage // Load workspaces and tokens from localStorage
localDbSvc.syncLocalStorage(); localDbSvc.syncLocalStorage();
// Try to find a suitable workspace provider // Try to find a suitable workspace sync provider
workspaceProvider = providerRegistry.providers[utils.queryParams.providerId]; syncProvider = providerRegistry.providers[utils.queryParams.providerId];
if (!workspaceProvider || !workspaceProvider.initWorkspace) { if (!syncProvider || !syncProvider.initWorkspace) {
workspaceProvider = googleDriveAppDataProvider; syncProvider = googleDriveAppDataProvider;
} }
return workspaceProvider.initWorkspace() return syncProvider.initWorkspace()
.then(workspace => store.commit('workspace/setCurrentWorkspaceId', workspace.id)) .then(workspace => store.dispatch('workspace/setCurrentWorkspaceId', workspace.id))
.then(() => localDbSvc.init()) .then(() => localDbSvc.init())
.then(() => { .then(() => {
// Sync periodically // Sync periodically

View File

@ -4,24 +4,26 @@ import defaultProperties from '../data/defaultFileProperties.yml';
const origin = `${location.protocol}//${location.host}`; const origin = `${location.protocol}//${location.host}`;
// For uid() // For utils.uid()
const uidLength = 16; const uidLength = 16;
const crypto = window.crypto || window.msCrypto; const crypto = window.crypto || window.msCrypto;
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const radix = alphabet.length; const radix = alphabet.length;
const array = new Uint32Array(uidLength); const array = new Uint32Array(uidLength);
// For parseQueryParams() // For utils.parseQueryParams()
const parseQueryParams = (params) => { const parseQueryParams = (params) => {
const result = {}; const result = {};
params.split('&').forEach((param) => { params.split('&').forEach((param) => {
const [key, value] = param.split('=').map(decodeURIComponent); const [key, value] = param.split('=').map(decodeURIComponent);
if (key) {
result[key] = value; result[key] = value;
}
}); });
return result; return result;
}; };
// For addQueryParams() // For utils.addQueryParams()
const urlParser = window.document.createElement('a'); const urlParser = window.document.createElement('a');
export default { export default {
@ -121,20 +123,43 @@ export default {
return setInterval(() => func(), this.randomize(interval)); return setInterval(() => func(), this.randomize(interval));
}, },
parseQueryParams, parseQueryParams,
addQueryParams(url = '', params = {}) { addQueryParams(url = '', params = {}, hash = false) {
const keys = Object.keys(params).filter(key => params[key] != null); const keys = Object.keys(params).filter(key => params[key] != null);
urlParser.href = url; urlParser.href = url;
if (!keys.length) { if (!keys.length) {
return urlParser.href; return urlParser.href;
} }
const serializedParams = keys.map(key =>
`${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&');
if (hash) {
if (urlParser.hash) {
urlParser.hash += '&';
} else {
urlParser.hash = '#';
}
urlParser.hash += serializedParams;
} else {
if (urlParser.search) { if (urlParser.search) {
urlParser.search += '&'; urlParser.search += '&';
} else { } else {
urlParser.search = '?'; urlParser.search = '?';
} }
urlParser.search += keys.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&'); urlParser.search += serializedParams;
}
return urlParser.href; return urlParser.href;
}, },
resolveUrl(url) {
return this.addQueryParams(url);
},
createHiddenIframe(url) {
const iframeElt = document.createElement('iframe');
iframeElt.style.position = 'absolute';
iframeElt.style.left = '-9999px';
iframeElt.style.width = '1px';
iframeElt.style.height = '1px';
iframeElt.src = url;
return iframeElt;
},
wrapRange(range, eltProperties) { wrapRange(range, eltProperties) {
const rangeLength = `${range}`.length; const rangeLength = `${range}`.length;
let wrappedLength = 0; let wrappedLength = 0;

View File

@ -87,6 +87,9 @@ const tokenSetter = providerId => ({ getters, dispatch }, token) => {
}); });
}; };
// For workspaces
const urlParser = window.document.createElement('a');
export default { export default {
namespaced: true, namespaced: true,
state: { state: {
@ -118,18 +121,24 @@ export default {
}, },
}, },
getters: { getters: {
workspaces: (state) => { workspaces: (state, getters, rootState, rootGetters) => {
const workspaces = (state.lsItemMap.workspaces || empty('workspaces')).data; const workspaces = (state.lsItemMap.workspaces || empty('workspaces')).data;
const result = {}; const sanitizedWorkspaces = {};
const mainWorkspaceToken = rootGetters['workspace/mainWorkspaceToken'];
Object.entries(workspaces).forEach(([id, workspace]) => { Object.entries(workspaces).forEach(([id, workspace]) => {
result[id] = { const sanitizedWorkspace = {
...workspace,
id, id,
providerId: workspace.providerId || 'googleDriveWorkspace', providerId: mainWorkspaceToken && 'googleDriveAppData',
url: utils.addQueryParams('app'), 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);
sanitizedWorkspaces[id] = sanitizedWorkspace;
}); });
return result; return sanitizedWorkspaces;
}, },
settings: getter('settings'), settings: getter('settings'),
computedSettings: (state, getters) => { computedSettings: (state, getters) => {
@ -202,13 +211,6 @@ export default {
githubTokens: (state, getters) => getters.tokens.github || {}, githubTokens: (state, getters) => getters.tokens.github || {},
wordpressTokens: (state, getters) => getters.tokens.wordpress || {}, wordpressTokens: (state, getters) => getters.tokens.wordpress || {},
zendeskTokens: (state, getters) => getters.tokens.zendesk || {}, zendeskTokens: (state, getters) => getters.tokens.zendesk || {},
loginToken: (state, getters) => {
// Return the first google token that has the isLogin flag
const googleTokens = getters.googleTokens;
const loginSubs = Object.keys(googleTokens)
.filter(sub => googleTokens[sub].isLogin);
return googleTokens[loginSubs[0]];
},
}, },
actions: { actions: {
setWorkspaces: setter('workspaces'), setWorkspaces: setter('workspaces'),

View File

@ -123,7 +123,7 @@ export default {
} }
}, },
createNewDiscussion({ commit, dispatch, rootGetters }, selection) { createNewDiscussion({ commit, dispatch, rootGetters }, selection) {
const loginToken = rootGetters['data/loginToken']; const loginToken = rootGetters['workspace/loginToken'];
if (!loginToken) { if (!loginToken) {
dispatch('modal/signInForComment', { dispatch('modal/signInForComment', {
onResolve: () => googleHelper.signin() onResolve: () => googleHelper.signin()

View File

@ -18,6 +18,7 @@ import modal from './modal';
import notification from './notification'; import notification from './notification';
import queue from './queue'; import queue from './queue';
import userInfo from './userInfo'; import userInfo from './userInfo';
import workspace from './workspace';
Vue.use(Vuex); Vue.use(Vuex);
@ -41,6 +42,7 @@ const store = new Vuex.Store({
notification, notification,
queue, queue,
userInfo, userInfo,
workspace,
}, },
state: { state: {
offline: false, offline: false,
@ -55,8 +57,8 @@ const store = new Vuex.Store({
return result; return result;
}, },
isSponsor: (state, getters) => { isSponsor: (state, getters) => {
const loginToken = getters['data/loginToken']; const sponsorToken = getters['workspace/sponsorToken'];
return state.monetizeSponsor || (loginToken && loginToken.isSponsor); return state.monetizeSponsor || (sponsorToken && sponsorToken.isSponsor);
}, },
}, },
mutations: { mutations: {

View File

@ -75,8 +75,13 @@ export default {
resolveText: 'Yes, revert', resolveText: 'Yes, revert',
rejectText: 'No', rejectText: 'No',
}), }),
removeWorkspace: ({ dispatch }) => dispatch('open', {
content: '<p>This will clean your workspace locally. Are you sure?</p>',
resolveText: 'Yes, clean',
rejectText: 'No',
}),
reset: ({ dispatch }) => dispatch('open', { reset: ({ dispatch }) => dispatch('open', {
content: '<p>This will clean your local files and settings. Are you sure?</p>', content: '<p>This will clean all your workspaces locally. Are you sure?</p>',
resolveText: 'Yes, clean', resolveText: 'Yes, clean',
rejectText: 'No', rejectText: 'No',
}), }),

View File

@ -1,38 +1,50 @@
import utils from '../services/utils';
import googleHelper from '../services/providers/helpers/googleHelper';
import syncSvc from '../services/syncSvc';
const idShifter = offset => (state, getters) => {
const ids = Object.keys(getters.currentFileDiscussions);
const idx = ids.indexOf(state.currentDiscussionId) + offset + ids.length;
return ids[idx % ids.length];
};
export default { export default {
namespaced: true, namespaced: true,
state: { state: {
currentWorkspaceId: null, currentWorkspaceId: null,
lastFocus: 0,
}, },
mutations: { mutations: {
setCurrentWorkspaceId: (state, value) => { setCurrentWorkspaceId: (state, value) => {
state.currentWorkspaceId = value; state.currentWorkspaceId = value;
}, },
setLastFocus: (state, value) => {
state.lastFocus = value;
},
}, },
getters: { getters: {
mainWorkspace: (state, getters, rootState, rootGetters) => {
const workspaces = rootGetters['data/workspaces'];
return workspaces.main;
},
currentWorkspace: (state, getters, rootState, rootGetters) => { currentWorkspace: (state, getters, rootState, rootGetters) => {
const workspaces = rootGetters['data/workspaces']; const workspaces = rootGetters['data/workspaces'];
return workspaces[state.currentWorkspaceId] || workspaces.main; return workspaces[state.currentWorkspaceId] || getters.mainWorkspace;
}, },
lastSyncActivityKey: (state, getters) => { lastSyncActivityKey: (state, getters) => `${getters.currentWorkspace.id}/lastSyncActivity`,
const workspaceId = getters.currentWorkspace.id; lastFocusKey: (state, getters) => `${getters.currentWorkspace.id}/lastWindowFocus`,
return `${workspaceId}/lastSyncActivity`; mainWorkspaceToken: (state, getters, rootState, rootGetters) => {
const googleTokens = rootGetters['data/googleTokens'];
const loginSubs = Object.keys(googleTokens)
.filter(sub => googleTokens[sub].isLogin);
return googleTokens[loginSubs[0]];
}, },
lastFocusKey: (state, getters) => { syncToken: (state, getters, rootState, rootGetters) => {
const workspaceId = getters.currentWorkspace.id; const workspace = getters.currentWorkspace;
return `${workspaceId}/lastWindowFocus`; if (workspace.providerId === 'googleDriveWorkspace') {
const googleTokens = rootGetters['data/googleTokens'];
return googleTokens[workspace.sub];
}
return getters.mainWorkspaceToken;
}, },
lastFocus: (state, getters) => parseInt(localStorage.getItem(getters.lastFocusKey), 10) || 0, loginToken: (state, getters) => getters.syncToken,
sponsorToken: (state, getters) => getters.mainWorkspaceToken,
}, },
actions: { actions: {
setCurrentWorkspaceId: ({ commit, getters }, value) => {
commit('setCurrentWorkspaceId', value);
const lastFocus = parseInt(localStorage.getItem(getters.lastFocusKey), 10) || 0;
commit('setLastFocus', lastFocus);
},
}, },
}; };