Workspaces (part 3)

This commit is contained in:
Benoit Schweblin 2017-12-23 19:25:14 +01:00
parent abbe1804e2
commit 32aa259790
22 changed files with 608 additions and 188 deletions

View File

@ -1,6 +1,6 @@
<template>
<div class="explorer-node" :class="{'explorer-node--selected': isSelected, 'explorer-node--open': isOpen, 'explorer-node--drag-target': isDragTargetFolder}" @dragover.prevent @dragenter.stop="setDragTarget(node.item.id)" @dragleave.stop="isDragTarget && setDragTargetId()" @drop.prevent.stop="onDrop">
<div class="explorer-node__item-editor" v-if="isEditing" :class="['explorer-node__item-editor--' + node.item.type]" :style="{paddingLeft: leftPadding}">
<div class="explorer-node__item-editor" v-if="isEditing" :class="['explorer-node__item-editor--' + node.item.type]" :style="{paddingLeft: leftPadding}" draggable="true" @dragstart.stop.prevent>
<input type="text" class="text-input" v-focus @blur="submitEdit()" @keyup.enter="submitEdit()" @keyup.esc="submitEdit(true)" v-model="editingNodeName">
</div>
<div class="explorer-node__item" v-else :class="['explorer-node__item--' + node.item.type]" :style="{paddingLeft: leftPadding}" @click="select(node.item.id)" draggable="true" @dragstart.stop="setDragSourceId" @dragend.stop="setDragTargetId()">

View File

@ -15,6 +15,7 @@
<sponsor-modal v-else-if="config.type === 'sponsor'"></sponsor-modal>
<!-- Providers -->
<google-photo-modal v-else-if="config.type === 'googlePhoto'"></google-photo-modal>
<google-drive-account-modal v-else-if="config.type === 'googleDriveAccount'"></google-drive-account-modal>
<google-drive-save-modal v-else-if="config.type === 'googleDriveSave'"></google-drive-save-modal>
<google-drive-workspace-modal v-else-if="config.type === 'googleDriveWorkspace'"></google-drive-workspace-modal>
<google-drive-publish-modal v-else-if="config.type === 'googleDrivePublish'"></google-drive-publish-modal>
@ -62,6 +63,7 @@ import SponsorModal from './modals/SponsorModal';
// Providers
import GooglePhotoModal from './modals/providers/GooglePhotoModal';
import GoogleDriveAccountModal from './modals/providers/GoogleDriveAccountModal';
import GoogleDriveSaveModal from './modals/providers/GoogleDriveSaveModal';
import GoogleDriveWorkspaceModal from './modals/providers/GoogleDriveWorkspaceModal';
import GoogleDrivePublishModal from './modals/providers/GoogleDrivePublishModal';
@ -102,6 +104,7 @@ export default {
SponsorModal,
// Providers
GooglePhotoModal,
GoogleDriveAccountModal,
GoogleDriveSaveModal,
GoogleDriveWorkspaceModal,
GoogleDrivePublishModal,

View File

@ -120,6 +120,12 @@ export default {
width: 100%;
height: 100%;
overflow: auto;
&::after {
content: '';
display: block;
height: 40px;
}
}
.side-bar__panel--hidden {
@ -127,11 +133,11 @@ export default {
}
.side-bar__panel--menu {
padding: 10px 10px 50px;
padding: 10px;
}
.side-bar__panel--help {
padding: 0 10px 40px 20px;
padding: 0 10px 0 20px;
pre {
font-size: 0.9em;

View File

@ -21,7 +21,7 @@
<script>
import { mapMutations } from 'vuex';
import googleDriveAppDataProvider from '../../services/providers/googleDriveAppDataProvider';
import providerRegistry from '../../services/providers/providerRegistry';
import MenuEntry from './common/MenuEntry';
import UserImage from '../UserImage';
import UserName from '../UserName';
@ -83,7 +83,7 @@ export default {
const currentFile = this.$store.getters['file/current'];
this.$store.dispatch('queue/enqueue',
() => Promise.resolve()
.then(() => googleDriveAppDataProvider.getRevisionContent(
.then(() => this.workspaceProvider.getRevisionContent(
loginToken, currentFile.id, revision.id))
.then(resolve, reject));
});
@ -123,6 +123,10 @@ export default {
},
},
created() {
// Find the workspace provider
const workspace = this.$store.getters['workspace/currentWorkspace'];
this.workspaceProvider = providerRegistry.providers[workspace.providerId];
// Watch file changes
this.$watch(
() => this.$store.getters['file/current'].id,
@ -138,7 +142,7 @@ export default {
revisionsPromise = new Promise((resolve, reject) => {
this.$store.dispatch('queue/enqueue',
() => Promise.resolve()
.then(() => googleDriveAppDataProvider.listRevisions(loginToken, currentFile.id))
.then(() => this.workspaceProvider.listRevisions(loginToken, currentFile.id))
.then((revisions) => {
resolve(revisions.sort(
(revision1, revision2) => revision2.created - revision1.created));

View File

@ -158,7 +158,10 @@ export default {
return this.$store.dispatch('modal/open', 'publishManagement');
},
addGoogleDriveAccount() {
return googleHelper.addDriveAccount()
return this.$store.dispatch('modal/open', {
type: 'googleDriveAccount',
onResolve: () => googleHelper.addDriveAccount(!store.getters['data/localSettings'].googleDriveRestrictedAccess),
})
.catch(() => {}); // Cancel
},
addDropboxAccount() {

View File

@ -139,7 +139,10 @@ export default {
return this.$store.dispatch('modal/open', 'syncManagement');
},
addGoogleDriveAccount() {
return googleHelper.addDriveAccount()
return this.$store.dispatch('modal/open', {
type: 'googleDriveAccount',
onResolve: () => googleHelper.addDriveAccount(!store.getters['data/localSettings'].googleDriveRestrictedAccess),
})
.catch(() => {}); // Cancel
},
addDropboxAccount() {

View File

@ -37,7 +37,7 @@ export default {
},
methods: {
addGoogleDriveWorkspace() {
return googleHelper.addDriveAccount()
return googleHelper.addDriveAccount(true)
.then(token => this.$store.dispatch('modal/open', {
type: 'googleDriveWorkspace',
token,

View File

@ -4,7 +4,7 @@
<div class="modal__image">
<icon-provider provider-id="dropbox"></icon-provider>
</div>
<p>This will link your <b>Dropbox</b> account to your <b>StackEdit</b> workspace.</p>
<p>This will link your <b>Dropbox</b> account to <b>StackEdit</b>.</p>
<div class="form-entry">
<div class="form-entry__checkbox">
<label>

View File

@ -4,7 +4,7 @@
<div class="modal__image">
<icon-provider provider-id="github"></icon-provider>
</div>
<p>This will link your <b>GitHub</b> account to your <b>StackEdit</b> workspace.</p>
<p>This will link your <b>GitHub</b> account to <b>StackEdit</b>.</p>
<div class="form-entry">
<div class="form-entry__checkbox">
<label>

View File

@ -0,0 +1,34 @@
<template>
<modal-inner aria-label="Link Google Drive account">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider>
</div>
<p>This will link your <b>Google Drive</b> account to <b>StackEdit</b>.</p>
<div class="form-entry">
<div class="form-entry__checkbox">
<label>
<input type="checkbox" v-model="restrictedAccess"> Restrict access
</label>
<div class="form-entry__info">
If checked, access will be restricted to files that you have opened or created with <b>StackEdit</b>.
</div>
</div>
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="config.resolve()">Ok</button>
</div>
</modal-inner>
</template>
<script>
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
computedLocalSettings: {
restrictedAccess: 'googleDriveRestrictedAccess',
},
});
</script>

View File

@ -4,7 +4,7 @@
<div class="modal__image">
<icon-provider provider-id="zendesk"></icon-provider>
</div>
<p>This will link your <b>Zendesk</b> account to your <b>StackEdit</b> workspace.</p>
<p>This will link your <b>Zendesk</b> account to <b>StackEdit</b>.</p>
<form-entry label="Site URL" error="siteUrl">
<input slot="field" class="textfield" type="text" v-model.trim="siteUrl" @keyup.enter="resolve()">
<div class="form-entry__info">

View File

@ -3,6 +3,7 @@ export default () => ({
htmlExportTemplate: 'styledHtml',
pdfExportTemplate: 'styledHtml',
pandocExportFormat: 'pdf',
googleDriveRestrictedAccess: false,
googleDriveFolderId: '',
googleDriveWorkspaceFolderId: '',
googleDrivePublishFormat: 'markdown',

View File

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

View File

@ -1,5 +1,6 @@
<template>
<div class="icon-provider" :class="'icon-provider--' + classState">
<icon-sync-off v-if="!classState"></icon-sync-off>
</div>
</template>

View File

@ -11,9 +11,10 @@ export default providerRegistry.register({
// Nothing to do since the main workspace isn't necessarily synchronized
return Promise.resolve(store.getters['data/workspaces'].main);
},
getChanges(token) {
getChanges() {
const syncToken = store.getters['workspace/syncToken'];
const startPageToken = store.getters['data/localSettings'].syncStartPageToken;
return googleHelper.getChanges(token, startPageToken, 'appDataFolder')
return googleHelper.getChanges(syncToken, startPageToken, true)
.then((result) => {
const changes = result.changes.filter((change) => {
if (change.file) {
@ -30,7 +31,6 @@ export default providerRegistry.register({
type: change.item.type,
hash: change.item.hash,
};
change.file = undefined;
}
change.syncDataId = change.fileId;
return true;
@ -41,19 +41,18 @@ export default providerRegistry.register({
},
setAppliedChanges(changes) {
store.dispatch('data/patchLocalSettings', {
workspaceSyncStartPageToken: changes.startPageToken,
syncStartPageToken: changes.startPageToken,
});
},
saveItem(token, item, syncData, ifNotTooLate) {
saveSimpleItem(item, syncData, ifNotTooLate) {
const syncToken = store.getters['workspace/syncToken'];
return googleHelper.uploadAppDataFile(
token,
JSON.stringify(item),
['appDataFolder'],
undefined,
undefined,
syncData && syncData.id,
ifNotTooLate,
)
syncToken,
JSON.stringify(item),
undefined,
syncData && syncData.id,
ifNotTooLate,
)
.then(file => ({
// Build sync data
id: file.id,
@ -62,19 +61,20 @@ export default providerRegistry.register({
hash: item.hash,
}));
},
removeItem(token, syncData, ifNotTooLate) {
return googleHelper.removeAppDataFile(token, syncData.id, ifNotTooLate)
.then(() => syncData);
removeItem(syncData, ifNotTooLate) {
const syncToken = store.getters['workspace/syncToken'];
return googleHelper.removeAppDataFile(syncToken, syncData.id, ifNotTooLate);
},
downloadContent(token, syncLocation) {
return this.downloadData(token, `${syncLocation.fileId}/content`);
return this.downloadData(`${syncLocation.fileId}/content`);
},
downloadData(token, dataId) {
downloadData(dataId) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (!syncData) {
return Promise.resolve();
}
return googleHelper.downloadAppDataFile(token, syncData.id)
const syncToken = store.getters['workspace/syncToken'];
return googleHelper.downloadAppDataFile(syncToken, syncData.id)
.then((content) => {
const item = JSON.parse(content);
if (item.hash !== syncData.hash) {
@ -89,27 +89,26 @@ export default providerRegistry.register({
});
},
uploadContent(token, content, syncLocation, ifNotTooLate) {
return this.uploadData(token, content, `${syncLocation.fileId}/content`, ifNotTooLate)
return this.uploadData(content, `${syncLocation.fileId}/content`, ifNotTooLate)
.then(() => syncLocation);
},
uploadData(token, item, dataId, ifNotTooLate) {
uploadData(item, dataId, ifNotTooLate) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (syncData && syncData.hash === item.hash) {
return Promise.resolve();
}
const syncToken = store.getters['workspace/syncToken'];
return googleHelper.uploadAppDataFile(
token,
JSON.stringify({
id: item.id,
type: item.type,
hash: item.hash,
}),
['appDataFolder'],
undefined,
JSON.stringify(item),
syncData && syncData.id,
ifNotTooLate,
)
syncToken,
JSON.stringify({
id: item.id,
type: item.type,
hash: item.hash,
}),
JSON.stringify(item),
syncData && syncData.id,
ifNotTooLate,
)
.then(file => store.dispatch('data/patchSyncData', {
[file.id]: {
// Build sync data
@ -125,7 +124,7 @@ export default providerRegistry.register({
if (!syncData) {
return Promise.reject(); // No need for a proper error message.
}
return googleHelper.getFileRevisions(token, syncData.id)
return googleHelper.getAppDataFileRevisions(token, syncData.id)
.then(revisions => revisions.map(revision => ({
id: revision.id,
sub: revision.lastModifyingUser && revision.lastModifyingUser.permissionId,
@ -137,7 +136,7 @@ export default providerRegistry.register({
if (!syncData) {
return Promise.reject(); // No need for a proper error message.
}
return googleHelper.downloadFileRevision(token, syncData.id, revisionId)
return googleHelper.downloadAppDataFileRevision(token, syncData.id, revisionId)
.then(content => JSON.parse(content));
},
});

View File

@ -1,6 +1,7 @@
import store from '../../store';
import googleHelper from './helpers/googleHelper';
import providerRegistry from './providerRegistry';
import providerUtils from './providerUtils';
import utils from '../utils';
export default providerRegistry.register({
@ -32,7 +33,7 @@ export default providerRegistry.register({
[folder.id],
{ folderId: folder.id },
undefined,
'application/vnd.google-apps.folder',
googleHelper.folderMimeType,
)
.then(dataFolder => ({
...properties,
@ -50,7 +51,7 @@ export default providerRegistry.register({
[folder.id],
{ folderId: folder.id },
undefined,
'application/vnd.google-apps.folder',
googleHelper.folderMimeType,
)
.then(trashFolder => ({
...properties,
@ -71,7 +72,7 @@ export default providerRegistry.register({
undefined,
properties,
undefined,
'application/vnd.google-apps.folder',
googleHelper.folderMimeType,
folder.id,
)
.then(() => properties);
@ -109,12 +110,12 @@ export default providerRegistry.register({
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) {
if (token && token.isDrive && token.driveFullAccess) {
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(true),
});
})
.then(token => Promise.resolve()
@ -125,7 +126,7 @@ export default providerRegistry.register({
[],
undefined,
undefined,
'application/vnd.google-apps.folder',
googleHelper.folderMimeType,
)
.then(folder => initFolder(token, {
...folder,
@ -135,27 +136,109 @@ export default providerRegistry.register({
// If workspace does not exist, initialize one
.then(folderId => getWorkspace(folderId) || googleHelper.getFile(token, folderId)
.then((folder) => {
folder.appProperties = folder.appProperties || {};
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.`);
throw new Error(`Folder ${folderId} is not accessible. Make sure you have the right permissions.`);
})));
},
getChanges(token) {
getChanges() {
const workspace = store.getters['workspace/currentWorkspace'];
const syncToken = store.getters['workspace/syncToken'];
const startPageToken = store.getters['data/localSettings'].syncStartPageToken;
return googleHelper.getChanges(token, startPageToken, 'appDataFolder')
return googleHelper.getChanges(syncToken, startPageToken, false)
.then((result) => {
const changes = result.changes.filter((change) => {
// Collect possible parent IDs
const parentIds = {};
Object.entries(store.getters['data/syncDataByItemId']).forEach(([id, syncData]) => {
parentIds[syncData.id] = id;
});
result.changes.forEach((change) => {
const id = ((change.file || {}).appProperties || {}).id;
if (id) {
parentIds[change.fileId] = id;
}
});
// Collect changes
const changes = [];
result.changes.forEach((change) => {
// Ignore changes on StackEdit own folders
if (change.fileId === workspace.folderId
|| change.fileId === workspace.dataFolderId
|| change.fileId === workspace.trashFolderId
) {
return;
}
let contentChange;
if (change.file) {
// Parse item from file name
try {
change.item = JSON.parse(change.file.name);
} catch (e) {
return false;
// Ignore changes in files that are not in the workspace
const properties = change.file.appProperties;
if (!properties || properties.folderId !== workspace.folderId
) {
return;
}
// If change is on a data item
if (change.file.parents[0] === workspace.dataFolderId) {
// Data item has a JSON as a filename
try {
change.item = JSON.parse(change.file.name);
} catch (e) {
return;
}
} else {
// Change on a file or folder
const type = change.file.mimeType === googleHelper.folderMimeType
? 'folder'
: 'file';
const item = {
id: properties.id,
type,
name: change.file.name,
parentId: null,
};
// Fill parentId
if (change.file.parents.some(parentId => parentId === workspace.trashFolderId)) {
item.parentId = 'trash';
} else {
change.file.parents.some((parentId) => {
if (!parentIds[parentId]) {
return false;
}
item.parentId = parentIds[parentId];
return true;
});
}
change.item = utils.addItemHash(item);
if (type === 'file') {
// create a fake change as a file content change
contentChange = {
item: {
id: `${properties.id}/content`,
type: 'content',
// Need a truthy value to force saving sync data
hash: 1,
},
syncData: {
id: `${change.fileId}/content`,
itemId: `${properties.id}/content`,
type: 'content',
// Need a truthy value to force downloading the content
hash: 1,
},
syncDataId: `${change.fileId}/content`,
};
}
}
// Build sync data
change.syncData = {
id: change.fileId,
@ -163,13 +246,237 @@ export default providerRegistry.register({
type: change.item.type,
hash: change.item.hash,
};
change.file = undefined;
} else {
// Item was removed
const syncData = store.getters['data/syncData'][change.fileId];
if (syncData && syncData.type === 'file') {
// create a fake change as a file content change
contentChange = {
syncDataId: `${change.fileId}/content`,
};
}
}
// Push change
change.syncDataId = change.fileId;
return true;
changes.push(change);
if (contentChange) {
changes.push(contentChange);
}
});
changes.startPageToken = result.startPageToken;
return changes;
});
},
setAppliedChanges(changes) {
store.dispatch('data/patchLocalSettings', {
syncStartPageToken: changes.startPageToken,
});
},
saveSimpleItem(item, syncData, ifNotTooLate) {
return Promise.resolve()
.then(() => {
const workspace = store.getters['workspace/currentWorkspace'];
const syncToken = store.getters['workspace/syncToken'];
if (item.type !== 'file' && item.type !== 'folder') {
return googleHelper.uploadFile(
syncToken,
JSON.stringify(item),
[workspace.dataFolderId],
{
folderId: workspace.folderId,
},
undefined,
undefined,
syncData && syncData.id,
ifNotTooLate,
);
}
// Type `file` or `folder`
const parentSyncData = store.getters['data/syncDataByItemId'][item.parentId];
return googleHelper.uploadFile(
syncToken,
item.name,
[parentSyncData ? parentSyncData.id : workspace.folderId],
{
id: item.id,
folderId: workspace.folderId,
},
undefined,
item.type === 'folder' ? googleHelper.folderMimeType : undefined,
syncData && syncData.id,
ifNotTooLate,
);
})
.then(file => ({
// Build sync data
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
}));
},
removeItem(syncData, ifNotTooLate) {
// Ignore content deletion
if (syncData.type === 'content') {
return Promise.resolve();
}
const syncToken = store.getters['workspace/syncToken'];
return googleHelper.removeFile(syncToken, syncData.id, ifNotTooLate);
},
downloadContent(token, syncLocation) {
const syncData = store.getters['data/syncDataByItemId'][syncLocation.fileId];
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
if (!syncData || !contentSyncData) {
return Promise.resolve();
}
return googleHelper.downloadFile(token, syncData.id)
.then((content) => {
const item = providerUtils.parseContent(content, syncLocation);
if (item.hash !== contentSyncData.hash) {
store.dispatch('data/patchSyncData', {
[contentSyncData.id]: {
...contentSyncData,
hash: item.hash,
},
});
}
return item;
});
},
downloadData(dataId) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (!syncData) {
return Promise.resolve();
}
const syncToken = store.getters['workspace/syncToken'];
return googleHelper.downloadFile(syncToken, 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) {
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
if (contentSyncData && contentSyncData.hash === content.hash) {
return Promise.resolve();
}
return Promise.resolve()
.then(() => {
const syncData = store.getters['data/syncDataByItemId'][syncLocation.fileId];
if (syncData) {
// Only update file media
return googleHelper.uploadFile(
token,
undefined,
undefined,
undefined,
providerUtils.serializeContent(content),
undefined,
syncData.id,
ifNotTooLate,
);
}
// Create file with media
const workspace = store.getters['workspace/currentWorkspace'];
// Use deepCopy to freeze objects
const item = utils.deepCopy(store.state.file.itemMap[syncLocation.fileId]);
const parentSyncData = store.getters['data/syncDataByItemId'][item.parentId];
return googleHelper.uploadFile(
token,
item.name,
[parentSyncData ? parentSyncData.id : workspace.folderId],
{
id: item.id,
folderId: workspace.folderId,
},
providerUtils.serializeContent(content),
undefined,
undefined,
ifNotTooLate,
)
.then((file) => {
store.dispatch('data/patchSyncData', {
[file.id]: {
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
},
});
return file;
});
})
.then(file => store.dispatch('data/patchSyncData', {
[`${file.id}/content`]: {
// Build sync data
id: `${file.id}/content`,
itemId: content.id,
type: content.type,
hash: content.hash,
},
}))
.then(() => syncLocation);
},
uploadData(item, dataId, ifNotTooLate) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (syncData && syncData.hash === item.hash) {
return Promise.resolve();
}
const workspace = store.getters['workspace/currentWorkspace'];
const syncToken = store.getters['workspace/syncToken'];
return googleHelper.uploadFile(
syncToken,
JSON.stringify({
id: item.id,
type: item.type,
hash: item.hash,
}),
[workspace.dataFolderId],
{
folderId: workspace.folderId,
},
JSON.stringify(item),
undefined,
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];
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];
if (!syncData) {
return Promise.reject(); // No need for a proper error message.
}
return googleHelper.downloadFileRevision(token, syncData.id, revisionId)
.then(content => providerUtils.parseContent(content));
},
});

View File

@ -30,6 +30,7 @@ const checkIdToken = (idToken) => {
};
export default {
folderMimeType: 'application/vnd.google-apps.folder',
request(token, options) {
return networkSvc.request({
...options,
@ -120,6 +121,53 @@ export default {
raw: true,
}).then(res => res.body);
},
removeFileInternal(refreshedToken, id, ifNotTooLate = cb => res => cb(res)) {
return Promise.resolve()
// Refreshing a token can take a while if an oauth window pops up, so check if it's too late
.then(ifNotTooLate(() => this.request(refreshedToken, {
method: 'DELETE',
url: `https://www.googleapis.com/drive/v3/files/${id}`,
})));
},
getFileRevisionsInternal(refreshedToken, id) {
return Promise.resolve()
.then(() => {
const revisions = [];
const getPage = pageToken => this.request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${id}/revisions`,
params: {
pageToken,
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,
});
revisions.push(revision);
});
if (res.body.nextPageToken) {
return getPage(res.body.nextPageToken);
}
return revisions;
});
return getPage();
});
},
downloadFileRevisionInternal(refreshedToken, fileId, revisionId) {
return Promise.resolve()
.then(() => this.request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${fileId}/revisions/${revisionId}?alt=media`,
raw: true,
}).then(res => res.body));
},
getUser(userId) {
return networkSvc.request({
method: 'GET',
@ -168,7 +216,7 @@ export default {
expiresOn: Date.now() + (data.expiresIn * 1000),
idToken: data.idToken,
sub: `${res.body.sub}`,
isLogin: !store.getters['workspace/loginToken'] &&
isLogin: !store.getters['workspace/mainWorkspaceToken'] &&
scopes.indexOf('https://www.googleapis.com/auth/drive.appdata') !== -1,
isSponsor: false,
isDrive: scopes.indexOf('https://www.googleapis.com/auth/drive') !== -1 ||
@ -297,20 +345,24 @@ export default {
addPhotosAccount() {
return this.startOauth2(photosScopes);
},
getChanges(token, startPageToken, spaces) {
getChanges(token, startPageToken, isAppData) {
const result = {
changes: [],
};
return this.refreshToken(token, driveAppDataScopes)
let fileFields = 'file/name';
if (!isAppData) {
fileFields += ',file/parents,file/mimeType,file/appProperties';
}
return this.refreshToken(token, isAppData ? driveAppDataScopes : getDriveScopes(token))
.then((refreshedToken) => {
const getPage = (pageToken = '1') => this.request(refreshedToken, {
method: 'GET',
url: 'https://www.googleapis.com/drive/v3/changes',
params: {
pageToken,
spaces,
spaces: isAppData ? 'appDataFolder' : 'drive',
pageSize: 1000,
fields: 'nextPageToken,newStartPageToken,changes(fileId,removed,file/name,file/appProperties)',
fields: `nextPageToken,newStartPageToken,changes(fileId,${fileFields})`,
},
})
.then((res) => {
@ -328,12 +380,28 @@ export default {
uploadFile(token, name, parents, appProperties, media, mediaType, fileId, ifNotTooLate) {
return this.refreshToken(token, getDriveScopes(token))
.then(refreshedToken => this.uploadFileInternal(
refreshedToken, name, parents, appProperties, media, mediaType, fileId, ifNotTooLate));
refreshedToken,
name,
parents,
appProperties,
media,
mediaType,
fileId,
ifNotTooLate,
));
},
uploadAppDataFile(token, name, parents, appProperties, media, fileId, ifNotTooLate) {
uploadAppDataFile(token, name, media, fileId, ifNotTooLate) {
return this.refreshToken(token, driveAppDataScopes)
.then(refreshedToken => this.uploadFileInternal(
refreshedToken, name, parents, appProperties, media, undefined, fileId, ifNotTooLate));
refreshedToken,
name,
['appDataFolder'],
undefined,
media,
undefined,
fileId,
ifNotTooLate,
));
},
getFile(token, id) {
return this.refreshToken(token, getDriveScopes(token))
@ -354,52 +422,31 @@ export default {
return this.refreshToken(token, driveAppDataScopes)
.then(refreshedToken => this.downloadFileInternal(refreshedToken, id));
},
removeFile(token, id, ifNotTooLate) {
return this.refreshToken(token, getDriveScopes(token))
.then(refreshedToken => this.removeFileInternal(refreshedToken, id, ifNotTooLate));
},
removeAppDataFile(token, id, ifNotTooLate = cb => res => cb(res)) {
return this.refreshToken(token, driveAppDataScopes)
// Refreshing a token can take a while if an oauth window pops up, so check if it's too late
.then(ifNotTooLate(refreshedToken => this.request(refreshedToken, {
method: 'DELETE',
url: `https://www.googleapis.com/drive/v3/files/${id}`,
})));
.then(refreshedToken => this.removeFileInternal(refreshedToken, id, ifNotTooLate));
},
getFileRevisions(token, id) {
return this.refreshToken(token, getDriveScopes(token))
.then(refreshedToken => this.getFileRevisionsInternal(refreshedToken, id));
},
getAppDataFileRevisions(token, id) {
return this.refreshToken(token, driveAppDataScopes)
.then((refreshedToken) => {
const revisions = [];
const getPage = pageToken => this.request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${id}/revisions`,
params: {
pageToken,
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,
});
revisions.push(revision);
});
if (res.body.nextPageToken) {
return getPage(res.body.nextPageToken);
}
return revisions;
});
return getPage();
});
.then(refreshedToken => this.getFileRevisionsInternal(refreshedToken, id));
},
downloadFileRevision(token, fileId, revisionId) {
return this.refreshToken(token, getDriveScopes(token))
.then(refreshedToken => this.downloadFileRevisionInternal(
refreshedToken, fileId, revisionId));
},
downloadAppDataFileRevision(token, fileId, revisionId) {
return this.refreshToken(token, driveAppDataScopes)
.then(refreshedToken => this.request(refreshedToken, {
method: 'GET',
url: `https://www.googleapis.com/drive/v3/files/${fileId}/revisions/${revisionId}?alt=media`,
raw: true,
}).then(res => res.body));
.then(refreshedToken => this.downloadFileRevisionInternal(
refreshedToken, fileId, revisionId));
},
uploadBlogger(
token, blogUrl, blogId, postId, title, content, labels, isDraft, published, isPage,
@ -475,6 +522,7 @@ export default {
let picker;
const pickerBuilder = new google.picker.PickerBuilder()
.setOAuthToken(refreshedToken.accessToken)
.hideTitleBar()
.setCallback((data) => {
switch (data[google.picker.Response.ACTION]) {
case google.picker.Action.PICKED:
@ -488,27 +536,35 @@ export default {
switch (type) {
default:
case 'doc': {
const view = new google.picker.DocsView(google.picker.ViewId.DOCS);
view.setParent('root');
view.setIncludeFolders(true);
view.setMimeTypes([
'text/plain',
'text/x-markdown',
'application/octet-stream',
].join(','));
pickerBuilder.enableFeature(google.picker.Feature.NAV_HIDDEN);
const addView = (hasRootParent) => {
const view = new google.picker.DocsView(google.picker.ViewId.DOCS);
if (hasRootParent) {
view.setParent('root');
}
view.setMimeTypes([
'text/plain',
'text/x-markdown',
'application/octet-stream',
].join(','));
pickerBuilder.addView(view);
};
pickerBuilder.enableFeature(google.picker.Feature.MULTISELECT_ENABLED);
pickerBuilder.addView(view);
addView(false);
// addView(true);
break;
}
case 'folder': {
const view = new google.picker.DocsView(google.picker.ViewId.FOLDERS);
view.setParent('root');
view.setIncludeFolders(true);
view.setSelectFolderEnabled(true);
view.setMimeTypes('application/vnd.google-apps.folder');
pickerBuilder.enableFeature(google.picker.Feature.NAV_HIDDEN);
pickerBuilder.addView(view);
const addView = (hasRootParent) => {
const view = new google.picker.DocsView(google.picker.ViewId.FOLDERS);
if (hasRootParent) {
view.setParent('root');
}
view.setSelectFolderEnabled(true);
view.setMimeTypes(this.folderMimeType);
pickerBuilder.addView(view);
};
addView(false);
// addView(true);
break;
}
case 'img': {

View File

@ -51,11 +51,7 @@ export default {
// Ignore
}
}
result.hash = utils.hash(utils.serializeObject({
...result,
hash: undefined,
}));
return result;
return utils.addItemHash(result);
},
/**
* Find and open a file location that fits the criteria

View File

@ -92,35 +92,39 @@ function cleanSyncedContent(syncedContent) {
function applyChanges(changes) {
const storeItemMap = { ...store.getters.allItemMap };
const syncData = { ...store.getters['data/syncData'] };
let syncDataChanged = false;
let saveSyncData = false;
changes.forEach((change) => {
const existingSyncData = syncData[change.syncDataId];
const existingItem = existingSyncData && storeItemMap[existingSyncData.itemId];
if (change.removed && existingSyncData) {
if (!change.item && existingSyncData) {
// Item was removed
delete syncData[change.syncDataId];
saveSyncData = true;
if (existingItem) {
// Remove object from the store
store.commit(`${existingItem.type}/deleteItem`, existingItem.id);
delete storeItemMap[existingItem.id];
}
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, 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;
}
}
} else if (change.item && change.item.hash) {
// Item was modifed
syncData[change.syncDataId] = change.syncData;
syncDataChanged = true;
saveSyncData = true;
if (
// If no sync data or existing one is different
(existingSyncData || {}).hash !== change.item.hash
// And no existing item or existing item is different
&& (existingItem || {}).hash !== change.item.hash
// And item is not content nor data, which will be merged later
&& change.item.type !== 'content' && change.item.type !== 'data'
) {
store.commit(`${change.item.type}/setItem`, change.item);
storeItemMap[change.item.id] = change.item;
}
}
});
if (syncDataChanged) {
if (saveSyncData) {
store.dispatch('data/setSyncData', syncData);
}
}
@ -405,8 +409,7 @@ function syncDataItem(dataId) {
return null;
}
const syncToken = store.getters['workspace/syncToken'];
return syncToken && syncProvider.downloadData(syncToken, dataId)
return syncProvider.downloadData(dataId)
.then((serverItem = null) => {
const dataSyncData = store.getters['data/dataSyncData'][dataId];
let mergedItem = (() => {
@ -452,11 +455,7 @@ function syncDataItem(dataId) {
if (serverItem && serverItem.hash === mergedItem.hash) {
return null;
}
return syncProvider.uploadData(
syncToken,
mergedItem,
dataId,
);
return syncProvider.uploadData(mergedItem, dataId);
})
.then(() => {
store.dispatch('data/patchDataSyncData', {
@ -469,22 +468,24 @@ function syncDataItem(dataId) {
/**
* Sync the whole workspace with the main provider and the current file explicit locations.
*/
function syncWorkspace(workspace, syncToken) {
function syncWorkspace() {
const workspace = store.getters['workspace/currentWorkspace'];
const syncContext = new SyncContext();
return Promise.resolve()
.then(() => {
// Store the sub in the DB since it's not safely stored in the token
const syncToken = store.getters['workspace/syncToken'];
const localSettings = store.getters['data/localSettings'];
if (!localSettings.syncSub) {
store.dispatch('data/patchLocalSettings', {
workspaceSyncSub: syncToken.sub,
syncSub: syncToken.sub,
});
} else if (localSettings.syncSub !== syncToken.sub) {
throw new Error('Synchronization failed due to token inconsistency.');
}
})
.then(() => syncProvider.getChanges(syncToken))
.then(() => syncProvider.getChanges())
.then((changes) => {
// Apply changes
applyChanges(changes);
@ -509,15 +510,16 @@ function syncWorkspace(workspace, syncToken) {
// Deal with contents and data later
};
const syncDataByItemId = store.getters['data/syncDataByItemId'];
let result;
let promise;
Object.entries(storeItemMap).some(([id, item]) => {
const existingSyncData = syncDataByItemId[id];
if ((!existingSyncData || existingSyncData.hash !== item.hash) &&
// Add file if content has been uploaded
(item.type !== 'file' || syncDataByItemId[`${id}/content`])
if ((!existingSyncData || existingSyncData.hash !== item.hash)
// Add file/folder if parent has been added
&& (!storeItemMap[item.parentId] || syncDataByItemId[item.parentId])
// Add file if content has been added
&& (item.type !== 'file' || syncDataByItemId[`${id}/content`])
) {
result = syncProvider.saveItem(
syncToken,
promise = syncProvider.saveSimpleItem(
// Use deepCopy to freeze objects
utils.deepCopy(item),
utils.deepCopy(existingSyncData),
@ -528,9 +530,9 @@ function syncWorkspace(workspace, syncToken) {
}))
.then(() => saveNextItem());
}
return result;
return promise;
});
return result;
return promise;
});
// Called until no item to remove
@ -543,7 +545,7 @@ function syncWorkspace(workspace, syncToken) {
...store.state.content.itemMap,
};
const syncData = store.getters['data/syncData'];
let result;
let promise;
Object.entries(syncData).some(([, existingSyncData]) => {
if (!storeItemMap[existingSyncData.itemId] &&
// We don't want to delete data items, especially on first sync
@ -553,8 +555,8 @@ function syncWorkspace(workspace, syncToken) {
) {
// Use deepCopy to freeze objects
const syncDataToRemove = utils.deepCopy(existingSyncData);
result = syncProvider
.removeItem(syncToken, syncDataToRemove, ifNotTooLate)
promise = syncProvider
.removeItem(syncDataToRemove, ifNotTooLate)
.then(() => {
const syncDataCopy = { ...store.getters['data/syncData'] };
delete syncDataCopy[syncDataToRemove.id];
@ -562,9 +564,9 @@ function syncWorkspace(workspace, syncToken) {
})
.then(() => removeNextItem());
}
return result;
return promise;
});
return result;
return promise;
});
const getOneFileIdToSync = () => {
@ -621,14 +623,14 @@ function syncWorkspace(workspace, syncToken) {
() => {
if (syncContext.restart) {
// Restart sync
return syncWorkspace(workspace, syncToken);
return syncWorkspace();
}
return null;
},
(err) => {
if (err && err.message === 'TOO_LATE') {
// Restart sync
return syncWorkspace(workspace, syncToken);
return syncWorkspace();
}
throw err;
});
@ -676,9 +678,7 @@ function requestSync() {
Promise.resolve()
.then(() => {
if (isWorkspaceSyncPossible()) {
return syncWorkspace(
store.getters['workspace/currentWorkspace'],
store.getters['workspace/syncToken']);
return syncWorkspace();
}
if (hasCurrentFileSyncLocations()) {
// Only sync current file if workspace sync is unavailable.

View File

@ -85,6 +85,17 @@ export default {
}
return hash;
},
addItemHash(item) {
return {
...item,
hash: this.hash(this.serializeObject({
...item,
// These properties must not be part of the hash
history: undefined,
hash: undefined,
})),
};
},
encodeBase64(str) {
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
(match, p1) => String.fromCharCode(`0x${p1}`)));

View File

@ -105,16 +105,12 @@ export default {
const data = typeof value.data === 'object'
? Object.assign(emptyItem.data, value.data)
: value.data;
const item = {
// Make item with hash
const item = utils.addItemHash({
...emptyItem,
data,
};
// Calculate item hash
item.hash = utils.hash(utils.serializeObject({
...item,
hash: undefined,
}));
});
// Store item in itemMap or lsItemMap if its stored in the localStorage
Vue.set(lsItemIdSet.has(item.id) ? state.lsItemMap : state.itemMap, item.id, item);

View File

@ -76,8 +76,8 @@ export default {
rejectText: 'No',
}),
removeWorkspace: ({ dispatch }) => dispatch('open', {
content: '<p>This will clean your workspace locally. Are you sure?</p>',
resolveText: 'Yes, clean',
content: '<p>You are about to remove a workspace locally. Are you sure?</p>',
resolveText: 'Yes, remove',
rejectText: 'No',
}),
reset: ({ dispatch }) => dispatch('open', {
@ -100,7 +100,7 @@ export default {
signInForSponsorship: ({ dispatch }, { onResolve }) => dispatch('open', {
type: 'signInForSponsorship',
content: `<p>You have to sign in with Google to enable your sponsorship.</p>
<div class="modal__info"><b>Note:</b> This will sync all your files and settings.</div>`,
<div class="modal__info"><b>Note:</b> This will sync your main workspace.</div>`,
resolveText: 'Ok, sign in',
rejectText: 'Cancel',
onResolve,