CouchDB workspace (part 2)

This commit is contained in:
benweet 2018-01-30 07:36:33 +00:00
parent aac305e410
commit 2374f459df
19 changed files with 315 additions and 59 deletions

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="history side-bar__panel"> <div class="history side-bar__panel">
<div class="revision" v-for="revision in revisions" :key="revision.id"> <div class="revision" v-for="revision in revisionsWithSpacer" :key="revision.id">
<div class="history__spacer" v-if="revision.spacer"></div> <div class="history__spacer" v-if="revision.spacer"></div>
<a class="revision__button button flex flex--row" href="javascript:void(0)" @click="open(revision)"> <a class="revision__button button flex flex--row" href="javascript:void(0)" @click="open(revision)">
<div class="revision__icon"> <div class="revision__icon">
@ -36,7 +36,7 @@ let previewClassAppliers = [];
let cachedFileId; let cachedFileId;
let revisionsPromise; let revisionsPromise;
let revisionContentPromises; let revisionContentPromises;
const pageSize = 50; const pageSize = 30;
const spacerThreshold = 12 * 60 * 60 * 1000; // 12h const spacerThreshold = 12 * 60 * 60 * 1000; // 12h
export default { export default {
@ -51,8 +51,11 @@ export default {
}), }),
computed: { computed: {
revisions() { revisions() {
return this.allRevisions.slice(0, this.showCount);
},
revisionsWithSpacer() {
let previousCreated = 0; let previousCreated = 0;
return this.allRevisions.slice(0, this.showCount).map((revision) => { return this.revisions.map((revision) => {
const revisionWithSpacer = { const revisionWithSpacer = {
...revision, ...revision,
spacer: revision.created + spacerThreshold < previousCreated, spacer: revision.created + spacerThreshold < previousCreated,
@ -79,12 +82,12 @@ export default {
let revisionContentPromise = revisionContentPromises[revision.id]; let revisionContentPromise = revisionContentPromises[revision.id];
if (!revisionContentPromise) { if (!revisionContentPromise) {
revisionContentPromise = new Promise((resolve, reject) => { revisionContentPromise = new Promise((resolve, reject) => {
const loginToken = this.$store.getters['workspace/loginToken']; const syncToken = this.$store.getters['workspace/syncToken'];
const currentFile = this.$store.getters['file/current']; const currentFile = this.$store.getters['file/current'];
this.$store.dispatch('queue/enqueue', this.$store.dispatch('queue/enqueue',
() => Promise.resolve() () => Promise.resolve()
.then(() => this.workspaceProvider.getRevisionContent( .then(() => this.workspaceProvider.getRevisionContent(
loginToken, currentFile.id, revision.id)) syncToken, currentFile.id, revision.id))
.then(resolve, reject)); .then(resolve, reject));
}); });
revisionContentPromises[revision.id] = revisionContentPromise; revisionContentPromises[revision.id] = revisionContentPromise;
@ -137,17 +140,13 @@ export default {
this.setRevisionContent(); this.setRevisionContent();
cachedFileId = id; cachedFileId = id;
revisionContentPromises = {}; revisionContentPromises = {};
const loginToken = this.$store.getters['workspace/loginToken']; const syncToken = this.$store.getters['workspace/syncToken'];
const currentFile = this.$store.getters['file/current']; const currentFile = this.$store.getters['file/current'];
revisionsPromise = new Promise((resolve, reject) => { revisionsPromise = new Promise((resolve, reject) => {
this.$store.dispatch('queue/enqueue', this.$store.dispatch('queue/enqueue',
() => Promise.resolve() () => Promise.resolve()
.then(() => this.workspaceProvider.listRevisions(loginToken, currentFile.id)) .then(() => this.workspaceProvider.listRevisions(syncToken, currentFile.id))
.then((revisions) => { .then(resolve, reject));
resolve(revisions.sort(
(revision1, revision2) => revision2.created - revision1.created));
})
.catch(reject));
}); });
revisionsPromise.catch(() => { revisionsPromise.catch(() => {
cachedFileId = null; cachedFileId = null;
@ -160,6 +159,28 @@ export default {
} }
}, { immediate: true }); }, { immediate: true });
const loadOne = () => {
this.$store.dispatch('queue/enqueue',
() => {
let loadPromise;
this.revisions.some((revision) => {
if (!revision.created) {
const syncToken = this.$store.getters['workspace/syncToken'];
const currentFile = this.$store.getters['file/current'];
loadPromise = this.workspaceProvider.loadRevision(syncToken, currentFile.id, revision)
.then(() => loadOne());
}
return loadPromise;
});
return loadPromise;
});
};
this.$watch(
() => this.revisions,
() => loadOne(),
{ immediate: true });
// Watch diffs changes // Watch diffs changes
this.$watch( this.$watch(
() => this.$store.state.content.revisionContent, () => this.$store.state.content.revisionContent,
@ -181,6 +202,8 @@ export default {
this.refreshHighlighters(); this.refreshHighlighters();
// Remove event listener // Remove event listener
window.removeEventListener('keyup', this.onKeyup); window.removeEventListener('keyup', this.onKeyup);
// Cancel loading revisions
this.showCount = 0;
}, },
}; };
</script> </script>

View File

@ -140,7 +140,7 @@ export default {
.catch(() => {}); // Cancel .catch(() => {}); // Cancel
}, },
history() { history() {
if (!this.loginToken) { if (!this.syncToken) {
this.$store.dispatch('modal/signInForHistory', { this.$store.dispatch('modal/signInForHistory', {
onResolve: () => googleHelper.signin() onResolve: () => googleHelper.signin()
.then(() => syncSvc.requestSync()), .then(() => syncSvc.requestSync()),

View File

@ -1,5 +1,5 @@
export default () => ({ export default (id = null) => ({
id: null, id,
type: 'content', type: 'content',
text: '\n', text: '\n',
properties: '\n', properties: '\n',

View File

@ -1,5 +1,5 @@
export default () => ({ export default (id = null) => ({
id: null, id,
type: 'contentState', type: 'contentState',
selectionStart: 0, selectionStart: 0,
selectionEnd: 0, selectionEnd: 0,

View File

@ -1,5 +1,5 @@
export default () => ({ export default (id = null) => ({
id: null, id,
type: 'file', type: 'file',
name: '', name: '',
parentId: null, parentId: null,

View File

@ -1,5 +1,5 @@
export default () => ({ export default (id = null) => ({
id: null, id,
type: 'folder', type: 'folder',
name: '', name: '',
parentId: null, parentId: null,

View File

@ -1,5 +1,5 @@
export default () => ({ export default (id = null) => ({
id: null, id,
type: 'publishLocation', type: 'publishLocation',
providerId: null, providerId: null,
fileId: null, fileId: null,

View File

@ -1,5 +1,5 @@
export default () => ({ export default (id = null) => ({
id: null, id,
type: 'syncLocation', type: 'syncLocation',
providerId: null, providerId: null,
fileId: null, fileId: null,

View File

@ -1,5 +1,5 @@
export default () => ({ export default (id = null) => ({
id: null, id,
type: 'syncedContent', type: 'syncedContent',
historyData: {}, historyData: {},
syncHistory: {}, syncHistory: {},

View File

@ -1,6 +1,7 @@
import store from '../../store'; import store from '../../store';
import couchdbHelper from './helpers/couchdbHelper'; import couchdbHelper from './helpers/couchdbHelper';
import providerRegistry from './providerRegistry'; import providerRegistry from './providerRegistry';
import providerUtils from './providerUtils';
import utils from '../utils'; import utils from '../utils';
export default providerRegistry.register({ export default providerRegistry.register({
@ -56,32 +57,169 @@ export default providerRegistry.register({
}); });
}, },
getChanges() { getChanges() {
const workspace = store.getters['workspace/currentWorkspace'];
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];
const lastSeq = store.getters['data/localSettings'].syncLastSeq; const lastSeq = store.getters['data/localSettings'].syncLastSeq;
return couchdbHelper.getChanges(syncToken, lastSeq, true) return couchdbHelper.getChanges(syncToken, lastSeq)
.then((result) => { .then((result) => {
const changes = result.changes.filter((change) => { const changes = result.changes.filter((change) => {
if (change.file) { if (!change.deleted && change.doc) {
// Parse item from file name change.item = change.doc.item;
try { if (!change.item || !change.item.id || !change.item.type) {
change.item = JSON.parse(change.file.name);
} catch (e) {
return false; return false;
} }
// Build sync data // Build sync data
change.syncData = { change.syncData = {
id: change.fileId, id: change.id,
itemId: change.item.id, itemId: change.item.id,
type: change.item.type, type: change.item.type,
hash: change.item.hash, hash: change.item.hash,
rev: change.doc._rev, // eslint-disable-line no-underscore-dangle
}; };
} }
change.syncDataId = change.fileId; change.syncDataId = change.id;
return true; return true;
}); });
changes.startPageToken = result.startPageToken; changes.lastSeq = result.lastSeq;
return changes; return changes;
}); });
}, },
setAppliedChanges(changes) {
store.dispatch('data/patchLocalSettings', {
syncLastSeq: changes.lastSeq,
});
},
saveSimpleItem(item, syncData) {
const syncToken = store.getters['workspace/syncToken'];
return couchdbHelper.uploadDocument(
syncToken,
item,
undefined,
undefined,
syncData && syncData.id,
syncData && syncData.rev,
)
.then(res => ({
// Build sync data
id: res.id,
itemId: item.id,
type: item.type,
hash: item.hash,
rev: res.rev,
}));
},
removeItem(syncData) {
const syncToken = store.getters['workspace/syncToken'];
return couchdbHelper.removeDocument(syncToken, syncData.id, syncData.rev);
},
downloadContent(token, syncLocation) {
return this.downloadData(`${syncLocation.fileId}/content`);
},
downloadData(dataId) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (!syncData) {
return Promise.resolve();
}
const syncToken = store.getters['workspace/syncToken'];
return couchdbHelper.retrieveDocumentWithAttachments(syncToken, syncData.id)
.then((body) => {
let item;
if (body.item.type === 'content') {
item = providerUtils.parseContent(body.attachments.data, body.item.id);
} else {
item = JSON.parse(body.attachments.data);
}
const rev = body._rev; // eslint-disable-line no-underscore-dangle
if (item.hash !== syncData.hash || rev !== syncData.rev) {
store.dispatch('data/patchSyncData', {
[syncData.id]: {
...syncData,
hash: item.hash,
rev,
},
});
}
return item;
});
},
uploadContent(token, content, syncLocation) {
return this.uploadData(content, `${syncLocation.fileId}/content`)
.then(() => syncLocation);
},
uploadData(item, dataId) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (syncData && syncData.hash === item.hash) {
return Promise.resolve();
}
let data;
let dataType;
if (item.type === 'content') {
data = providerUtils.serializeContent(item);
dataType = 'text/plain';
} else {
data = JSON.stringify(item);
dataType = 'application/json';
}
const syncToken = store.getters['workspace/syncToken'];
return couchdbHelper.uploadDocument(
syncToken,
{
id: item.id,
type: item.type,
hash: item.hash,
},
data,
dataType,
syncData && syncData.id,
syncData && syncData.rev,
)
.then(res => store.dispatch('data/patchSyncData', {
[res.id]: {
// Build sync data
id: res.id,
itemId: item.id,
type: item.type,
hash: item.hash,
rev: res.rev,
},
}));
},
listRevisions(token, fileId) {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
if (!syncData) {
return Promise.reject(); // No need for a proper error message.
}
return couchdbHelper.retrieveDocumentWithRevisions(token, syncData.id)
.then((body) => {
const revisions = [];
body._revs_info.forEach((revInfo) => { // eslint-disable-line no-underscore-dangle
if (revInfo.status === 'available') {
revisions.push({
id: revInfo.rev,
sub: null,
created: null,
});
}
});
return revisions;
});
},
loadRevision(token, fileId, revision) {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
if (!syncData) {
return Promise.reject(); // No need for a proper error message.
}
return couchdbHelper.retrieveDocument(token, syncData.id, revision.id)
.then((body) => {
revision.sub = body.sub;
revision.created = body.created;
});
},
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 couchdbHelper.retrieveDocumentWithAttachments(token, syncData.id, revisionId)
.then(body => providerUtils.parseContent(body.attachments.data, body.item.id));
},
}); });

View File

@ -40,7 +40,7 @@ export default providerRegistry.register({
makePathRelative(token, syncLocation.path), makePathRelative(token, syncLocation.path),
syncLocation.dropboxFileId, syncLocation.dropboxFileId,
) )
.then(({ content }) => providerUtils.parseContent(content, syncLocation)); .then(({ content }) => providerUtils.parseContent(content, `${syncLocation.fileId}/content`));
}, },
uploadContent(token, content, syncLocation) { uploadContent(token, content, syncLocation) {
return dropboxHelper.uploadFile( return dropboxHelper.uploadFile(

View File

@ -18,7 +18,7 @@ export default providerRegistry.register({
}, },
downloadContent(token, syncLocation) { downloadContent(token, syncLocation) {
return githubHelper.downloadGist(token, syncLocation.gistId, syncLocation.filename) return githubHelper.downloadGist(token, syncLocation.gistId, syncLocation.filename)
.then(content => providerUtils.parseContent(content, syncLocation)); .then(content => providerUtils.parseContent(content, `${syncLocation.fileId}/content`));
}, },
uploadContent(token, content, syncLocation) { uploadContent(token, content, syncLocation) {
const file = store.state.file.itemMap[syncLocation.fileId]; const file = store.state.file.itemMap[syncLocation.fileId];

View File

@ -24,7 +24,7 @@ export default providerRegistry.register({
) )
.then(({ sha, content }) => { .then(({ sha, content }) => {
savedSha[syncLocation.id] = sha; savedSha[syncLocation.id] = sha;
return providerUtils.parseContent(content, syncLocation); return providerUtils.parseContent(content, `${syncLocation.fileId}/content`);
}) })
.catch(() => null); // Ignore error, without the sha upload is going to fail anyway .catch(() => null); // Ignore error, without the sha upload is going to fail anyway
}, },

View File

@ -82,8 +82,8 @@ export default providerRegistry.register({
} }
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];
return googleHelper.downloadAppDataFile(syncToken, syncData.id) return googleHelper.downloadAppDataFile(syncToken, syncData.id)
.then((content) => { .then((data) => {
const item = JSON.parse(content); const item = JSON.parse(data);
if (item.hash !== syncData.hash) { if (item.hash !== syncData.hash) {
store.dispatch('data/patchSyncData', { store.dispatch('data/patchSyncData', {
[syncData.id]: { [syncData.id]: {
@ -136,7 +136,8 @@ export default providerRegistry.register({
id: revision.id, id: revision.id,
sub: revision.lastModifyingUser && revision.lastModifyingUser.permissionId, sub: revision.lastModifyingUser && revision.lastModifyingUser.permissionId,
created: new Date(revision.modifiedTime).getTime(), created: new Date(revision.modifiedTime).getTime(),
}))); }))
.sort((revision1, revision2) => revision2.created - revision1.created));
}, },
getRevisionContent(token, fileId, revisionId) { getRevisionContent(token, fileId, revisionId) {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`]; const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];

View File

@ -110,7 +110,7 @@ export default providerRegistry.register({
}, },
downloadContent(token, syncLocation) { downloadContent(token, syncLocation) {
return googleHelper.downloadFile(token, syncLocation.driveFileId) return googleHelper.downloadFile(token, syncLocation.driveFileId)
.then(content => providerUtils.parseContent(content, syncLocation)); .then(content => providerUtils.parseContent(content, `${syncLocation.fileId}/content`));
}, },
uploadContent(token, content, syncLocation, ifNotTooLate) { uploadContent(token, content, syncLocation, ifNotTooLate) {
const file = store.state.file.itemMap[syncLocation.fileId]; const file = store.state.file.itemMap[syncLocation.fileId];

View File

@ -398,7 +398,7 @@ export default providerRegistry.register({
} }
return googleHelper.downloadFile(token, syncData.id) return googleHelper.downloadFile(token, syncData.id)
.then((content) => { .then((content) => {
const item = providerUtils.parseContent(content, syncLocation); const item = providerUtils.parseContent(content, `${syncLocation.fileId}/content`);
if (item.hash !== contentSyncData.hash) { if (item.hash !== contentSyncData.hash) {
store.dispatch('data/patchSyncData', { store.dispatch('data/patchSyncData', {
[contentSyncData.id]: { [contentSyncData.id]: {
@ -543,7 +543,8 @@ export default providerRegistry.register({
id: revision.id, id: revision.id,
sub: revision.lastModifyingUser && revision.lastModifyingUser.permissionId, sub: revision.lastModifyingUser && revision.lastModifyingUser.permissionId,
created: new Date(revision.modifiedTime).getTime(), created: new Date(revision.modifiedTime).getTime(),
}))); }))
.sort((revision1, revision2) => revision2.created - revision1.created));
}, },
getRevisionContent(token, fileId, revisionId) { getRevisionContent(token, fileId, revisionId) {
const syncData = store.getters['data/syncDataByItemId'][fileId]; const syncData = store.getters['data/syncDataByItemId'][fileId];
@ -551,6 +552,6 @@ export default providerRegistry.register({
return Promise.reject(); // No need for a proper error message. return Promise.reject(); // No need for a proper error message.
} }
return googleHelper.downloadFileRevision(token, syncData.id, revisionId) return googleHelper.downloadFileRevision(token, syncData.id, revisionId)
.then(content => providerUtils.parseContent(content)); .then(content => providerUtils.parseContent(content, `${fileId}/content`));
}, },
}); });

View File

@ -30,21 +30,115 @@ const request = (token, options = {}) => {
const config = { const config = {
...options, ...options,
headers: {
Accept: 'application/json',
...options.headers || {},
},
url: utils.resolveUrl(baseUrl, options.path || '.'), url: utils.resolveUrl(baseUrl, options.path || '.'),
withCredentials: true, withCredentials: true,
}; };
return networkSvc.request(config) return networkSvc.request(config)
.catch(ifUnauthorized(() => onUnauthorized() .catch(ifUnauthorized(() => onUnauthorized()
.then(() => networkSvc.request(config)))); .then(() => networkSvc.request(config))))
.then(res => res.body)
.catch((err) => {
if (err.status === 409) {
throw new Error('TOO_LATE');
}
throw err;
});
}; };
export default { export default {
getDb(token) { getDb(token) {
return request(token) return request(token);
.then(res => res.body);
}, },
getChanges() { getChanges(token, lastSeq) {
const result = {
changes: [],
};
const getPage = (since = 0) => request(token, {
method: 'GET',
path: '_changes',
params: {
since,
include_docs: true,
limit: 1000,
},
})
.then((body) => {
result.changes = result.changes.concat(body.results);
if (body.pending) {
return getPage(body.last_seq);
}
result.lastSeq = body.last_seq;
return result;
});
return getPage(lastSeq);
},
uploadDocument(
token,
item,
data = null,
dataType = null,
documentId = null,
rev = null,
) {
const options = {
method: 'POST',
body: { item },
};
if (documentId) {
options.method = 'PUT';
options.path = documentId;
options.body._rev = rev; // eslint-disable-line no-underscore-dangle
}
if (data) {
options.body._attachments = { // eslint-disable-line no-underscore-dangle
data: {
content_type: dataType,
data: utils.encodeBase64(data),
},
};
}
return request(token, options);
},
removeDocument(token, documentId, rev) {
return request(token, {
method: 'DELETE',
path: documentId,
params: { rev },
});
},
retrieveDocument(token, documentId, rev) {
return request(token, {
path: documentId,
params: { rev },
});
},
retrieveDocumentWithAttachments(token, documentId, rev) {
return request(token, {
path: documentId,
params: { attachments: true, rev },
})
.then((body) => {
body.attachments = {};
// eslint-disable-next-line no-underscore-dangle
Object.entries(body._attachments).forEach(([name, attachment]) => {
body.attachments[name] = utils.decodeBase64(attachment.data);
});
return body;
});
},
retrieveDocumentWithRevisions(token, documentId) {
return request(token, {
path: documentId,
params: {
revs_info: true,
},
});
}, },
}; };

View File

@ -26,9 +26,8 @@ export default {
} }
return result; return result;
}, },
parseContent(serializedContent, syncLocation = {}) { parseContent(serializedContent, id) {
const result = utils.deepCopy(store.state.content.itemMap[`${syncLocation.fileId}/content`]) const result = utils.deepCopy(store.state.content.itemMap[id]) || emptyContent(id);
|| emptyContent();
result.text = utils.sanitizeText(serializedContent); result.text = utils.sanitizeText(serializedContent);
result.history = []; result.history = [];
const extractedData = dataExtractor.exec(serializedContent); const extractedData = dataExtractor.exec(serializedContent);

View File

@ -365,7 +365,7 @@ function syncFile(fileId, syncContext = new SyncContext()) {
}); });
}) })
.catch((err) => { .catch((err) => {
if (store.state.offline) { if (store.state.offline || (err && err.message === 'TOO_LATE')) {
throw err; throw err;
} }
console.error(err); // eslint-disable-line no-console console.error(err); // eslint-disable-line no-console
@ -583,10 +583,10 @@ function syncWorkspace() {
const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId]; const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId];
const syncData = store.getters['data/syncDataByItemId'][contentId]; const syncData = store.getters['data/syncDataByItemId'][contentId];
if ( if (
// Sync if syncData does not exist and content syncing was not attempted yet // Sync if content syncing was not attempted yet
(!syncData && !syncContext.synced[contentId]) || !syncContext.synced[contentId] &&
// Or if content hash and syncData hash are inconsistent // And if syncData does not exist or if content hash and syncData hash are inconsistent
(syncData && hash !== syncData.hash) (!syncData || syncData.hash !== hash)
) { ) {
[fileId] = contentId.split('/'); [fileId] = contentId.split('/');
} }