CouchDB workspace (part 2)
This commit is contained in:
parent
aac305e410
commit
2374f459df
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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>
|
||||
<a class="revision__button button flex flex--row" href="javascript:void(0)" @click="open(revision)">
|
||||
<div class="revision__icon">
|
||||
@ -36,7 +36,7 @@ let previewClassAppliers = [];
|
||||
let cachedFileId;
|
||||
let revisionsPromise;
|
||||
let revisionContentPromises;
|
||||
const pageSize = 50;
|
||||
const pageSize = 30;
|
||||
const spacerThreshold = 12 * 60 * 60 * 1000; // 12h
|
||||
|
||||
export default {
|
||||
@ -51,8 +51,11 @@ export default {
|
||||
}),
|
||||
computed: {
|
||||
revisions() {
|
||||
return this.allRevisions.slice(0, this.showCount);
|
||||
},
|
||||
revisionsWithSpacer() {
|
||||
let previousCreated = 0;
|
||||
return this.allRevisions.slice(0, this.showCount).map((revision) => {
|
||||
return this.revisions.map((revision) => {
|
||||
const revisionWithSpacer = {
|
||||
...revision,
|
||||
spacer: revision.created + spacerThreshold < previousCreated,
|
||||
@ -79,12 +82,12 @@ export default {
|
||||
let revisionContentPromise = revisionContentPromises[revision.id];
|
||||
if (!revisionContentPromise) {
|
||||
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'];
|
||||
this.$store.dispatch('queue/enqueue',
|
||||
() => Promise.resolve()
|
||||
.then(() => this.workspaceProvider.getRevisionContent(
|
||||
loginToken, currentFile.id, revision.id))
|
||||
syncToken, currentFile.id, revision.id))
|
||||
.then(resolve, reject));
|
||||
});
|
||||
revisionContentPromises[revision.id] = revisionContentPromise;
|
||||
@ -137,17 +140,13 @@ export default {
|
||||
this.setRevisionContent();
|
||||
cachedFileId = id;
|
||||
revisionContentPromises = {};
|
||||
const loginToken = this.$store.getters['workspace/loginToken'];
|
||||
const syncToken = this.$store.getters['workspace/syncToken'];
|
||||
const currentFile = this.$store.getters['file/current'];
|
||||
revisionsPromise = new Promise((resolve, reject) => {
|
||||
this.$store.dispatch('queue/enqueue',
|
||||
() => Promise.resolve()
|
||||
.then(() => this.workspaceProvider.listRevisions(loginToken, currentFile.id))
|
||||
.then((revisions) => {
|
||||
resolve(revisions.sort(
|
||||
(revision1, revision2) => revision2.created - revision1.created));
|
||||
})
|
||||
.catch(reject));
|
||||
.then(() => this.workspaceProvider.listRevisions(syncToken, currentFile.id))
|
||||
.then(resolve, reject));
|
||||
});
|
||||
revisionsPromise.catch(() => {
|
||||
cachedFileId = null;
|
||||
@ -160,6 +159,28 @@ export default {
|
||||
}
|
||||
}, { 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
|
||||
this.$watch(
|
||||
() => this.$store.state.content.revisionContent,
|
||||
@ -181,6 +202,8 @@ export default {
|
||||
this.refreshHighlighters();
|
||||
// Remove event listener
|
||||
window.removeEventListener('keyup', this.onKeyup);
|
||||
// Cancel loading revisions
|
||||
this.showCount = 0;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -140,7 +140,7 @@ export default {
|
||||
.catch(() => {}); // Cancel
|
||||
},
|
||||
history() {
|
||||
if (!this.loginToken) {
|
||||
if (!this.syncToken) {
|
||||
this.$store.dispatch('modal/signInForHistory', {
|
||||
onResolve: () => googleHelper.signin()
|
||||
.then(() => syncSvc.requestSync()),
|
||||
|
@ -1,5 +1,5 @@
|
||||
export default () => ({
|
||||
id: null,
|
||||
export default (id = null) => ({
|
||||
id,
|
||||
type: 'content',
|
||||
text: '\n',
|
||||
properties: '\n',
|
||||
|
@ -1,5 +1,5 @@
|
||||
export default () => ({
|
||||
id: null,
|
||||
export default (id = null) => ({
|
||||
id,
|
||||
type: 'contentState',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0,
|
||||
|
@ -1,5 +1,5 @@
|
||||
export default () => ({
|
||||
id: null,
|
||||
export default (id = null) => ({
|
||||
id,
|
||||
type: 'file',
|
||||
name: '',
|
||||
parentId: null,
|
||||
|
@ -1,5 +1,5 @@
|
||||
export default () => ({
|
||||
id: null,
|
||||
export default (id = null) => ({
|
||||
id,
|
||||
type: 'folder',
|
||||
name: '',
|
||||
parentId: null,
|
||||
|
@ -1,5 +1,5 @@
|
||||
export default () => ({
|
||||
id: null,
|
||||
export default (id = null) => ({
|
||||
id,
|
||||
type: 'publishLocation',
|
||||
providerId: null,
|
||||
fileId: null,
|
||||
|
@ -1,5 +1,5 @@
|
||||
export default () => ({
|
||||
id: null,
|
||||
export default (id = null) => ({
|
||||
id,
|
||||
type: 'syncLocation',
|
||||
providerId: null,
|
||||
fileId: null,
|
||||
|
@ -1,5 +1,5 @@
|
||||
export default () => ({
|
||||
id: null,
|
||||
export default (id = null) => ({
|
||||
id,
|
||||
type: 'syncedContent',
|
||||
historyData: {},
|
||||
syncHistory: {},
|
||||
|
@ -1,6 +1,7 @@
|
||||
import store from '../../store';
|
||||
import couchdbHelper from './helpers/couchdbHelper';
|
||||
import providerRegistry from './providerRegistry';
|
||||
import providerUtils from './providerUtils';
|
||||
import utils from '../utils';
|
||||
|
||||
export default providerRegistry.register({
|
||||
@ -56,32 +57,169 @@ export default providerRegistry.register({
|
||||
});
|
||||
},
|
||||
getChanges() {
|
||||
const workspace = store.getters['workspace/currentWorkspace'];
|
||||
const syncToken = store.getters['workspace/syncToken'];
|
||||
const lastSeq = store.getters['data/localSettings'].syncLastSeq;
|
||||
return couchdbHelper.getChanges(syncToken, lastSeq, true)
|
||||
return couchdbHelper.getChanges(syncToken, lastSeq)
|
||||
.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) {
|
||||
if (!change.deleted && change.doc) {
|
||||
change.item = change.doc.item;
|
||||
if (!change.item || !change.item.id || !change.item.type) {
|
||||
return false;
|
||||
}
|
||||
// Build sync data
|
||||
change.syncData = {
|
||||
id: change.fileId,
|
||||
id: change.id,
|
||||
itemId: change.item.id,
|
||||
type: change.item.type,
|
||||
hash: change.item.hash,
|
||||
rev: change.doc._rev, // eslint-disable-line no-underscore-dangle
|
||||
};
|
||||
}
|
||||
change.syncDataId = change.fileId;
|
||||
change.syncDataId = change.id;
|
||||
return true;
|
||||
});
|
||||
changes.startPageToken = result.startPageToken;
|
||||
changes.lastSeq = result.lastSeq;
|
||||
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));
|
||||
},
|
||||
});
|
||||
|
@ -40,7 +40,7 @@ export default providerRegistry.register({
|
||||
makePathRelative(token, syncLocation.path),
|
||||
syncLocation.dropboxFileId,
|
||||
)
|
||||
.then(({ content }) => providerUtils.parseContent(content, syncLocation));
|
||||
.then(({ content }) => providerUtils.parseContent(content, `${syncLocation.fileId}/content`));
|
||||
},
|
||||
uploadContent(token, content, syncLocation) {
|
||||
return dropboxHelper.uploadFile(
|
||||
|
@ -18,7 +18,7 @@ export default providerRegistry.register({
|
||||
},
|
||||
downloadContent(token, syncLocation) {
|
||||
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) {
|
||||
const file = store.state.file.itemMap[syncLocation.fileId];
|
||||
|
@ -24,7 +24,7 @@ export default providerRegistry.register({
|
||||
)
|
||||
.then(({ sha, content }) => {
|
||||
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
|
||||
},
|
||||
|
@ -82,8 +82,8 @@ export default providerRegistry.register({
|
||||
}
|
||||
const syncToken = store.getters['workspace/syncToken'];
|
||||
return googleHelper.downloadAppDataFile(syncToken, syncData.id)
|
||||
.then((content) => {
|
||||
const item = JSON.parse(content);
|
||||
.then((data) => {
|
||||
const item = JSON.parse(data);
|
||||
if (item.hash !== syncData.hash) {
|
||||
store.dispatch('data/patchSyncData', {
|
||||
[syncData.id]: {
|
||||
@ -136,7 +136,8 @@ export default providerRegistry.register({
|
||||
id: revision.id,
|
||||
sub: revision.lastModifyingUser && revision.lastModifyingUser.permissionId,
|
||||
created: new Date(revision.modifiedTime).getTime(),
|
||||
})));
|
||||
}))
|
||||
.sort((revision1, revision2) => revision2.created - revision1.created));
|
||||
},
|
||||
getRevisionContent(token, fileId, revisionId) {
|
||||
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
|
||||
|
@ -110,7 +110,7 @@ export default providerRegistry.register({
|
||||
},
|
||||
downloadContent(token, syncLocation) {
|
||||
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) {
|
||||
const file = store.state.file.itemMap[syncLocation.fileId];
|
||||
|
@ -398,7 +398,7 @@ export default providerRegistry.register({
|
||||
}
|
||||
return googleHelper.downloadFile(token, syncData.id)
|
||||
.then((content) => {
|
||||
const item = providerUtils.parseContent(content, syncLocation);
|
||||
const item = providerUtils.parseContent(content, `${syncLocation.fileId}/content`);
|
||||
if (item.hash !== contentSyncData.hash) {
|
||||
store.dispatch('data/patchSyncData', {
|
||||
[contentSyncData.id]: {
|
||||
@ -543,7 +543,8 @@ export default providerRegistry.register({
|
||||
id: revision.id,
|
||||
sub: revision.lastModifyingUser && revision.lastModifyingUser.permissionId,
|
||||
created: new Date(revision.modifiedTime).getTime(),
|
||||
})));
|
||||
}))
|
||||
.sort((revision1, revision2) => revision2.created - revision1.created));
|
||||
},
|
||||
getRevisionContent(token, fileId, revisionId) {
|
||||
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 googleHelper.downloadFileRevision(token, syncData.id, revisionId)
|
||||
.then(content => providerUtils.parseContent(content));
|
||||
.then(content => providerUtils.parseContent(content, `${fileId}/content`));
|
||||
},
|
||||
});
|
||||
|
@ -30,21 +30,115 @@ const request = (token, options = {}) => {
|
||||
|
||||
const config = {
|
||||
...options,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...options.headers || {},
|
||||
},
|
||||
url: utils.resolveUrl(baseUrl, options.path || '.'),
|
||||
withCredentials: true,
|
||||
};
|
||||
|
||||
return networkSvc.request(config)
|
||||
.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 {
|
||||
getDb(token) {
|
||||
return request(token)
|
||||
.then(res => res.body);
|
||||
return request(token);
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
@ -26,9 +26,8 @@ export default {
|
||||
}
|
||||
return result;
|
||||
},
|
||||
parseContent(serializedContent, syncLocation = {}) {
|
||||
const result = utils.deepCopy(store.state.content.itemMap[`${syncLocation.fileId}/content`])
|
||||
|| emptyContent();
|
||||
parseContent(serializedContent, id) {
|
||||
const result = utils.deepCopy(store.state.content.itemMap[id]) || emptyContent(id);
|
||||
result.text = utils.sanitizeText(serializedContent);
|
||||
result.history = [];
|
||||
const extractedData = dataExtractor.exec(serializedContent);
|
||||
|
@ -365,7 +365,7 @@ function syncFile(fileId, syncContext = new SyncContext()) {
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
if (store.state.offline) {
|
||||
if (store.state.offline || (err && err.message === 'TOO_LATE')) {
|
||||
throw err;
|
||||
}
|
||||
console.error(err); // eslint-disable-line no-console
|
||||
@ -583,10 +583,10 @@ function syncWorkspace() {
|
||||
const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId];
|
||||
const syncData = store.getters['data/syncDataByItemId'][contentId];
|
||||
if (
|
||||
// Sync if syncData does not exist and content syncing was not attempted yet
|
||||
(!syncData && !syncContext.synced[contentId]) ||
|
||||
// Or if content hash and syncData hash are inconsistent
|
||||
(syncData && hash !== syncData.hash)
|
||||
// Sync if content syncing was not attempted yet
|
||||
!syncContext.synced[contentId] &&
|
||||
// And if syncData does not exist or if content hash and syncData hash are inconsistent
|
||||
(!syncData || syncData.hash !== hash)
|
||||
) {
|
||||
[fileId] = contentId.split('/');
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user