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();
sponsorSvc.init();
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'">
<icon-folder-plus></icon-folder-plus>
</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'">
<icon-delete></icon-delete>
</button>
<button class="side-title__button button" @click="editItem()" v-title="'Rename'">
<icon-pen></icon-pen>
</button>
</div>
<button class="side-title__button button" @click="toggleExplorer(false)" v-title="'Close explorer'">
<icon-close></icon-close>

View File

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

View File

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

View File

@ -22,7 +22,7 @@ import { mapGetters, mapActions } from 'vuex';
import CommentList from './gutters/CommentList';
import PreviewNewDiscussionButton from './gutters/PreviewNewDiscussionButton';
const appUri = `${window.location.protocol}//${window.location.host}`;
const appUri = `${location.protocol}//${location.host}`;
export default {
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;
$border-radius-base: 2px;
$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-background: rgba(255, 255, 255, 0.1);

View File

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

View File

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

View File

@ -12,12 +12,12 @@
</menu-entry>
<menu-entry @click.native="exportPdf">
<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>
</menu-entry>
<menu-entry @click.native="exportPandoc">
<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>
</menu-entry>
</div>

View File

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

View File

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

View File

@ -13,22 +13,22 @@
<menu-entry @click.native="reset">
<icon-logout slot="icon"></icon-logout>
<div>Reset application</div>
<span>Sign out and clean local data.</span>
<span>Sign out and clean all workspaces.</span>
</menu-entry>
<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">
<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">
<icon-content-save></icon-content-save>
</div>
<div class="flex flex--column">
Import backup
Import workspace backup
</div>
</label>
<menu-entry href="#exportBackup=true" target="_blank">
<icon-content-save slot="icon"></icon-content-save>
Export backup
</menu-entry>
<hr>
<menu-entry @click.native="about">
<icon-help-circle slot="icon"></icon-help-circle>
@ -47,8 +47,8 @@
<script>
import MenuEntry from './common/MenuEntry';
import localDbSvc from '../../services/localDbSvc';
import backupSvc from '../../services/backupSvc';
import utils from '../../services/utils';
import welcomeFile from '../../data/welcomeFile.md';
export default {
@ -72,6 +72,17 @@ export default {
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() {
return this.$store.dispatch('modal/open', 'settings')
.then(
@ -88,10 +99,10 @@ export default {
},
reset() {
return this.$store.dispatch('modal/reset')
.then(
() => localDbSvc.removeDb(),
() => {}, // Cancel
);
.then(() => {
location.href = '#reset=true';
location.reload();
});
},
welcomeFile() {
return this.$store.dispatch('createFile', {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,7 +24,7 @@ if (NODE_ENV === 'production') {
.then(() => {
localStorage.updated = true;
// 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 dbStoreName = 'objects';
const exportBackup = utils.queryParams.exportBackup;
if (exportBackup) {
location.hash = '';
}
const exportWorkspace = utils.queryParams.exportWorkspace;
const deleteMarkerMaxAge = 1000;
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 {
constructor() {
this.getTxCbs = [];
// Make the DB name
const workspaceId = store.getters['workspace/currentWorkspace'].id;
this.dbName = 'stackedit-db';
if (workspaceId !== 'main') {
this.dbName += `-${workspaceId}`;
}
this.dbName = getDbName(workspaceId);
// Init connection
const request = indexedDB.open(this.dbName, dbVersion);
@ -33,7 +34,7 @@ class Connection {
request.onsuccess = (event) => {
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 = null;
@ -101,14 +102,33 @@ const localDbSvc = {
* Create the connection and start syncing.
*/
init() {
// Create the connection
this.connection = new Connection();
// Load the DB
return localDbSvc.sync()
return Promise.resolve()
.then(() => {
// If exportBackup parameter was provided
if (exportBackup) {
// 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
this.connection = new Connection();
// Load the DB
return localDbSvc.sync();
})
.then(() => {
// If exportWorkspace parameter was provided
if (exportWorkspace) {
const backup = JSON.stringify(store.getters.allItemMap);
const blob = new Blob([backup], {
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 (!store.getters['data/loginToken'] &&
(store.getters['workspace/lastFocus'] + utils.cleanTrashAfter < Date.now())
if (!store.getters['workspace/syncToken'] &&
(store.state.workspace.lastFocus + utils.cleanTrashAfter < Date.now())
) {
// Clean files
store.getters['file/items']
@ -141,14 +161,14 @@ const localDbSvc = {
// Enable sponsorship
if (utils.queryParams.paymentSuccess) {
location.hash = '';
location.hash = ''; // PaymentSuccess param is always on its own
store.dispatch('modal/paymentSuccess');
const loginToken = store.getters['data/loginToken'];
const sponsorToken = store.getters['workspace/sponsorToken'];
// Force check sponsorship after a few seconds
const currentDate = Date.now();
if (loginToken && loginToken.expiresOn > currentDate - checkSponsorshipAfter) {
if (sponsorToken && sponsorToken.expiresOn > currentDate - checkSponsorshipAfter) {
store.dispatch('data/setGoogleToken', {
...loginToken,
...sponsorToken,
expiresOn: currentDate - checkSponsorshipAfter,
});
}
@ -288,7 +308,7 @@ const localDbSvc = {
lastTx = item.tx;
if (this.lastTx && item.tx - this.lastTx > deleteMarkerMaxAge) {
// We may have missed some delete markers
window.location.reload();
location.reload();
return;
}
}
@ -376,7 +396,7 @@ const localDbSvc = {
// DB item is different from the corresponding store item
this.hashMap[dbItem.type][dbItem.id] = dbItem.hash;
// 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
dbItem.tx = undefined;
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) => {
const request = indexedDB.deleteDatabase('stackedit-db');
const dbName = getDbName(id);
const request = indexedDB.deleteDatabase(dbName);
request.onerror = reject;
request.onsuccess = resolve;
})
.then(() => {
window.location.reload();
}, () => store.dispatch('notification/error', 'Could not delete local database.'));
.then(() => {
localStorage.removeItem(`${id}/lastSyncActivity`);
localStorage.removeItem(`${id}/lastWindowFocus`);
});
},
};

View File

@ -7,7 +7,6 @@ const networkTimeout = 30 * 1000; // 30 sec
let isConnectionDown = false;
const userInactiveAfter = 2 * 60 * 1000; // 2 minutes
export default {
init() {
// Keep track of the last user activity
@ -82,7 +81,9 @@ export default {
window.addEventListener('offline', checkOffline);
},
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() {
return this.lastActivity > Date.now() - userInactiveAfter && this.isWindowFocused();
@ -113,10 +114,7 @@ export default {
let wnd;
if (silent) {
// Use an iframe as wnd for silent mode
iframeElt = document.createElement('iframe');
iframeElt.style.position = 'absolute';
iframeElt.style.left = '-9999px';
iframeElt.src = authorizeUrl;
iframeElt = utils.createHiddenIframe(authorizeUrl);
document.body.appendChild(iframeElt);
wnd = iframeElt.contentWindow;
} else {

View File

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

View File

@ -3,20 +3,21 @@ import googleHelper from './helpers/googleHelper';
import providerRegistry from './providerRegistry';
import utils from '../utils';
let workspaceFolderId;
const makeWorkspaceId = () => {
};
export default providerRegistry.register({
id: 'googleDriveWorkspace',
getToken() {
return store.getters['data/loginToken'];
return store.getters['workspace/syncToken'];
},
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({
workspaceId: this.makeWorkspaceId(folder.id),
folderId: folder.id,
dataFolderId: folder.appProperties.dataFolderId,
trashFolderId: folder.appProperties.trashFolderId,
})
@ -29,7 +30,7 @@ export default providerRegistry.register({
token,
'.stackedit-data',
[folder.id],
{ workspaceId: properties.workspaceId },
{ folderId: folder.id },
undefined,
'application/vnd.google-apps.folder',
)
@ -47,7 +48,7 @@ export default providerRegistry.register({
token,
'.stackedit-trash',
[folder.id],
{ workspaceId: properties.workspaceId },
{ folderId: folder.id },
undefined,
'application/vnd.google-apps.folder',
)
@ -58,7 +59,7 @@ export default providerRegistry.register({
})
.then((properties) => {
// 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.trashFolderId === folder.appProperties.trashFolderId
) {
@ -76,26 +77,48 @@ export default providerRegistry.register({
.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
const workspaceId = makeWorkspaceId(folder.id);
store.dispatch('data/patchWorkspaces', {
[properties.workspaceId]: {
id: properties.workspaceId,
[workspaceId]: {
id: workspaceId,
sub: token.sub,
name: folder.name,
providerId: this.id,
url: utils.resolveUrl(hash),
folderId: folder.id,
dataFolderId: properties.dataFolderId,
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])
.then(token => token || this.$store.dispatch('modal/workspaceGoogleRedirection', {
onResolve: () => googleHelper.addDriveAccount(),
}))
const workspace = getWorkspace(utils.queryParams.folderId);
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(),
});
})
.then(token => Promise.resolve()
// If no folderId is provided, create one
.then(() => utils.queryParams.folderId || googleHelper.uploadFile(
token,
'StackEdit workspace',
@ -103,25 +126,31 @@ export default providerRegistry.register({
undefined,
undefined,
'application/vnd.google-apps.folder',
).then(folder => initFolder(token, folder).then(() => folder.id)))
.then((folderId) => {
const workspaceId = this.makeWorkspaceId(folderId);
const workspace = store.getters['data/workspaces'][workspaceId];
return workspace || googleHelper.getFile(token, folderId)
.then((folder) => {
const folderWorkspaceId = folder.appProperties.workspaceId;
if (folderWorkspaceId && folderWorkspaceId !== workspaceId) {
throw new Error(`Google Drive folder ${folderId} is part of another workspace.`);
}
return initFolder(token, folder);
});
}));
)
.then(folder => initFolder(token, {
...folder,
appProperties: {},
})
.then(() => folder.id)))
// If workspace does not exist, initialize one
.then(folderId => getWorkspace(folderId) || googleHelper.getFile(token, folderId)
.then((folder) => {
const folderIdProperty = folder.appProperties.folderId;
if (folderIdProperty && folderIdProperty !== folderId) {
throw new Error(`Google Drive folder ${folderId} is part of another workspace.`);
}
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) {
return googleHelper.getChanges(token)
const startPageToken = store.getters['data/localSettings'].syncStartPageToken;
return googleHelper.getChanges(token, startPageToken, 'appDataFolder')
.then((result) => {
const changes = result.changes.filter((change) => {
if (change.file) {
// Parse item from file name
try {
change.item = JSON.parse(change.file.name);
} catch (e) {
@ -136,121 +165,11 @@ export default providerRegistry.register({
};
change.file = undefined;
}
change.syncDataId = change.fileId;
return true;
});
changes.nextPageToken = result.nextPageToken;
changes.startPageToken = result.startPageToken;
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),
idToken: data.idToken,
sub: `${res.body.sub}`,
isLogin: !store.getters['data/loginToken'] &&
isLogin: !store.getters['workspace/loginToken'] &&
scopes.indexOf('https://www.googleapis.com/auth/drive.appdata') !== -1,
isSponsor: false,
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.
// That's no problem, token will be refreshed later with merged scopes.
// Restore flags
token.isLogin = existingToken.isLogin || token.isLogin;
token.isSponsor = existingToken.isSponsor;
token.isDrive = existingToken.isDrive || token.isDrive;
token.isBlogger = existingToken.isBlogger || token.isBlogger;
token.isPhotos = existingToken.isPhotos || token.isPhotos;
token.driveFullAccess = existingToken.driveFullAccess || token.driveFullAccess;
token.nextPageToken = existingToken.nextPageToken;
Object.assign(token, {
isLogin: existingToken.isLogin || token.isLogin,
isSponsor: existingToken.isSponsor,
isDrive: existingToken.isDrive || token.isDrive,
isBlogger: existingToken.isBlogger || token.isBlogger,
isPhotos: existingToken.isPhotos || token.isPhotos,
driveFullAccess: existingToken.driveFullAccess || token.driveFullAccess,
});
}
return token.isLogin && networkSvc.request({
method: 'GET',
@ -296,7 +297,7 @@ export default {
addPhotosAccount() {
return this.startOauth2(photosScopes);
},
getChanges(token) {
getChanges(token, startPageToken, spaces) {
const result = {
changes: [],
};
@ -307,20 +308,21 @@ export default {
url: 'https://www.googleapis.com/drive/v3/changes',
params: {
pageToken,
spaces: 'appDataFolder',
spaces,
pageSize: 1000,
fields: 'nextPageToken,newStartPageToken,changes(fileId,removed,file/name,file/properties)',
fields: 'nextPageToken,newStartPageToken,changes(fileId,removed,file/name,file/appProperties)',
},
}).then((res) => {
result.changes = result.changes.concat(res.body.changes.filter(item => item.fileId));
if (res.body.nextPageToken) {
return getPage(res.body.nextPageToken);
}
result.nextPageToken = res.body.newStartPageToken;
return result;
});
})
.then((res) => {
result.changes = result.changes.concat(res.body.changes.filter(item => item.fileId));
if (res.body.nextPageToken) {
return getPage(res.body.nextPageToken);
}
result.startPageToken = res.body.newStartPageToken;
return result;
});
return getPage(refreshedToken.nextPageToken);
return getPage(startPageToken);
});
},
uploadFile(token, name, parents, appProperties, media, mediaType, fileId, ifNotTooLate) {
@ -338,6 +340,9 @@ export default {
.then(refreshedToken => this.request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${id}`,
params: {
fields: 'id,name,mimeType,appProperties',
},
})
.then(res => res.body));
},
@ -369,20 +374,21 @@ export default {
pageSize: 1000,
fields: 'nextPageToken,revisions(id,modifiedTime,lastModifyingUser/permissionId,lastModifyingUser/displayName,lastModifyingUser/photoLink)',
},
}).then((res) => {
res.body.revisions.forEach((revision) => {
store.commit('userInfo/addItem', {
id: revision.lastModifyingUser.permissionId,
name: revision.lastModifyingUser.displayName,
imageUrl: revision.lastModifyingUser.photoLink,
})
.then((res) => {
res.body.revisions.forEach((revision) => {
store.commit('userInfo/addItem', {
id: revision.lastModifyingUser.permissionId,
name: revision.lastModifyingUser.displayName,
imageUrl: revision.lastModifyingUser.photoLink,
});
revisions.push(revision);
});
revisions.push(revision);
if (res.body.nextPageToken) {
return getPage(res.body.nextPageToken);
}
return revisions;
});
if (res.body.nextPageToken) {
return getPage(res.body.nextPageToken);
}
return revisions;
});
return getPage();
});

View File

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

View File

@ -5,12 +5,13 @@ import diffUtils from './diffUtils';
import networkSvc from './networkSvc';
import providerRegistry from './providers/providerRegistry';
import googleDriveAppDataProvider from './providers/googleDriveAppDataProvider';
import './providers/googleDriveWorkspaceProvider';
const inactivityThreshold = 3 * 1000; // 3 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.
@ -22,14 +23,7 @@ const getLastStoredSyncActivity = () =>
/**
* Return true if workspace sync is possible.
*/
const isWorkspaceSyncPossible = () => {
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;
};
const isWorkspaceSyncPossible = () => !!store.getters['workspace/syncToken'];
/**
* 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.
*/
function isAutoSyncReady() {
const storedLastSyncActivity = getLastStoredSyncActivity();
return Date.now() > autoSyncAfter + storedLastSyncActivity;
let autoSyncEvery = store.getters['data/computedSettings'].autoSyncEvery;
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.
* @param {*} changes The changes to apply.
*/
function applyChanges(changes) {
const storeItemMap = { ...store.getters.allItemMap };
@ -99,7 +95,7 @@ function applyChanges(changes) {
let syncDataChanged = false;
changes.forEach((change) => {
const existingSyncData = syncData[change.fileId];
const existingSyncData = syncData[change.syncDataId];
const existingItem = existingSyncData && storeItemMap[existingSyncData.itemId];
if (change.removed && existingSyncData) {
if (existingItem) {
@ -107,19 +103,19 @@ function applyChanges(changes) {
store.commit(`${existingItem.type}/deleteItem`, existingItem.id);
delete storeItemMap[existingItem.id];
}
delete syncData[change.fileId];
delete syncData[change.syncDataId];
syncDataChanged = true;
} else if (!change.removed && change.item && change.item.hash) {
if (!existingSyncData || (existingSyncData.hash !== change.item.hash && (
!existingItem || existingItem.hash !== change.item.hash
))) {
// Put object in the store
if (change.item.type !== 'content') { // Merge contents later
// Put object in the store, except for content and data which will be merge later
if (change.item.type !== 'content' && change.item.type !== 'data') {
store.commit(`${change.item.type}/setItem`, change.item);
storeItemMap[change.item.id] = change.item;
}
}
syncData[change.fileId] = change.syncData;
syncData[change.syncDataId] = change.syncData;
syncDataChanged = true;
}
});
@ -221,7 +217,7 @@ function syncFile(fileId, syncContext = new SyncContext()) {
...store.getters['syncLocation/groupedByFileId'][fileId] || [],
];
if (isWorkspaceSyncPossible()) {
syncLocations.unshift({ id: 'main', providerId: mainProvider.id, fileId });
syncLocations.unshift({ id: 'main', providerId: syncProvider.id, fileId });
}
let result;
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 (provider === mainProvider &&
if (provider === syncProvider &&
!store.getters['data/syncDataByItemId'][fileId]
) {
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) {
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];
// Sync if item hash and syncData hash are inconsistent
if (syncData && item && item.hash === syncData.hash) {
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) => {
const dataSyncData = store.getters['data/dataSyncData'][dataId];
let mergedItem = (() => {
const clientItem = utils.deepCopy(store.state.data.itemMap[dataId]);
const clientItem = utils.deepCopy(getItem());
if (!clientItem) {
return serverItem;
}
@ -445,15 +445,15 @@ function syncDataItem(dataId) {
});
// Retrieve item with new `hash` and freeze it
mergedItem = utils.deepCopy(store.state.data.itemMap[dataId]);
mergedItem = utils.deepCopy(getItem());
return Promise.resolve()
.then(() => {
if (serverItem && serverItem.hash === mergedItem.hash) {
return null;
}
return mainProvider.uploadData(
token,
return syncProvider.uploadData(
syncToken,
mergedItem,
dataId,
);
@ -469,14 +469,26 @@ function syncDataItem(dataId) {
/**
* 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 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) => {
// Apply changes
applyChanges(changes);
mainProvider.setAppliedChanges(mainToken, changes);
syncProvider.setAppliedChanges(changes);
// Prevent from sending items too long after changes have been retrieved
const syncStartTime = Date.now();
@ -504,8 +516,8 @@ function syncWorkspace() {
// Add file if content has been uploaded
(item.type !== 'file' || syncDataByItemId[`${id}/content`])
) {
result = mainProvider.saveItem(
mainToken,
result = syncProvider.saveItem(
syncToken,
// Use deepCopy to freeze objects
utils.deepCopy(item),
utils.deepCopy(existingSyncData),
@ -529,19 +541,20 @@ function syncWorkspace() {
...store.state.syncLocation.itemMap,
...store.state.publishLocation.itemMap,
...store.state.content.itemMap,
...store.state.data.itemMap,
};
const syncData = store.getters['data/syncData'];
let result;
Object.entries(syncData).some(([, existingSyncData]) => {
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
(existingSyncData.type !== 'content' || !storeItemMap[existingSyncData.itemId.split('/')[0]])
) {
// Use deepCopy to freeze objects
const syncDataToRemove = utils.deepCopy(existingSyncData);
result = mainProvider
.removeItem(mainToken, syncDataToRemove, ifNotTooLate)
result = syncProvider
.removeItem(syncToken, syncDataToRemove, ifNotTooLate)
.then(() => {
const syncDataCopy = { ...store.getters['data/syncData'] };
delete syncDataCopy[syncDataToRemove.id];
@ -590,7 +603,10 @@ function syncWorkspace() {
return Promise.resolve()
.then(() => saveNextItem())
.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(() => {
const currentFileId = store.getters['file/current'].id;
@ -605,14 +621,14 @@ function syncWorkspace() {
() => {
if (syncContext.restart) {
// Restart sync
return syncWorkspace();
return syncWorkspace(workspace, syncToken);
}
return null;
},
(err) => {
if (err && err.message === 'TOO_LATE') {
// Restart sync
return syncWorkspace();
return syncWorkspace(workspace, syncToken);
}
throw err;
});
@ -660,12 +676,14 @@ function requestSync() {
Promise.resolve()
.then(() => {
if (isWorkspaceSyncPossible()) {
return syncWorkspace();
return syncWorkspace(
store.getters['workspace/currentWorkspace'],
store.getters['workspace/syncToken']);
}
if (hasCurrentFileSyncLocations()) {
// Only sync current file if data sync is unavailable.
// We also could sync files that are out-of-sync but it would
// require to load the syncedContent objects of all files.
// Only sync current file if workspace sync is unavailable.
// We could also sync files that are out-of-sync but it would
// require to load all the syncedContent objects from the DB.
return syncFile(store.getters['file/current'].id);
}
return null;
@ -692,14 +710,14 @@ export default {
// Load workspaces and tokens from localStorage
localDbSvc.syncLocalStorage();
// Try to find a suitable workspace provider
workspaceProvider = providerRegistry.providers[utils.queryParams.providerId];
if (!workspaceProvider || !workspaceProvider.initWorkspace) {
workspaceProvider = googleDriveAppDataProvider;
// Try to find a suitable workspace sync provider
syncProvider = providerRegistry.providers[utils.queryParams.providerId];
if (!syncProvider || !syncProvider.initWorkspace) {
syncProvider = googleDriveAppDataProvider;
}
return workspaceProvider.initWorkspace()
.then(workspace => store.commit('workspace/setCurrentWorkspaceId', workspace.id))
return syncProvider.initWorkspace()
.then(workspace => store.dispatch('workspace/setCurrentWorkspaceId', workspace.id))
.then(() => localDbSvc.init())
.then(() => {
// Sync periodically

View File

@ -4,24 +4,26 @@ import defaultProperties from '../data/defaultFileProperties.yml';
const origin = `${location.protocol}//${location.host}`;
// For uid()
// For utils.uid()
const uidLength = 16;
const crypto = window.crypto || window.msCrypto;
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const radix = alphabet.length;
const array = new Uint32Array(uidLength);
// For parseQueryParams()
// For utils.parseQueryParams()
const parseQueryParams = (params) => {
const result = {};
params.split('&').forEach((param) => {
const [key, value] = param.split('=').map(decodeURIComponent);
result[key] = value;
if (key) {
result[key] = value;
}
});
return result;
};
// For addQueryParams()
// For utils.addQueryParams()
const urlParser = window.document.createElement('a');
export default {
@ -121,20 +123,43 @@ export default {
return setInterval(() => func(), this.randomize(interval));
},
parseQueryParams,
addQueryParams(url = '', params = {}) {
addQueryParams(url = '', params = {}, hash = false) {
const keys = Object.keys(params).filter(key => params[key] != null);
urlParser.href = url;
if (!keys.length) {
return urlParser.href;
}
if (urlParser.search) {
urlParser.search += '&';
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 {
urlParser.search = '?';
if (urlParser.search) {
urlParser.search += '&';
} else {
urlParser.search = '?';
}
urlParser.search += serializedParams;
}
urlParser.search += keys.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`).join('&');
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) {
const rangeLength = `${range}`.length;
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 {
namespaced: true,
state: {
@ -118,18 +121,24 @@ export default {
},
},
getters: {
workspaces: (state) => {
workspaces: (state, getters, rootState, rootGetters) => {
const workspaces = (state.lsItemMap.workspaces || empty('workspaces')).data;
const result = {};
const sanitizedWorkspaces = {};
const mainWorkspaceToken = rootGetters['workspace/mainWorkspaceToken'];
Object.entries(workspaces).forEach(([id, workspace]) => {
result[id] = {
...workspace,
const sanitizedWorkspace = {
id,
providerId: workspace.providerId || 'googleDriveWorkspace',
url: utils.addQueryParams('app'),
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);
sanitizedWorkspaces[id] = sanitizedWorkspace;
});
return result;
return sanitizedWorkspaces;
},
settings: getter('settings'),
computedSettings: (state, getters) => {
@ -202,13 +211,6 @@ export default {
githubTokens: (state, getters) => getters.tokens.github || {},
wordpressTokens: (state, getters) => getters.tokens.wordpress || {},
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: {
setWorkspaces: setter('workspaces'),

View File

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

View File

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

View File

@ -75,8 +75,13 @@ export default {
resolveText: 'Yes, revert',
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', {
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',
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 {
namespaced: true,
state: {
currentWorkspaceId: null,
lastFocus: 0,
},
mutations: {
setCurrentWorkspaceId: (state, value) => {
state.currentWorkspaceId = value;
},
setLastFocus: (state, value) => {
state.lastFocus = value;
},
},
getters: {
mainWorkspace: (state, getters, rootState, rootGetters) => {
const workspaces = rootGetters['data/workspaces'];
return workspaces.main;
},
currentWorkspace: (state, getters, rootState, rootGetters) => {
const workspaces = rootGetters['data/workspaces'];
return workspaces[state.currentWorkspaceId] || workspaces.main;
return workspaces[state.currentWorkspaceId] || getters.mainWorkspace;
},
lastSyncActivityKey: (state, getters) => {
const workspaceId = getters.currentWorkspace.id;
return `${workspaceId}/lastSyncActivity`;
lastSyncActivityKey: (state, getters) => `${getters.currentWorkspace.id}/lastSyncActivity`,
lastFocusKey: (state, getters) => `${getters.currentWorkspace.id}/lastWindowFocus`,
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) => {
const workspaceId = getters.currentWorkspace.id;
return `${workspaceId}/lastWindowFocus`;
syncToken: (state, getters, rootState, rootGetters) => {
const workspace = getters.currentWorkspace;
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: {
setCurrentWorkspaceId: ({ commit, getters }, value) => {
commit('setCurrentWorkspaceId', value);
const lastFocus = parseInt(localStorage.getItem(getters.lastFocusKey), 10) || 0;
commit('setLastFocus', lastFocus);
},
},
};