From 9596339684b4f678c2559f40045f1a348450fee3 Mon Sep 17 00:00:00 2001 From: benweet Date: Sun, 10 Dec 2017 23:49:20 +0000 Subject: [PATCH] Workspaces (part 1) --- src/components/App.vue | 20 +- src/components/ButtonBar.vue | 12 +- src/components/ExplorerNode.vue | 5 +- src/components/FindReplace.vue | 16 +- src/components/Modal.vue | 10 + src/components/NavigationBar.vue | 3 + src/components/SideBar.vue | 6 +- src/components/gutters/CommentList.vue | 15 +- src/components/gutters/CurrentDiscussion.vue | 6 +- src/components/menus/HistoryMenu.vue | 71 ++- src/components/menus/MainMenu.vue | 24 +- src/components/menus/WorkspacesMenu.vue | 60 +++ src/components/menus/common/MenuEntry.vue | 2 +- src/components/modals/ImageModal.vue | 7 +- .../modals/PublishManagementModal.vue | 5 +- src/components/modals/SyncManagementModal.vue | 5 +- src/components/modals/TemplatesModal.vue | 12 +- .../modals/WorkspaceManagementModal.vue | 133 ++++++ src/components/modals/common/ModalInner.vue | 8 +- src/components/modals/common/modalTemplate.js | 11 +- .../providers/GoogleDriveWorkspaceModal.vue | 59 +++ src/data/defaultLayoutSettings.js | 13 + src/data/defaultLocalSettings.js | 11 - src/data/defaultWorkspaces.js | 5 + src/icons/Database.vue | 5 + src/icons/Provider.vue | 34 +- src/icons/index.js | 2 + src/services/backupSvc.js | 7 +- src/services/editorSvc.js | 45 +- src/services/editorSvcDiscussions.js | 39 +- src/services/editorSvcUtils.js | 33 +- src/services/localDbSvc.js | 301 +++++++------ src/services/markdownConversionSvc.js | 6 +- src/services/markdownGrammarSvc.js | 6 +- src/services/networkSvc.js | 8 +- src/services/optional/scrollSync.js | 4 +- src/services/optional/shortcuts.js | 3 +- .../providers/googleDriveAppDataProvider.js | 17 +- src/services/providers/googleDriveProvider.js | 2 + .../providers/googleDriveWorkspaceProvider.js | 256 +++++++++++ .../providers/helpers/googleHelper.js | 36 +- src/services/providers/providerUtils.js | 4 +- src/services/syncSvc.js | 146 +++++-- src/services/utils.js | 20 +- src/store/content.js | 3 +- src/store/data.js | 411 ++++++++++-------- src/store/discussion.js | 37 +- src/store/explorer.js | 3 +- src/store/index.js | 4 - src/store/layout.js | 30 +- src/store/modal.js | 15 +- src/store/moduleTemplate.js | 2 +- src/store/workspace.js | 29 ++ 53 files changed, 1406 insertions(+), 621 deletions(-) create mode 100644 src/components/menus/WorkspacesMenu.vue create mode 100644 src/components/modals/WorkspaceManagementModal.vue create mode 100644 src/components/modals/providers/GoogleDriveWorkspaceModal.vue create mode 100644 src/data/defaultLayoutSettings.js create mode 100644 src/data/defaultWorkspaces.js create mode 100644 src/icons/Database.vue create mode 100644 src/services/providers/googleDriveWorkspaceProvider.js create mode 100644 src/store/workspace.js diff --git a/src/components/App.vue b/src/components/App.vue index f8e72d42..bd4b2f7a 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -1,7 +1,7 @@ diff --git a/src/components/modals/TemplatesModal.vue b/src/components/modals/TemplatesModal.vue index 3c6930c2..84607e3c 100644 --- a/src/components/modals/TemplatesModal.vue +++ b/src/components/modals/TemplatesModal.vue @@ -95,12 +95,12 @@ export default { (allTemplates) => { const templates = {}; // Sort templates by name - Object.keys(allTemplates) - .sort((id1, id2) => collator.compare(allTemplates[id1].name, allTemplates[id2].name)) - .forEach((id) => { - const template = utils.deepCopy(allTemplates[id]); - fillEmptyFields(template); - templates[id] = template; + Object.entries(allTemplates) + .sort(([, template1], [, template2]) => collator.compare(template1.name, template2.name)) + .forEach(([id, template]) => { + const templateClone = utils.deepCopy(template); + fillEmptyFields(templateClone); + templates[id] = templateClone; }); this.templates = templates; this.selectedId = this.config.selectedId; diff --git a/src/components/modals/WorkspaceManagementModal.vue b/src/components/modals/WorkspaceManagementModal.vue new file mode 100644 index 00000000..7ee78940 --- /dev/null +++ b/src/components/modals/WorkspaceManagementModal.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/src/components/modals/common/ModalInner.vue b/src/components/modals/common/ModalInner.vue index 2aa4ed9f..4e8feb3b 100644 --- a/src/components/modals/common/ModalInner.vue +++ b/src/components/modals/common/ModalInner.vue @@ -32,9 +32,11 @@ export default { sponsor() { Promise.resolve() .then(() => !this.$store.getters['data/loginToken'] && - this.$store.dispatch('modal/signInForSponsorship') // If user has to sign in - .then(() => googleHelper.signin()) - .then(() => syncSvc.requestSync())) + // 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'); diff --git a/src/components/modals/common/modalTemplate.js b/src/components/modals/common/modalTemplate.js index 6e2abd52..4ff7feda 100644 --- a/src/components/modals/common/modalTemplate.js +++ b/src/components/modals/common/modalTemplate.js @@ -40,8 +40,7 @@ export default (desc) => { }, }, }; - Object.keys(desc.computedLocalSettings || {}).forEach((key) => { - const id = desc.computedLocalSettings[key]; + Object.entries(desc.computedLocalSettings || {}).forEach(([key, id]) => { component.computed[key] = { get() { return store.getters['data/localSettings'][id]; @@ -56,10 +55,10 @@ export default (desc) => { component.computed.allTemplates = () => { const allTemplates = store.getters['data/allTemplates']; const sortedTemplates = {}; - Object.keys(allTemplates) - .sort((id1, id2) => collator.compare(allTemplates[id1].name, allTemplates[id2].name)) - .forEach((templateId) => { - sortedTemplates[templateId] = allTemplates[templateId]; + Object.entries(allTemplates) + .sort(([, template1], [, template2]) => collator.compare(template1.name, template2.name)) + .forEach(([templateId, template]) => { + sortedTemplates[templateId] = template; }); return sortedTemplates; }; diff --git a/src/components/modals/providers/GoogleDriveWorkspaceModal.vue b/src/components/modals/providers/GoogleDriveWorkspaceModal.vue new file mode 100644 index 00000000..a5b7e3b1 --- /dev/null +++ b/src/components/modals/providers/GoogleDriveWorkspaceModal.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/data/defaultLayoutSettings.js b/src/data/defaultLayoutSettings.js new file mode 100644 index 00000000..eb7c2abf --- /dev/null +++ b/src/data/defaultLayoutSettings.js @@ -0,0 +1,13 @@ +export default () => ({ + showNavigationBar: true, + showEditor: true, + showSidePreview: true, + showStatusBar: true, + showSideBar: false, + showExplorer: false, + scrollSync: true, + focusMode: false, + findCaseSensitive: false, + findUseRegexp: false, + sideBarPanel: 'menu', +}); diff --git a/src/data/defaultLocalSettings.js b/src/data/defaultLocalSettings.js index 0e48569b..243ef4d2 100644 --- a/src/data/defaultLocalSettings.js +++ b/src/data/defaultLocalSettings.js @@ -1,16 +1,5 @@ export default () => ({ welcomeFileHashes: {}, - showNavigationBar: true, - showEditor: true, - showSidePreview: true, - showStatusBar: true, - showSideBar: false, - showExplorer: false, - scrollSync: true, - focusMode: false, - findCaseSensitive: false, - findUseRegexp: false, - sideBarPanel: 'menu', htmlExportTemplate: 'styledHtml', pdfExportTemplate: 'styledHtml', pandocExportFormat: 'pdf', diff --git a/src/data/defaultWorkspaces.js b/src/data/defaultWorkspaces.js new file mode 100644 index 00000000..27779707 --- /dev/null +++ b/src/data/defaultWorkspaces.js @@ -0,0 +1,5 @@ +export default () => ({ + main: { + name: 'Main workspace', + }, +}); diff --git a/src/icons/Database.vue b/src/icons/Database.vue new file mode 100644 index 00000000..7f7be0bb --- /dev/null +++ b/src/icons/Database.vue @@ -0,0 +1,5 @@ + diff --git a/src/icons/Provider.vue b/src/icons/Provider.vue index d69ca19b..72da0c0a 100644 --- a/src/icons/Provider.vue +++ b/src/icons/Provider.vue @@ -1,11 +1,30 @@ @@ -22,21 +41,19 @@ export default { background-image: url(../assets/iconStackedit.svg); } -.icon-provider--googleDrive { +.icon-provider--google-drive { background-image: url(../assets/iconGoogleDrive.svg); } -.icon-provider--googlePhotos { +.icon-provider--google-photos { background-image: url(../assets/iconGooglePhotos.svg); } -.icon-provider--github, -.icon-provider--gist { +.icon-provider--github { background-image: url(../assets/iconGithub.svg); } -.icon-provider--dropbox, -.icon-provider--dropboxRestricted { +.icon-provider--dropbox { background-image: url(../assets/iconDropbox.svg); } @@ -44,8 +61,7 @@ export default { background-image: url(../assets/iconWordpress.svg); } -.icon-provider--blogger, -.icon-provider--bloggerPage { +.icon-provider--blogger { background-image: url(../assets/iconBlogger.svg); } diff --git a/src/icons/index.js b/src/icons/index.js index eb6f6d4d..6461baba 100644 --- a/src/icons/index.js +++ b/src/icons/index.js @@ -48,6 +48,7 @@ import Redo from './Redo'; import ContentSave from './ContentSave'; import Message from './Message'; import History from './History'; +import Database from './Database'; Vue.component('iconProvider', Provider); Vue.component('iconFormatBold', FormatBold); @@ -98,3 +99,4 @@ Vue.component('iconRedo', Redo); Vue.component('iconContentSave', ContentSave); Vue.component('iconMessage', Message); Vue.component('iconHistory', History); +Vue.component('iconDatabase', Database); diff --git a/src/services/backupSvc.js b/src/services/backupSvc.js index d3698ef9..0141b124 100644 --- a/src/services/backupSvc.js +++ b/src/services/backupSvc.js @@ -15,8 +15,7 @@ export default { // Parse JSON value const parsedValue = JSON.parse(jsonValue); - Object.keys(parsedValue).forEach((id) => { - const value = parsedValue[id]; + Object.entries(parsedValue).forEach(([id, value]) => { if (value) { const v4Match = id.match(/^file\.([^.]+)\.([^.]+)$/); if (v4Match) { @@ -56,8 +55,8 @@ export default { }); // Go through the maps - Object.keys(nameMap).forEach(externalId => store.dispatch('createFile', { - name: nameMap[externalId], + Object.entries(nameMap).forEach(([externalId, name]) => store.dispatch('createFile', { + name, parentId: folderIdMap[parentIdMap[externalId]], text: textMap[externalId], properties: propertiesMap[externalId], diff --git a/src/services/editorSvc.js b/src/services/editorSvc.js index fa4e004f..67f050b8 100644 --- a/src/services/editorSvc.js +++ b/src/services/editorSvc.js @@ -32,6 +32,16 @@ const diffMatchPatch = new DiffMatchPatch(); let instantPreview = true; let tokens; +class SectionDesc { + constructor(section, previewElt, tocElt, html) { + this.section = section; + this.editorElt = section.elt; + this.previewElt = previewElt; + this.tocElt = tocElt; + this.html = html; + } +} + // Use a vue instance as an event bus const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, { // Elements @@ -88,7 +98,7 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, return this.parsingCtx.sections; }, getCursorFocusRatio: () => { - if (store.getters['data/localSettings'].focusMode) { + if (store.getters['data/layoutSettings'].focusMode) { return 1; } return 0.15; @@ -128,12 +138,8 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, sectionDescIdx += 1; if (sectionDesc.editorElt !== section.elt) { // Force textToPreviewDiffs computation - sectionDesc = { - ...sectionDesc, - section, - editorElt: section.elt, - textToPreviewDiffs: null, - }; + sectionDesc = new SectionDesc( + section, sectionDesc.previewElt, sectionDesc.tocElt, sectionDesc.html); } newSectionDescList.push(sectionDesc); previewHtml += sectionDesc.html; @@ -183,16 +189,11 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, } previewHtml += html; - newSectionDescList.push({ - section, - editorElt: section.elt, - previewElt: sectionPreviewElt, - tocElt: sectionTocElt, - html, - }); + newSectionDescList.push(new SectionDesc(section, sectionPreviewElt, sectionTocElt, html)); } } }); + this.sectionDescList = newSectionDescList; this.previewHtml = previewHtml.replace(/^\s+|\s+$/g, ''); this.$emit('previewHtml', this.previewHtml); @@ -275,7 +276,7 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, }, /** - * Save editor selection/scroll state into the current file content. + * Save editor selection/scroll state into the store. */ saveContentState: allowDebounce(() => { const scrollPosition = editorSvc.getScrollPosition() || @@ -342,12 +343,12 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, this.tocElt = tocElt; this.createClEditor(editorElt); + this.clEditor.on('contentChanged', (content, diffs, sectionList) => { - const parsingCtx = { + this.parsingCtx = { ...this.parsingCtx, sectionList, }; - this.parsingCtx = parsingCtx; }); this.clEditor.undoMgr.on('undoStateChange', () => { const canUndo = this.clEditor.undoMgr.canUndo(); @@ -447,11 +448,11 @@ const editorSvc = Object.assign(new Vue(), editorSvcDiscussions, editorSvcUtils, }; const triggerImgCacheGc = debounce(() => { - Object.keys(imgCache).forEach((src) => { - const entries = imgCache[src] - .filter(imgElt => this.editorElt.contains(imgElt)); - if (entries.length) { - imgCache[src] = entries; + Object.entries(imgCache).forEach(([src, entries]) => { + // Filter entries that are not attached to the DOM + const filteredEntries = entries.filter(imgElt => this.editorElt.contains(imgElt)); + if (filteredEntries.length) { + imgCache[src] = filteredEntries; } else { delete imgCache[src]; } diff --git a/src/services/editorSvcDiscussions.js b/src/services/editorSvcDiscussions.js index a981bb28..5089683d 100644 --- a/src/services/editorSvcDiscussions.js +++ b/src/services/editorSvcDiscussions.js @@ -45,8 +45,7 @@ function syncDiscussionMarkers(content, writeOffsets) { ...newDiscussion, }; } - Object.keys(discussionMarkers).forEach((markerKey) => { - const marker = discussionMarkers[markerKey]; + Object.entries(discussionMarkers).forEach(([markerKey, marker]) => { // Remove marker if discussion was removed const discussion = discussions[marker.discussionId]; if (!discussion) { @@ -55,8 +54,7 @@ function syncDiscussionMarkers(content, writeOffsets) { } }); - Object.keys(discussions).forEach((discussionId) => { - const discussion = discussions[discussionId]; + Object.entries(discussions).forEach(([discussionId, discussion]) => { getDiscussionMarkers(discussion, discussionId, writeOffsets ? (marker) => { discussion[marker.offsetName] = marker.offset; @@ -73,8 +71,8 @@ function syncDiscussionMarkers(content, writeOffsets) { } function removeDiscussionMarkers() { - Object.keys(discussionMarkers).forEach((markerKey) => { - clEditor.removeMarker(discussionMarkers[markerKey]); + Object.entries(discussionMarkers).forEach(([, marker]) => { + clEditor.removeMarker(marker); }); discussionMarkers = {}; markerKeys = []; @@ -138,25 +136,6 @@ export default { isChangePatch = false; }); clEditor.on('focus', () => store.commit('discussion/setNewCommentFocus', false)); - - // Track new discussions (not sure it's a good idea) - // store.watch( - // () => store.getters['content/current'].discussions, - // (discussions) => { - // const oldDiscussionIds = discussionIds; - // discussionIds = {}; - // let hasNewDiscussion = false; - // Object.keys(discussions).forEach((discussionId) => { - // discussionIds[discussionId] = true; - // if (!oldDiscussionIds[discussionId]) { - // hasNewDiscussion = true; - // } - // }); - // if (hasNewDiscussion) { - // const content = store.getters['content/current']; - // currentPatchableText = diffUtils.makePatchableText(content, markerKeys, markerIdxMap); - // } - // }); }, initClEditorInternal(opts) { const content = store.getters['content/current']; @@ -241,9 +220,10 @@ export default { classGetter('editor', discussionId), offsetGetter(discussionId), { discussionId }); editorClassAppliers[discussionId] = classApplier; }); - Object.keys(oldEditorClassAppliers).forEach((discussionId) => { + // Clean unused class appliers + Object.entries(oldEditorClassAppliers).forEach(([discussionId, classApplier]) => { if (!editorClassAppliers[discussionId]) { - oldEditorClassAppliers[discussionId].stop(); + classApplier.stop(); } }); @@ -255,9 +235,10 @@ export default { classGetter('preview', discussionId), offsetGetter(discussionId), { discussionId }); previewClassAppliers[discussionId] = classApplier; }); - Object.keys(oldPreviewClassAppliers).forEach((discussionId) => { + // Clean unused class appliers + Object.entries(oldPreviewClassAppliers).forEach(([discussionId, classApplier]) => { if (!previewClassAppliers[discussionId]) { - oldPreviewClassAppliers[discussionId].stop(); + classApplier.stop(); } }); }, diff --git a/src/services/editorSvcUtils.js b/src/services/editorSvcUtils.js index daecf1b3..53637b0a 100644 --- a/src/services/editorSvcUtils.js +++ b/src/services/editorSvcUtils.js @@ -6,36 +6,25 @@ import store from '../store'; const diffMatchPatch = new DiffMatchPatch(); export default { - /** - * Get element and dimension that handles scrolling. - */ - getObjectToScroll() { - let elt = this.editorElt.parentNode; - let dimensionKey = 'editorDimension'; - if (!store.getters['layout/styles'].showEditor) { - elt = this.previewElt.parentNode; - dimensionKey = 'previewDimension'; - } - return { - elt, - dimensionKey, - }; - }, - /** * Get an object describing the position of the scroll bar in the file. */ - getScrollPosition() { - const objToScroll = this.getObjectToScroll(); - const scrollTop = objToScroll.elt.scrollTop; + getScrollPosition(elt = store.getters['layout/styles'].showEditor + ? this.editorElt + : this.previewElt, + ) { + const dimensionKey = elt === this.editorElt + ? 'editorDimension' + : 'previewDimension'; + const scrollTop = elt.parentNode.scrollTop; let result; if (this.sectionDescMeasuredList) { this.sectionDescMeasuredList.some((sectionDesc, sectionIdx) => { - if (scrollTop >= sectionDesc[objToScroll.dimensionKey].endOffset) { + if (scrollTop >= sectionDesc[dimensionKey].endOffset) { return false; } - const posInSection = (scrollTop - sectionDesc[objToScroll.dimensionKey].startOffset) / - (sectionDesc[objToScroll.dimensionKey].height || 1); + const posInSection = (scrollTop - sectionDesc[dimensionKey].startOffset) / + (sectionDesc[dimensionKey].height || 1); result = { sectionIdx, posInSection, diff --git a/src/services/localDbSvc.js b/src/services/localDbSvc.js index 06ceb168..b287cb18 100644 --- a/src/services/localDbSvc.js +++ b/src/services/localDbSvc.js @@ -15,11 +15,11 @@ const deleteMarkerMaxAge = 1000; const checkSponsorshipAfter = (5 * 60 * 1000) + (30 * 1000); // tokenExpirationMargin + 30 sec class Connection { - constructor() { + constructor(dbName) { this.getTxCbs = []; - // Init connexion - const request = indexedDB.open('stackedit-db', dbVersion); + // Init connection + const request = indexedDB.open(dbName, dbVersion); request.onerror = () => { throw new Error("Can't connect to IndexedDB."); @@ -39,7 +39,7 @@ class Connection { const oldVersion = event.oldVersion || 0; // We don't use 'break' in this switch statement, - // the fall-through behaviour is what we want. + // the fall-through behavior is what we want. /* eslint-disable no-fallthrough */ switch (oldVersion) { case 0: @@ -80,21 +80,174 @@ class Connection { } } -const hashMap = {}; -utils.types.forEach((type) => { - hashMap[type] = Object.create(null); -}); - const contentTypes = { content: true, contentState: true, syncedContent: true, }; +const hashMap = {}; +utils.types.forEach((type) => { + hashMap[type] = Object.create(null); +}); +const lsHashMap = Object.create(null); + const localDbSvc = { lastTx: 0, hashMap, - connection: new Connection(), + connection: null, + + /** + * Create the connection and start syncing. + */ + init() { + // Create the connection + this.connection = new Connection(store.getters['data/dbName']); + + // Load the DB + return localDbSvc.sync() + .then(() => { + // If exportBackup parameter was provided + if (exportBackup) { + const backup = JSON.stringify(store.getters.allItemMap); + const blob = new Blob([backup], { + type: 'text/plain;charset=utf-8', + }); + FileSaver.saveAs(blob, 'StackEdit workspace.json'); + return; + } + + // Save welcome file content hash if not done already + const hash = utils.hash(welcomeFile); + const welcomeFileHashes = store.getters['data/localSettings'].welcomeFileHashes; + if (!welcomeFileHashes[hash]) { + store.dispatch('data/patchLocalSettings', { + welcomeFileHashes: { + ...welcomeFileHashes, + [hash]: 1, + }, + }); + } + + // If app was last opened 7 days ago and synchronization is off + if (!store.getters['data/loginToken'] && + (utils.lastOpened + utils.cleanTrashAfter < Date.now()) + ) { + // Clean files + store.getters['file/items'] + .filter(file => file.parentId === 'trash') // If file is in the trash + .forEach(file => store.dispatch('deleteFile', file.id)); + } + + // Enable sponsorship + if (utils.queryParams.paymentSuccess) { + location.hash = ''; + store.dispatch('modal/paymentSuccess'); + const loginToken = store.getters['data/loginToken']; + // Force check sponsorship after a few seconds + const currentDate = Date.now(); + if (loginToken && loginToken.expiresOn > currentDate - checkSponsorshipAfter) { + store.dispatch('data/setGoogleToken', { + ...loginToken, + expiresOn: currentDate - checkSponsorshipAfter, + }); + } + } + + // Sync local DB periodically + utils.setInterval(() => localDbSvc.sync(), 1000); + + const ifNoId = cb => (obj) => { + if (obj.id) { + return obj; + } + return cb(); + }; + + // watch current file changing + store.watch( + () => store.getters['file/current'].id, + () => Promise.resolve(store.getters['file/current']) + // If current file has no ID, get the most recent file + .then(ifNoId(() => store.getters['file/lastOpened'])) + // If still no ID, create a new file + .then(ifNoId(() => store.dispatch('createFile', { + name: 'Welcome file', + text: welcomeFile, + }))) + .then((currentFile) => { + // Fix current file ID + if (store.getters['file/current'].id !== currentFile.id) { + store.commit('file/setCurrentId', currentFile.id); + // Wait for the next watch tick + return null; + } + + return Promise.resolve() + // Load contentState from DB + .then(() => localDbSvc.loadContentState(currentFile.id)) + // Load syncedContent from DB + .then(() => localDbSvc.loadSyncedContent(currentFile.id)) + // Load content from DB + .then(() => localDbSvc.loadItem(`${currentFile.id}/content`)) + .then( + () => { + // Set last opened file + store.dispatch('data/setLastOpenedId', currentFile.id); + // Cancel new discussion + store.commit('discussion/setCurrentDiscussionId'); + // Open the gutter if file contains discussions + store.commit('discussion/setCurrentDiscussionId', + store.getters['discussion/nextDiscussionId']); + }, + (err) => { + // Failure (content is not available), go back to previous file + const lastOpenedFile = store.getters['file/lastOpened']; + store.commit('file/setCurrentId', lastOpenedFile.id); + throw err; + }, + ); + }) + .catch((err) => { + console.error(err); // eslint-disable-line no-console + store.dispatch('notification/error', err); + }), + { + immediate: true, + }); + }); + }, + + /** + * Sync data items stored in the localStorage. + */ + syncLocalStorage() { + utils.localStorageDataIds.forEach((id) => { + const key = `data/${id}`; + + // Skip reloading the layoutSettings + if (id !== 'layoutSettings' || !lsHashMap[id]) { + try { + // Try to parse the item from the localStorage + const storedItem = JSON.parse(localStorage.getItem(key)); + if (storedItem.hash && lsHashMap[id] !== storedItem.hash) { + // Item has changed, replace it in the store + store.commit('data/setItem', storedItem); + lsHashMap[id] = storedItem.hash; + } + } catch (e) { + // Ignore parsing issue + } + } + + // Write item if different from stored one + const item = store.state.data.lsItemMap[id]; + if (item && item.hash !== lsHashMap[id]) { + localStorage.setItem(key, JSON.stringify(item)); + lsHashMap[id] = item.hash; + } + }); + }, /** * Return a promise that will be resolved once the synchronization between the store and the @@ -103,9 +256,15 @@ const localDbSvc = { */ sync() { return new Promise((resolve, reject) => { + // Create the DB transaction this.connection.createTx((tx) => { + // Look for DB changes and apply them to the store this.readAll(tx, (storeItemMap) => { + // Persist all the store changes into the DB this.writeAll(storeItemMap, tx); + // Sync localStorage + this.syncLocalStorage(); + // Done resolve(); }); }, () => reject(new Error('Local DB access error.'))); @@ -186,8 +345,7 @@ const localDbSvc = { }); // Put changes - Object.keys(storeItemMap).forEach((id) => { - const storeItem = storeItemMap[id]; + Object.entries(storeItemMap).forEach(([, storeItem]) => { // Store object has changed if (this.hashMap[storeItem.type][storeItem.id] !== storeItem.hash) { const item = { @@ -266,11 +424,11 @@ const localDbSvc = { return this.sync() .then(() => { // Keep only last opened files in memory - const lastOpenedFileIds = new Set(store.getters['data/lastOpenedIds']); + const lastOpenedFileIdSet = new Set(store.getters['data/lastOpenedIds']); Object.keys(contentTypes).forEach((type) => { store.getters[`${type}/items`].forEach((item) => { const [fileId] = item.id.split('/'); - if (!lastOpenedFileIds.has(fileId)) { + if (!lastOpenedFileIdSet.has(fileId)) { // Remove item from the store store.commit(`${type}/deleteItem`, item.id); } @@ -303,119 +461,4 @@ const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`) localDbSvc.loadSyncedContent = loader('syncedContent'); localDbSvc.loadContentState = loader('contentState'); -const ifNoId = cb => (obj) => { - if (obj.id) { - return obj; - } - return cb(); -}; - -// Load the DB on boot -localDbSvc.sync() - .then(() => { - if (exportBackup) { - const backup = JSON.stringify(store.getters.allItemMap); - const blob = new Blob([backup], { - type: 'text/plain;charset=utf-8', - }); - FileSaver.saveAs(blob, 'StackEdit workspace.json'); - return; - } - - // Set the ready flag - store.commit('setReady'); - - // Save welcome file content hash if not done already - const hash = utils.hash(welcomeFile); - const welcomeFileHashes = store.getters['data/localSettings'].welcomeFileHashes; - if (!welcomeFileHashes[hash]) { - store.dispatch('data/patchLocalSettings', { - welcomeFileHashes: { - ...welcomeFileHashes, - [hash]: 1, - }, - }); - } - - // If app was last opened 7 days ago and synchronization is off - if (!store.getters['data/loginToken'] && - (utils.lastOpened + utils.cleanTrashAfter < Date.now()) - ) { - // Clean files - store.getters['file/items'] - .filter(file => file.parentId === 'trash') // If file is in the trash - .forEach(file => store.dispatch('deleteFile', file.id)); - } - - // Enable sponsorship - if (utils.queryParams.paymentSuccess) { - location.hash = ''; - store.dispatch('modal/paymentSuccess'); - const loginToken = store.getters['data/loginToken']; - // Force check sponsorship after a few seconds - const currentDate = Date.now(); - if (loginToken && loginToken.expiresOn > currentDate - checkSponsorshipAfter) { - store.dispatch('data/setGoogleToken', { - ...loginToken, - expiresOn: currentDate - checkSponsorshipAfter, - }); - } - } - - // watch file changing - store.watch( - () => store.getters['file/current'].id, - () => Promise.resolve(store.getters['file/current']) - // If current file has no ID, get the most recent file - .then(ifNoId(() => store.getters['file/lastOpened'])) - // If still no ID, create a new file - .then(ifNoId(() => store.dispatch('createFile', { - name: 'Welcome file', - text: welcomeFile, - }))) - .then((currentFile) => { - // Fix current file ID - if (store.getters['file/current'].id !== currentFile.id) { - store.commit('file/setCurrentId', currentFile.id); - // Wait for the next watch tick - return null; - } - - return Promise.resolve() - // Load contentState from DB - .then(() => localDbSvc.loadContentState(currentFile.id)) - // Load syncedContent from DB - .then(() => localDbSvc.loadSyncedContent(currentFile.id)) - // Load content from DB - .then(() => localDbSvc.loadItem(`${currentFile.id}/content`)) - .then( - () => { - // Set last opened file - store.dispatch('data/setLastOpenedId', currentFile.id); - // Cancel new discussion - store.commit('discussion/setCurrentDiscussionId'); - // Open the gutter if file contains discussions - store.commit('discussion/setCurrentDiscussionId', - store.getters['discussion/nextDiscussionId']); - }, - (err) => { - // Failure (content is not available), go back to previous file - const lastOpenedFile = store.getters['file/lastOpened']; - store.commit('file/setCurrentId', lastOpenedFile.id); - throw err; - }, - ); - }) - .catch((err) => { - console.error(err); // eslint-disable-line no-console - store.dispatch('notification/error', err); - }), - { - immediate: true, - }); - }); - -// Sync local DB periodically -utils.setInterval(() => localDbSvc.sync(), 1000); - export default localDbSvc; diff --git a/src/services/markdownConversionSvc.js b/src/services/markdownConversionSvc.js index 6a187ec9..03eca42b 100644 --- a/src/services/markdownConversionSvc.js +++ b/src/services/markdownConversionSvc.js @@ -20,15 +20,13 @@ const languageAliases = ({ ps1: 'powershell', psm1: 'powershell', }); -Object.keys(languageAliases).forEach((alias) => { - const language = languageAliases[alias]; +Object.entries(languageAliases).forEach(([alias, language]) => { Prism.languages[alias] = Prism.languages[language]; }); // Add programming language parsing capability to markdown fences const insideFences = {}; -Object.keys(Prism.languages).forEach((name) => { - const language = Prism.languages[name]; +Object.entries(Prism.languages).forEach(([name, language]) => { if (Prism.util.type(language) === 'Object') { insideFences[`language-${name}`] = { pattern: new RegExp(`(\`\`\`|~~~)${name}\\W[\\s\\S]*`), diff --git a/src/services/markdownGrammarSvc.js b/src/services/markdownGrammarSvc.js index ae5c3b26..6eca26db 100644 --- a/src/services/markdownGrammarSvc.js +++ b/src/services/markdownGrammarSvc.js @@ -190,8 +190,7 @@ export default { }, }; - Object.keys(defs).forEach((name) => { - const def = defs[name]; + Object.entries(defs).forEach(([name, def]) => { grammars.main[name] = def; grammars.list[name] = def; grammars.blockquote[name] = def; @@ -396,8 +395,7 @@ export default { rest.linkref.inside['cl cl-underlined-text'].inside = inside; // Wrap any other characters to allow paragraph folding - Object.keys(grammars).forEach((key) => { - const grammar = grammars[key]; + Object.entries(grammars).forEach(([, grammar]) => { grammar.rest = grammar.rest || {}; grammar.rest.p = /.+/; }); diff --git a/src/services/networkSvc.js b/src/services/networkSvc.js index b42de77a..53dd0ab7 100644 --- a/src/services/networkSvc.js +++ b/src/services/networkSvc.js @@ -193,12 +193,8 @@ export default { const url = utils.addQueryParams(config.url, config.params); xhr.open(config.method || 'GET', url); - Object.keys(config.headers).forEach((key) => { - const value = config.headers[key]; - if (value) { - xhr.setRequestHeader(key, `${value}`); - } - }); + Object.entries(config.headers).forEach(([key, value]) => + value && xhr.setRequestHeader(key, `${value}`)); if (config.blob) { xhr.responseType = 'blob'; } diff --git a/src/services/optional/scrollSync.js b/src/services/optional/scrollSync.js index a9bec828..9820b560 100644 --- a/src/services/optional/scrollSync.js +++ b/src/services/optional/scrollSync.js @@ -34,7 +34,7 @@ function throttle(func, wait) { const doScrollSync = () => { const localSkipAnimation = skipAnimation || !store.getters['layout/styles'].showSidePreview; skipAnimation = false; - if (!store.getters['data/localSettings'].scrollSync || !sectionDescList || sectionDescList.length === 0) { + if (!store.getters['data/layoutSettings'].scrollSync || !sectionDescList || sectionDescList.length === 0) { return; } let editorScrollTop = editorScrollerElt.scrollTop; @@ -116,7 +116,7 @@ const forceScrollSync = () => { doScrollSync(); } }; -store.watch(() => store.getters['data/localSettings'].scrollSync, forceScrollSync); +store.watch(() => store.getters['data/layoutSettings'].scrollSync, forceScrollSync); editorSvc.$on('inited', () => { editorScrollerElt = editorSvc.editorElt.parentNode; diff --git a/src/services/optional/shortcuts.js b/src/services/optional/shortcuts.js index 967d5c21..66699cf0 100644 --- a/src/services/optional/shortcuts.js +++ b/src/services/optional/shortcuts.js @@ -67,8 +67,7 @@ store.watch( Mousetrap.reset(); const shortcuts = computedSettings.shortcuts; - Object.keys(shortcuts).forEach((key) => { - const shortcut = shortcuts[key]; + Object.entries(shortcuts).forEach(([key, shortcut]) => { if (shortcut) { const method = `${shortcut.method || shortcut}`; let params = shortcut.params || []; diff --git a/src/services/providers/googleDriveAppDataProvider.js b/src/services/providers/googleDriveAppDataProvider.js index be2220c4..8eac76b8 100644 --- a/src/services/providers/googleDriveAppDataProvider.js +++ b/src/services/providers/googleDriveAppDataProvider.js @@ -7,6 +7,10 @@ export default providerRegistry.register({ getToken() { return store.getters['data/loginToken']; }, + initWorkspace() { + // Nothing to do since the main workspace isn't necessarily synchronized + return Promise.resolve(); + }, getChanges(token) { return googleHelper.getChanges(token) .then((result) => { @@ -44,8 +48,10 @@ export default providerRegistry.register({ saveItem(token, item, syncData, ifNotTooLate) { return googleHelper.uploadAppDataFile( token, - JSON.stringify(item), ['appDataFolder'], - null, + JSON.stringify(item), + ['appDataFolder'], + undefined, + undefined, syncData && syncData.id, ifNotTooLate, ) @@ -100,6 +106,7 @@ export default providerRegistry.register({ hash: item.hash, }), ['appDataFolder'], + undefined, JSON.stringify(item), syncData && syncData.id, ifNotTooLate, @@ -116,6 +123,9 @@ export default providerRegistry.register({ }, 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, @@ -125,6 +135,9 @@ export default providerRegistry.register({ }, 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)); }, diff --git a/src/services/providers/googleDriveProvider.js b/src/services/providers/googleDriveProvider.js index 8333b60b..18985e11 100644 --- a/src/services/providers/googleDriveProvider.js +++ b/src/services/providers/googleDriveProvider.js @@ -32,6 +32,7 @@ export default providerRegistry.register({ token, name, parents, + undefined, providerUtils.serializeContent(content), undefined, syncLocation.driveFileId, @@ -47,6 +48,7 @@ export default providerRegistry.register({ token, metadata.title, [], + undefined, html, publishLocation.templateId ? 'text/html' : undefined, publishLocation.driveFileId, diff --git a/src/services/providers/googleDriveWorkspaceProvider.js b/src/services/providers/googleDriveWorkspaceProvider.js new file mode 100644 index 00000000..1dd9433e --- /dev/null +++ b/src/services/providers/googleDriveWorkspaceProvider.js @@ -0,0 +1,256 @@ +import store from '../../store'; +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']; + }, + initWorkspace() { + const initFolder = (token, folder) => Promise.resolve({ + workspaceId: this.makeWorkspaceId(folder.id), + dataFolderId: folder.appProperties.dataFolderId, + trashFolderId: folder.appProperties.trashFolderId, + }) + .then((properties) => { + // Make sure data folder exists + if (properties.dataFolderId) { + return properties; + } + return googleHelper.uploadFile( + token, + '.stackedit-data', + [folder.id], + { workspaceId: properties.workspaceId }, + undefined, + 'application/vnd.google-apps.folder', + ) + .then(dataFolder => ({ + ...properties, + dataFolderId: dataFolder.id, + })); + }) + .then((properties) => { + // Make sure trash folder exists + if (properties.trashFolderId) { + return properties; + } + return googleHelper.uploadFile( + token, + '.stackedit-trash', + [folder.id], + { workspaceId: properties.workspaceId }, + undefined, + 'application/vnd.google-apps.folder', + ) + .then(trashFolder => ({ + ...properties, + trashFolderId: trashFolder.id, + })); + }) + .then((properties) => { + // Update workspace if some properties are missing + if (properties.workspaceId === folder.appProperties.workspaceId + && properties.dataFolderId === folder.appProperties.dataFolderId + && properties.trashFolderId === folder.appProperties.trashFolderId + ) { + return properties; + } + return googleHelper.uploadFile( + token, + undefined, + undefined, + properties, + undefined, + 'application/vnd.google-apps.folder', + folder.id, + ) + .then(() => properties); + }) + .then((properties) => { + // Update workspace in the store + store.dispatch('data/patchWorkspaces', { + [properties.workspaceId]: { + id: properties.workspaceId, + sub: token.sub, + name: folder.name, + providerId: this.id, + folderId: folder.id, + dataFolderId: properties.dataFolderId, + trashFolderId: properties.trashFolderId, + }, + }); + return store.getters['data/workspaces'][properties.workspaceId]; + }); + + return Promise.resolve(store.getters['data/googleTokens'][utils.queryParams.sub]) + .then(token => token || this.$store.dispatch('modal/workspaceGoogleRedirection', { + onResolve: () => googleHelper.addDriveAccount(), + })) + .then(token => Promise.resolve() + .then(() => utils.queryParams.folderId || googleHelper.uploadFile( + token, + 'StackEdit workspace', + [], + 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); + }); + })); + }, + getChanges(token) { + return googleHelper.getChanges(token) + .then((result) => { + const changes = result.changes.filter((change) => { + if (change.file) { + try { + change.item = JSON.parse(change.file.name); + } catch (e) { + return false; + } + // Build sync data + change.syncData = { + id: change.fileId, + itemId: change.item.id, + type: change.item.type, + hash: change.item.hash, + }; + change.file = undefined; + } + return true; + }); + changes.nextPageToken = result.nextPageToken; + 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); + }, +}); diff --git a/src/services/providers/helpers/googleHelper.js b/src/services/providers/helpers/googleHelper.js index 810ac123..d953999e 100644 --- a/src/services/providers/helpers/googleHelper.js +++ b/src/services/providers/helpers/googleHelper.js @@ -53,7 +53,16 @@ export default { throw err; }); }, - uploadFileInternal(refreshedToken, name, parents, media = null, mediaType = 'text/plain', fileId = null, ifNotTooLate = cb => res => cb(res)) { + uploadFileInternal( + refreshedToken, + name, + parents, + appProperties, + media = null, + mediaType = null, + fileId = null, + ifNotTooLate = cb => res => cb(res), + ) { return Promise.resolve() // Refreshing a token can take a while if an oauth window pops up, make sure it's not too late .then(ifNotTooLate(() => { @@ -61,7 +70,7 @@ export default { method: 'POST', url: 'https://www.googleapis.com/drive/v3/files', }; - const metadata = { name }; + const metadata = { name, appProperties }; if (fileId) { options.method = 'PATCH'; options.url = `https://www.googleapis.com/drive/v3/files/${fileId}`; @@ -78,7 +87,7 @@ export default { multipartRequestBody += 'Content-Type: application/json; charset=UTF-8\r\n\r\n'; multipartRequestBody += JSON.stringify(metadata); multipartRequestBody += delimiter; - multipartRequestBody += `Content-Type: ${mediaType}; charset=UTF-8\r\n\r\n`; + multipartRequestBody += `Content-Type: ${mediaType || 'text/plain'}; charset=UTF-8\r\n\r\n`; multipartRequestBody += media; multipartRequestBody += closeDelimiter; options.url = options.url.replace( @@ -95,6 +104,9 @@ export default { body: multipartRequestBody, }).then(res => res.body); } + if (mediaType) { + metadata.mimeType = mediaType; + } return this.request(refreshedToken, { ...options, body: metadata, @@ -170,7 +182,7 @@ export default { .then(token => this.getUser(token.sub) .catch((err) => { if (err.status === 404) { - store.dispatch('notification/info', 'Please activate Google Plus to change your account name!'); + store.dispatch('notification/info', 'Please activate Google Plus to change your account name and photo!'); } else { throw err; } @@ -311,15 +323,23 @@ export default { return getPage(refreshedToken.nextPageToken); }); }, - uploadFile(token, name, parents, media, mediaType, fileId, ifNotTooLate) { + uploadFile(token, name, parents, appProperties, media, mediaType, fileId, ifNotTooLate) { return this.refreshToken(token, getDriveScopes(token)) .then(refreshedToken => this.uploadFileInternal( - refreshedToken, name, parents, media, mediaType, fileId, ifNotTooLate)); + refreshedToken, name, parents, appProperties, media, mediaType, fileId, ifNotTooLate)); }, - uploadAppDataFile(token, name, parents, media, fileId, ifNotTooLate) { + uploadAppDataFile(token, name, parents, appProperties, media, fileId, ifNotTooLate) { return this.refreshToken(token, driveAppDataScopes) .then(refreshedToken => this.uploadFileInternal( - refreshedToken, name, parents, media, undefined, fileId, ifNotTooLate)); + refreshedToken, name, parents, appProperties, media, undefined, fileId, ifNotTooLate)); + }, + getFile(token, id) { + return this.refreshToken(token, getDriveScopes(token)) + .then(refreshedToken => this.request(refreshedToken, { + method: 'GET', + url: `https://www.googleapis.com/drive/v3/files/${id}`, + }) + .then(res => res.body)); }, downloadFile(token, id) { return this.refreshToken(token, getDriveScopes(token)) diff --git a/src/services/providers/providerUtils.js b/src/services/providers/providerUtils.js index 11e5f677..326acfd7 100644 --- a/src/services/providers/providerUtils.js +++ b/src/services/providers/providerUtils.js @@ -62,8 +62,8 @@ export default { */ openFileWithLocation(allLocations, criteria) { return allLocations.some((location) => { - // If every field fits the criteria - if (Object.keys(criteria).every(key => criteria[key] === location[key])) { + // If every field fits the criteria + if (Object.entries(criteria).every(([key, value]) => value === location[key])) { // Found one location that fits, open it if it exists const file = store.state.file.itemMap[location.fileId]; if (file) { diff --git a/src/services/syncSvc.js b/src/services/syncSvc.js index 197829f6..c53ad2e6 100644 --- a/src/services/syncSvc.js +++ b/src/services/syncSvc.js @@ -3,38 +3,73 @@ import store from '../store'; import utils from './utils'; import diffUtils from './diffUtils'; import providerRegistry from './providers/providerRegistry'; -import mainProvider from './providers/googleDriveAppDataProvider'; +import googleDriveAppDataProvider from './providers/googleDriveAppDataProvider'; -const lastSyncActivityKey = `${utils.workspaceId}/lastSyncActivity`; -let lastSyncActivity; -const getLastStoredSyncActivity = () => parseInt(localStorage[lastSyncActivityKey], 10) || 0; const inactivityThreshold = 3 * 1000; // 3 sec const restartSyncAfter = 30 * 1000; // 30 sec const autoSyncAfter = utils.randomize(60 * 1000); // 60 sec -const isDataSyncPossible = () => !!store.getters['data/loginToken']; +let workspaceProvider; + +/** + * Use a lock in the local storage to prevent multiple windows concurrency. + */ +const lastSyncActivityKey = `${utils.workspaceId}/lastSyncActivity`; +let lastSyncActivity; +const getLastStoredSyncActivity = () => parseInt(localStorage[lastSyncActivityKey], 10) || 0; + +/** + * 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; +}; + +/** + * Return true if file has at least one explicit sync location. + */ const hasCurrentFileSyncLocations = () => !!store.getters['syncLocation/current'].length; +/** + * Return true if we are online and we have something to sync. + */ const isSyncPossible = () => !store.state.offline && - (isDataSyncPossible() || hasCurrentFileSyncLocations()); + (isWorkspaceSyncPossible() || hasCurrentFileSyncLocations()); +/** + * Return true if we are the many window, ie we have the lastSyncActivity lock. + */ function isSyncWindow() { const storedLastSyncActivity = getLastStoredSyncActivity(); return lastSyncActivity === storedLastSyncActivity || Date.now() > inactivityThreshold + storedLastSyncActivity; } +/** + * Return true if auto sync can start, ie that lastSyncActivity is old enough. + */ function isAutoSyncReady() { const storedLastSyncActivity = getLastStoredSyncActivity(); return Date.now() > autoSyncAfter + storedLastSyncActivity; } +/** + * Update the lastSyncActivity, assuming we have the lock. + */ function setLastSyncActivity() { const currentDate = Date.now(); lastSyncActivity = currentDate; localStorage[lastSyncActivityKey] = currentDate; } +/** + * Clean a syncedContent. + */ function cleanSyncedContent(syncedContent) { // Clean syncHistory from removed syncLocations Object.keys(syncedContent.syncHistory).forEach((syncLocationId) => { @@ -42,17 +77,21 @@ function cleanSyncedContent(syncedContent) { delete syncedContent.syncHistory[syncLocationId]; } }); - const allSyncLocationHashes = new Set([].concat( + const allSyncLocationHashSet = new Set([].concat( ...Object.keys(syncedContent.syncHistory).map( id => syncedContent.syncHistory[id]))); // Clean historyData from unused contents Object.keys(syncedContent.historyData).map(hash => parseInt(hash, 10)).forEach((hash) => { - if (!allSyncLocationHashes.has(hash)) { + if (!allSyncLocationHashSet.has(hash)) { delete syncedContent.historyData[hash]; } }); } +/** + * 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 }; const syncData = { ...store.getters['data/syncData'] }; @@ -92,6 +131,9 @@ function applyChanges(changes) { const LAST_SENT = 0; const LAST_MERGED = 1; +/** + * Create a sync location by uploading the current file content. + */ function createSyncLocation(syncLocation) { syncLocation.id = utils.uid(); const currentFile = store.getters['file/current']; @@ -137,6 +179,9 @@ class FileSyncContext { } } +/** + * Sync one file with all its locations. + */ function syncFile(fileId, syncContext = new SyncContext()) { const fileSyncContext = new FileSyncContext(); syncContext.synced[`${fileId}/content`] = true; @@ -174,7 +219,7 @@ function syncFile(fileId, syncContext = new SyncContext()) { const syncLocations = [ ...store.getters['syncLocation/groupedByFileId'][fileId] || [], ]; - if (isDataSyncPossible()) { + if (isWorkspaceSyncPossible()) { syncLocations.unshift({ id: 'main', providerId: mainProvider.id, fileId }); } let result; @@ -349,7 +394,9 @@ function syncFile(fileId, syncContext = new SyncContext()) { }); } - +/** + * Sync a data item, typically settings and templates. + */ function syncDataItem(dataId) { const item = store.state.data.itemMap[dataId]; const syncData = store.getters['data/syncDataByItemId'][dataId]; @@ -418,7 +465,10 @@ function syncDataItem(dataId) { }); } -function sync() { +/** + * Sync the whole workspace with the main provider and the current file explicit locations. + */ +function syncWorkspace() { const syncContext = new SyncContext(); const mainToken = store.getters['data/loginToken']; return mainProvider.getChanges(mainToken) @@ -447,8 +497,7 @@ function sync() { }; const syncDataByItemId = store.getters['data/syncDataByItemId']; let result; - Object.keys(storeItemMap).some((id) => { - const item = storeItemMap[id]; + Object.entries(storeItemMap).some(([id, item]) => { const existingSyncData = syncDataByItemId[id]; if ((!existingSyncData || existingSyncData.hash !== item.hash) && // Add file if content has been uploaded @@ -483,8 +532,7 @@ function sync() { }; const syncData = store.getters['data/syncData']; let result; - Object.keys(syncData).some((id) => { - const existingSyncData = syncData[id]; + Object.entries(syncData).some(([, existingSyncData]) => { if (!storeItemMap[existingSyncData.itemId] && // Remove content only if file has been removed (existingSyncData.type !== 'content' || !storeItemMap[existingSyncData.itemId.split('/')[0]]) @@ -556,20 +604,23 @@ function sync() { () => { if (syncContext.restart) { // Restart sync - return sync(); + return syncWorkspace(); } return null; }, (err) => { if (err && err.message === 'TOO_LATE') { // Restart sync - return sync(); + return syncWorkspace(); } throw err; }); }); } +/** + * Enqueue a sync task, if possible. + */ function requestSync() { store.dispatch('queue/enqueueSyncRequest', () => new Promise((resolve, reject) => { let intervalId; @@ -607,8 +658,8 @@ function requestSync() { Promise.resolve() .then(() => { - if (isDataSyncPossible()) { - return sync(); + if (isWorkspaceSyncPossible()) { + return syncWorkspace(); } if (hasCurrentFileSyncLocations()) { // Only sync current file if data sync is unavailable. @@ -620,9 +671,9 @@ function requestSync() { }) .then(() => { // Clean files - Object.keys(fileHashesToClean).forEach((fileId) => { + Object.entries(fileHashesToClean).forEach(([fileId, fileHash]) => { const file = store.state.file.itemMap[fileId]; - if (file && file.hash === fileHashesToClean[fileId]) { + if (file && file.hash === fileHash) { store.dispatch('deleteFile', fileId); } }); @@ -635,26 +686,41 @@ function requestSync() { })); } -// Sync periodically -utils.setInterval(() => { - if (isSyncPossible() && - utils.isUserActive() && - isSyncWindow() && - isAutoSyncReady() - ) { - requestSync(); - } -}, 1000); - -// Unload contents from memory periodically -utils.setInterval(() => { - // Wait for sync and publish to finish - if (store.state.queue.isEmpty) { - localDbSvc.unloadContents(); - } -}, 5000); - export default { + init() { + // 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; + } + + return workspaceProvider.initWorkspace() + .then(workspace => store.commit('workspace/setCurrentWorkspaceId', workspace.id)) + .then(() => localDbSvc.init()) + .then(() => { + // Sync periodically + utils.setInterval(() => { + if (isSyncPossible() && + utils.isUserActive() && + isSyncWindow() && + isAutoSyncReady() + ) { + requestSync(); + } + }, 1000); + + // Unload contents from memory periodically + utils.setInterval(() => { + // Wait for sync and publish to finish + if (store.state.queue.isEmpty) { + localDbSvc.unloadContents(); + } + }, 5000); + }); + }, isSyncPossible, requestSync, createSyncLocation, diff --git a/src/services/utils.js b/src/services/utils.js index 3850624f..6975b0d9 100644 --- a/src/services/utils.js +++ b/src/services/utils.js @@ -31,7 +31,9 @@ const setLastFocus = () => { localStorage[lastFocusKey] = lastFocus; setLastActivity(); }; -setLastFocus(); +if (document.hasFocus()) { + setLastFocus(); +} window.addEventListener('focus', setLastFocus); // For parseQueryParams() @@ -64,6 +66,12 @@ export default { 'publishLocation', 'data', ], + localStorageDataIds: [ + 'workspaces', + 'settings', + 'layoutSettings', + 'tokens', + ], textMaxLength: 150000, sanitizeText(text) { const result = `${text || ''}`.slice(0, this.textMaxLength); @@ -148,10 +156,10 @@ export default { parseQueryParams, addQueryParams(url = '', params = {}) { const keys = Object.keys(params).filter(key => params[key] != null); - if (!keys.length) { - return url; - } urlParser.href = url; + if (!keys.length) { + return urlParser.href; + } if (urlParser.search) { urlParser.search += '&'; } else { @@ -180,8 +188,8 @@ export default { startOffset = 0; } const elt = document.createElement('span'); - Object.keys(eltProperties).forEach((key) => { - elt[key] = eltProperties[key]; + Object.entries(eltProperties).forEach(([key, value]) => { + elt[key] = value; }); treeWalker.currentNode.parentNode.insertBefore(elt, treeWalker.currentNode); elt.appendChild(treeWalker.currentNode); diff --git a/src/store/content.js b/src/store/content.js index 3775905e..626e6c97 100644 --- a/src/store/content.js +++ b/src/store/content.js @@ -81,8 +81,7 @@ module.actions = { const diffs = diffMatchPatch.diff_main( currentContent.text, revisionContent.originalText); diffMatchPatch.diff_cleanupSemantic(diffs); - Object.keys(currentContent.discussions).forEach((discussionId) => { - const discussion = currentContent.discussions[discussionId]; + Object.entries(currentContent.discussions).forEach(([, discussion]) => { const adjustOffset = (offsetName) => { const marker = new cledit.Marker(discussion[offsetName], offsetName === 'end'); marker.adjustOffset(diffs); diff --git a/src/store/data.js b/src/store/data.js index b4faed1e..ba9e7b58 100644 --- a/src/store/data.js +++ b/src/store/data.js @@ -1,9 +1,10 @@ import Vue from 'vue'; import yaml from 'js-yaml'; -import moduleTemplate from './moduleTemplate'; import utils from '../services/utils'; +import defaultWorkspaces from '../data/defaultWorkspaces'; import defaultSettings from '../data/defaultSettings.yml'; import defaultLocalSettings from '../data/defaultLocalSettings'; +import defaultLayoutSettings from '../data/defaultLayoutSettings'; import plainHtmlTemplate from '../data/plainHtmlTemplate.html'; import styledHtmlTemplate from '../data/styledHtmlTemplate.html'; import styledHtmlWithTocTemplate from '../data/styledHtmlWithTocTemplate.html'; @@ -13,36 +14,31 @@ const itemTemplate = (id, data = {}) => ({ id, type: 'data', data, hash: 0 }); const empty = (id) => { switch (id) { + case 'workspaces': + return itemTemplate(id, defaultWorkspaces()); case 'settings': return itemTemplate(id, '\n'); case 'localSettings': return itemTemplate(id, defaultLocalSettings()); + case 'layoutSettings': + return itemTemplate(id, defaultLayoutSettings()); default: return itemTemplate(id); } }; -const module = moduleTemplate(empty, true); -module.mutations.setItem = (state, value) => { - const emptyItem = empty(value.id); - const data = typeof value.data === 'object' - ? Object.assign(emptyItem.data, value.data) - : value.data; - const item = { - ...emptyItem, - data, - }; - item.hash = utils.hash(utils.serializeObject({ - ...item, - hash: undefined, - })); - Vue.set(state.itemMap, item.id, item); -}; +// Item IDs that will be stored in the localStorage +const lsItemIdSet = new Set(utils.localStorageDataIds); -const getter = id => state => (state.itemMap[id] || empty(id)).data; +// Getter/setter/patcher factories +const getter = id => state => ((lsItemIdSet.has(id) + ? state.lsItemMap + : state.itemMap)[id] || empty(id)).data; const setter = id => ({ commit }, data) => commit('setItem', itemTemplate(id, data)); const patcher = id => ({ state, commit }, data) => { - const item = Object.assign(empty(id), state.itemMap[id]); + const item = Object.assign(empty(id), (lsItemIdSet.has(id) + ? state.lsItemMap + : state.itemMap)[id]); commit('setItem', { ...empty(id), data: typeof data === 'object' ? { @@ -52,18 +48,10 @@ const patcher = id => ({ state, commit }, data) => { }); }; -// Local settings -module.getters.localSettings = getter('localSettings'); -module.actions.patchLocalSettings = patcher('localSettings'); -const localSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLocalSettings', { - [propertyName]: value === undefined ? !getters.localSettings[propertyName] : value, +// For layoutSettings +const layoutSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLayoutSettings', { + [propertyName]: value === undefined ? !getters.layoutSettings[propertyName] : value, }); -module.actions.toggleNavigationBar = localSettingsToggler('showNavigationBar'); -module.actions.toggleEditor = localSettingsToggler('showEditor'); -module.actions.toggleSidePreview = localSettingsToggler('showSidePreview'); -module.actions.toggleStatusBar = localSettingsToggler('showStatusBar'); -module.actions.toggleScrollSync = localSettingsToggler('scrollSync'); -module.actions.toggleFocusMode = localSettingsToggler('focusMode'); const notEnoughSpace = (getters) => { const constants = getters['layout/constants']; const showGutter = getters['discussion/currentDiscussion']; @@ -73,60 +61,8 @@ const notEnoughSpace = (getters) => { constants.buttonBarWidth + (showGutter ? constants.gutterWidth : 0); }; -module.actions.toggleSideBar = ({ commit, getters, dispatch, rootGetters }, value) => { - // Reset side bar - dispatch('setSideBarPanel'); - // Close explorer if not enough space - const patch = { - showSideBar: value === undefined ? !getters.localSettings.showSideBar : value, - }; - if (patch.showSideBar && notEnoughSpace(rootGetters)) { - patch.showExplorer = false; - } - dispatch('patchLocalSettings', patch); -}; -module.actions.toggleExplorer = ({ commit, getters, dispatch, rootGetters }, value) => { - // Close side bar if not enough space - const patch = { - showExplorer: value === undefined ? !getters.localSettings.showExplorer : value, - }; - if (patch.showExplorer && notEnoughSpace(rootGetters)) { - patch.showSideBar = false; - } - dispatch('patchLocalSettings', patch); -}; -module.actions.setSideBarPanel = ({ dispatch }, value) => dispatch('patchLocalSettings', { - sideBarPanel: value === undefined ? 'menu' : value, -}); -// Settings -module.getters.settings = getter('settings'); -module.getters.computedSettings = (state, getters) => { - const customSettings = yaml.safeLoad(getters.settings); - const settings = yaml.safeLoad(defaultSettings); - const override = (obj, opt) => { - const objType = Object.prototype.toString.call(obj); - const optType = Object.prototype.toString.call(opt); - if (objType !== optType) { - return obj; - } else if (objType !== '[object Object]') { - return opt; - } - Object.keys(obj).forEach((key) => { - if (key === 'shortcuts') { - obj[key] = Object.assign(obj[key], opt[key]); - } else { - obj[key] = override(obj[key], opt[key]); - } - }); - return obj; - }; - return override(settings, customSettings); -}; -module.actions.setSettings = setter('settings'); - -// Templates -module.getters.templates = getter('templates'); +// For templates const makeAdditionalTemplate = (name, value, helpers = '\n') => ({ name, value, @@ -140,100 +76,8 @@ const additionalTemplates = { styledHtmlWithToc: makeAdditionalTemplate('Styled HTML with TOC', styledHtmlWithTocTemplate), jekyllSite: makeAdditionalTemplate('Jekyll site', jekyllSiteTemplate), }; -module.getters.allTemplates = (state, getters) => ({ - ...getters.templates, - ...additionalTemplates, -}); -module.actions.setTemplates = ({ commit }, data) => { - const dataToCommit = { - ...data, - }; - // We don't store additional templates - Object.keys(additionalTemplates).forEach((id) => { - delete dataToCommit[id]; - }); - commit('setItem', itemTemplate('templates', dataToCommit)); -}; -// Last opened -module.getters.lastOpened = getter('lastOpened'); -module.getters.lastOpenedIds = (state, getters, rootState) => { - const lastOpened = { - ...getters.lastOpened, - }; - const currentFileId = rootState.file.currentId; - if (currentFileId && !lastOpened[currentFileId]) { - lastOpened[currentFileId] = Date.now(); - } - return Object.keys(lastOpened) - .filter(id => rootState.file.itemMap[id]) - .sort((id1, id2) => lastOpened[id2] - lastOpened[id1]) - .slice(0, 20); -}; -module.actions.setLastOpenedId = ({ getters, commit, dispatch, rootState }, fileId) => { - const lastOpened = { ...getters.lastOpened }; - lastOpened[fileId] = Date.now(); - commit('setItem', itemTemplate('lastOpened', lastOpened)); - dispatch('cleanLastOpenedId'); -}; -module.actions.cleanLastOpenedId = ({ getters, commit, rootState }) => { - const lastOpened = {}; - const oldLastOpened = getters.lastOpened; - Object.keys(oldLastOpened).forEach((fileId) => { - if (rootState.file.itemMap[fileId]) { - lastOpened[fileId] = oldLastOpened[fileId]; - } - }); - commit('setItem', itemTemplate('lastOpened', lastOpened)); -}; - -// Sync data -module.getters.syncData = getter('syncData'); -module.getters.syncDataByItemId = (state, getters) => { - const result = {}; - const syncData = getters.syncData; - Object.keys(syncData).forEach((id) => { - const value = syncData[id]; - result[value.itemId] = value; - }); - return result; -}; -module.getters.syncDataByType = (state, getters) => { - const result = {}; - utils.types.forEach((type) => { - result[type] = {}; - }); - const syncData = getters.syncData; - Object.keys(syncData).forEach((id) => { - const item = syncData[id]; - if (result[item.type]) { - result[item.type][item.itemId] = item; - } - }); - return result; -}; -module.actions.patchSyncData = patcher('syncData'); -module.actions.setSyncData = setter('syncData'); - -// Data sync data (used to sync settings and settings) -module.getters.dataSyncData = getter('dataSyncData'); -module.actions.patchDataSyncData = patcher('dataSyncData'); - -// Tokens -module.getters.tokens = getter('tokens'); -module.getters.googleTokens = (state, getters) => getters.tokens.google || {}; -module.getters.dropboxTokens = (state, getters) => getters.tokens.dropbox || {}; -module.getters.githubTokens = (state, getters) => getters.tokens.github || {}; -module.getters.wordpressTokens = (state, getters) => getters.tokens.wordpress || {}; -module.getters.zendeskTokens = (state, getters) => getters.tokens.zendesk || {}; -module.getters.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]]; -}; -module.actions.patchTokens = patcher('tokens'); +// For tokens const tokenSetter = providerId => ({ getters, dispatch }, token) => { dispatch('patchTokens', { [providerId]: { @@ -242,10 +86,213 @@ const tokenSetter = providerId => ({ getters, dispatch }, token) => { }, }); }; -module.actions.setGoogleToken = tokenSetter('google'); -module.actions.setDropboxToken = tokenSetter('dropbox'); -module.actions.setGithubToken = tokenSetter('github'); -module.actions.setWordpressToken = tokenSetter('wordpress'); -module.actions.setZendeskToken = tokenSetter('zendesk'); -export default module; +export default { + namespaced: true, + state: { + // Data items stored in the DB + itemMap: {}, + // Data items stored in the localStorage + lsItemMap: {}, + }, + mutations: { + setItem: (state, value) => { + // Create an empty item and override its data field + const emptyItem = empty(value.id); + const data = typeof value.data === 'object' + ? Object.assign(emptyItem.data, value.data) + : value.data; + const item = { + ...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); + }, + }, + getters: { + workspaces: (state) => { + const workspaces = (state.lsItemMap.workspaces || empty('workspaces')).data; + const result = {}; + Object.entries(workspaces).forEach(([id, workspace]) => { + result[id] = { + ...workspace, + id, + providerId: workspace.providerId || 'googleDriveWorkspace', + url: utils.addQueryParams('app'), + }; + }); + return result; + }, + dbName: (state, getters) => { + let dbName; + Object.keys(getters.workspaces).some((id) => { + dbName = 'stackedit-db'; + if (id !== 'main') { + dbName += `-${id}`; + } + return dbName; + }); + return dbName; + }, + settings: getter('settings'), + computedSettings: (state, getters) => { + const customSettings = yaml.safeLoad(getters.settings); + const settings = yaml.safeLoad(defaultSettings); + const override = (obj, opt) => { + const objType = Object.prototype.toString.call(obj); + const optType = Object.prototype.toString.call(opt); + if (objType !== optType) { + return obj; + } else if (objType !== '[object Object]') { + return opt; + } + Object.keys(obj).forEach((key) => { + if (key === 'shortcuts') { + obj[key] = Object.assign(obj[key], opt[key]); + } else { + obj[key] = override(obj[key], opt[key]); + } + }); + return obj; + }; + return override(settings, customSettings); + }, + localSettings: getter('localSettings'), + layoutSettings: getter('layoutSettings'), + templates: getter('templates'), + allTemplates: (state, getters) => ({ + ...getters.templates, + ...additionalTemplates, + }), + lastOpened: getter('lastOpened'), + lastOpenedIds: (state, getters, rootState) => { + const lastOpened = { + ...getters.lastOpened, + }; + const currentFileId = rootState.file.currentId; + if (currentFileId && !lastOpened[currentFileId]) { + lastOpened[currentFileId] = Date.now(); + } + return Object.keys(lastOpened) + .filter(id => rootState.file.itemMap[id]) + .sort((id1, id2) => lastOpened[id2] - lastOpened[id1]) + .slice(0, 20); + }, + syncData: getter('syncData'), + syncDataByItemId: (state, getters) => { + const result = {}; + Object.entries(getters.syncData).forEach(([, value]) => { + result[value.itemId] = value; + }); + return result; + }, + syncDataByType: (state, getters) => { + const result = {}; + utils.types.forEach((type) => { + result[type] = {}; + }); + Object.entries(getters.syncData).forEach(([, item]) => { + if (result[item.type]) { + result[item.type][item.itemId] = item; + } + }); + return result; + }, + dataSyncData: getter('dataSyncData'), + tokens: getter('tokens'), + googleTokens: (state, getters) => getters.tokens.google || {}, + dropboxTokens: (state, getters) => getters.tokens.dropbox || {}, + 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'), + patchWorkspaces: patcher('workspaces'), + setSettings: setter('settings'), + patchLocalSettings: patcher('localSettings'), + patchLayoutSettings: patcher('layoutSettings'), + toggleNavigationBar: layoutSettingsToggler('showNavigationBar'), + toggleEditor: layoutSettingsToggler('showEditor'), + toggleSidePreview: layoutSettingsToggler('showSidePreview'), + toggleStatusBar: layoutSettingsToggler('showStatusBar'), + toggleScrollSync: layoutSettingsToggler('scrollSync'), + toggleFocusMode: layoutSettingsToggler('focusMode'), + toggleSideBar: ({ commit, getters, dispatch, rootGetters }, value) => { + // Reset side bar + dispatch('setSideBarPanel'); + + // Close explorer if not enough space + const patch = { + showSideBar: value === undefined ? !getters.layoutSettings.showSideBar : value, + }; + if (patch.showSideBar && notEnoughSpace(rootGetters)) { + patch.showExplorer = false; + } + dispatch('patchLayoutSettings', patch); + }, + toggleExplorer: ({ commit, getters, dispatch, rootGetters }, value) => { + // Close side bar if not enough space + const patch = { + showExplorer: value === undefined ? !getters.layoutSettings.showExplorer : value, + }; + if (patch.showExplorer && notEnoughSpace(rootGetters)) { + patch.showSideBar = false; + } + dispatch('patchLayoutSettings', patch); + }, + setSideBarPanel: ({ dispatch }, value) => dispatch('patchLayoutSettings', { + sideBarPanel: value === undefined ? 'menu' : value, + }), + setTemplates: ({ commit }, data) => { + const dataToCommit = { + ...data, + }; + // We don't store additional templates + Object.keys(additionalTemplates).forEach((id) => { + delete dataToCommit[id]; + }); + commit('setItem', itemTemplate('templates', dataToCommit)); + }, + setLastOpenedId: ({ getters, commit, dispatch, rootState }, fileId) => { + const lastOpened = { ...getters.lastOpened }; + lastOpened[fileId] = Date.now(); + commit('setItem', itemTemplate('lastOpened', lastOpened)); + dispatch('cleanLastOpenedId'); + }, + cleanLastOpenedId: ({ getters, commit, rootState }) => { + const lastOpened = {}; + const oldLastOpened = getters.lastOpened; + Object.entries(oldLastOpened).forEach(([fileId, date]) => { + if (rootState.file.itemMap[fileId]) { + lastOpened[fileId] = date; + } + }); + commit('setItem', itemTemplate('lastOpened', lastOpened)); + }, + setSyncData: setter('syncData'), + patchSyncData: patcher('syncData'), + patchDataSyncData: patcher('dataSyncData'), + patchTokens: patcher('tokens'), + setGoogleToken: tokenSetter('google'), + setDropboxToken: tokenSetter('dropbox'), + setGithubToken: tokenSetter('github'), + setWordpressToken: tokenSetter('wordpress'), + setZendeskToken: tokenSetter('zendesk'), + }, +}; diff --git a/src/store/discussion.js b/src/store/discussion.js index 3745f5bf..2ce49b32 100644 --- a/src/store/discussion.js +++ b/src/store/discussion.js @@ -65,8 +65,7 @@ export default { const discussions = rootGetters['content/current'].discussions; const comments = rootGetters['content/current'].comments; const discussionLastComments = {}; - Object.keys(comments).forEach((commentId) => { - const comment = comments[commentId]; + Object.entries(comments).forEach(([, comment]) => { if (discussions[comment.discussionId]) { const lastComment = discussionLastComments[comment.discussionId]; if (!lastComment || lastComment.created < comment.created) { @@ -84,10 +83,10 @@ export default { } const discussions = rootGetters['content/current'].discussions; const discussionLastComments = getters.currentFileDiscussionLastComments; - Object.keys(discussionLastComments) - .sort((id1, id2) => - discussionLastComments[id2].created - discussionLastComments[id1].created) - .forEach((discussionId) => { + Object.entries(discussionLastComments) + .sort(([, lastComment1], [, lastComment2]) => + lastComment1.created - lastComment2.created) + .forEach(([discussionId]) => { currentFileDiscussions[discussionId] = discussions[discussionId]; }); return currentFileDiscussions; @@ -100,13 +99,13 @@ export default { const comments = {}; if (getters.currentDiscussion) { const contentComments = rootGetters['content/current'].comments; - Object.keys(contentComments) - .filter(commentId => - contentComments[commentId].discussionId === state.currentDiscussionId) - .sort((id1, id2) => - contentComments[id1].created - contentComments[id2].created) - .forEach((commentId) => { - comments[commentId] = contentComments[commentId]; + Object.entries(contentComments) + .filter(([, comment]) => + comment.discussionId === state.currentDiscussionId) + .sort(([, comment1], [, comment2]) => + comment1.created - comment2.created) + .forEach(([commentId, comment]) => { + comments[commentId] = comment; }); } return comments; @@ -126,10 +125,11 @@ export default { createNewDiscussion({ commit, dispatch, rootGetters }, selection) { const loginToken = rootGetters['data/loginToken']; if (!loginToken) { - dispatch('modal/signInForComment', null, { root: true }) - .then(() => googleHelper.signin()) - .then(() => syncSvc.requestSync()) - .then(() => dispatch('createNewDiscussion', selection)) + dispatch('modal/signInForComment', { + onResolve: () => googleHelper.signin() + .then(() => syncSvc.requestSync()) + .then(() => dispatch('createNewDiscussion', selection)), + }, { root: true }) .catch(() => { }); // Cancel } else if (selection) { let text = rootGetters['content/current'].text.slice(selection.start, selection.end).trim(); @@ -150,8 +150,7 @@ export default { discussions: {}, comments: {}, }; - Object.keys(comments).forEach((commentId) => { - const comment = comments[commentId]; + Object.entries(comments).forEach(([commentId, comment]) => { const discussion = discussions[comment.discussionId]; if (discussion && comment !== filterComment && discussion !== filterDiscussion) { patch.discussions[comment.discussionId] = discussion; diff --git a/src/store/explorer.js b/src/store/explorer.js index 3fd1e2b4..9e55dee2 100644 --- a/src/store/explorer.js +++ b/src/store/explorer.js @@ -87,8 +87,7 @@ export default { nodeMap[item.id] = new Node(item, locations); }); const rootNode = new Node(emptyFolder(), [], true, true); - Object.keys(nodeMap).forEach((id) => { - const node = nodeMap[id]; + Object.entries(nodeMap).forEach(([id, node]) => { let parentNode = nodeMap[node.item.parentId]; if (!parentNode || !parentNode.isFolder) { if (id === 'trash') { diff --git a/src/store/index.js b/src/store/index.js index 127468c5..316405c7 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -43,7 +43,6 @@ const store = new Vuex.Store({ userInfo, }, state: { - ready: false, offline: false, lastOfflineCheck: 0, minuteCounter: 0, @@ -61,9 +60,6 @@ const store = new Vuex.Store({ }, }, mutations: { - setReady: (state) => { - state.ready = true; - }, setOffline: (state, value) => { state.offline = value; }, diff --git a/src/store/layout.js b/src/store/layout.js index 7f220ad0..09e19850 100644 --- a/src/store/layout.js +++ b/src/store/layout.js @@ -20,14 +20,14 @@ const constants = { statusBarHeight: 20, }; -function computeStyles(state, getters, localSettings = getters['data/localSettings'], styles = { - showNavigationBar: !localSettings.showEditor || localSettings.showNavigationBar, - showStatusBar: localSettings.showStatusBar, - showEditor: localSettings.showEditor, - showSidePreview: localSettings.showSidePreview && localSettings.showEditor, - showPreview: localSettings.showSidePreview || !localSettings.showEditor, - showSideBar: localSettings.showSideBar, - showExplorer: localSettings.showExplorer, +function computeStyles(state, getters, layoutSettings = getters['data/layoutSettings'], styles = { + showNavigationBar: !layoutSettings.showEditor || layoutSettings.showNavigationBar, + showStatusBar: layoutSettings.showStatusBar, + showEditor: layoutSettings.showEditor, + showSidePreview: layoutSettings.showSidePreview && layoutSettings.showEditor, + showPreview: layoutSettings.showSidePreview || !layoutSettings.showEditor, + showSideBar: layoutSettings.showSideBar, + showExplorer: layoutSettings.showExplorer, layoutOverflow: false, }) { styles.innerHeight = state.layout.bodyHeight; @@ -64,7 +64,7 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin styles.showSidePreview = false; styles.showPreview = false; styles.layoutOverflow = false; - return computeStyles(state, getters, localSettings, styles); + return computeStyles(state, getters, layoutSettings, styles); } const computedSettings = getters['data/computedSettings']; @@ -96,7 +96,7 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin if (!styles.showSidePreview) { styles.previewWidth += constants.buttonBarWidth; } - styles.previewGutterWidth = showGutter && !localSettings.showEditor + styles.previewGutterWidth = showGutter && !layoutSettings.showEditor ? constants.gutterWidth : 0; const previewLeftPadding = previewRightPadding + styles.previewGutterWidth; @@ -107,7 +107,7 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin doublePanelWidth; const editorRightPadding = Math.max( Math.floor((styles.editorWidth - styles.textWidth) / 2), minPadding); - styles.editorGutterWidth = showGutter && localSettings.showEditor + styles.editorGutterWidth = showGutter && layoutSettings.showEditor ? constants.gutterWidth : 0; const editorLeftPadding = editorRightPadding + styles.editorGutterWidth; @@ -129,8 +129,8 @@ function computeStyles(state, getters, localSettings = getters['data/localSettin styles.hideLocations = true; } } - styles.titleMaxWidth = Math.min(styles.titleMaxWidth, maxTitleMaxWidth); - styles.titleMaxWidth = Math.max(styles.titleMaxWidth, minTitleMaxWidth); + styles.titleMaxWidth = Math.max(minTitleMaxWidth, + Math.min(maxTitleMaxWidth, styles.titleMaxWidth)); return styles; } @@ -162,8 +162,8 @@ export default { updateBodySize({ commit, dispatch, rootGetters }) { commit('updateBodySize'); // Make sure both explorer and side bar are not open if body width is small - const localSettings = rootGetters['data/localSettings']; - dispatch('data/toggleExplorer', localSettings.showExplorer, { root: true }); + const layoutSettings = rootGetters['data/layoutSettings']; + dispatch('data/toggleExplorer', layoutSettings.showExplorer, { root: true }); }, }, }; diff --git a/src/store/modal.js b/src/store/modal.js index 096b57c5..ed294fc6 100644 --- a/src/store/modal.js +++ b/src/store/modal.js @@ -86,24 +86,33 @@ export default { rejectText: 'Cancel', onResolve, }), - signInForSponsorship: ({ dispatch }) => dispatch('open', { + workspaceGoogleRedirection: ({ dispatch }, { onResolve }) => dispatch('open', { + content: '

You have to sign in with Google to access this workspace.

', + resolveText: 'Ok, sign in', + rejectText: 'Cancel', + onResolve, + }), + signInForSponsorship: ({ dispatch }, { onResolve }) => dispatch('open', { type: 'signInForSponsorship', content: `

You have to sign in with Google to enable your sponsorship.

`, resolveText: 'Ok, sign in', rejectText: 'Cancel', + onResolve, }), - signInForComment: ({ dispatch }) => dispatch('open', { + signInForComment: ({ dispatch }, { onResolve }) => dispatch('open', { content: `

You have to sign in with Google to start commenting.

`, resolveText: 'Ok, sign in', rejectText: 'Cancel', + onResolve, }), - signInForHistory: ({ dispatch }) => dispatch('open', { + signInForHistory: ({ dispatch }, { onResolve }) => dispatch('open', { content: `

You have to sign in with Google to enable revision history.

`, resolveText: 'Ok, sign in', rejectText: 'Cancel', + onResolve, }), sponsorOnly: ({ dispatch }) => dispatch('open', { content: '

This feature is restricted to sponsor users as it relies on server resources.

', diff --git a/src/store/moduleTemplate.js b/src/store/moduleTemplate.js index 22d8cc1d..8b69c899 100644 --- a/src/store/moduleTemplate.js +++ b/src/store/moduleTemplate.js @@ -33,7 +33,7 @@ export default (empty, simpleHash = false) => { itemMap: {}, }, getters: { - items: state => Object.keys(state.itemMap).map(key => state.itemMap[key]), + items: state => Object.entries(state.itemMap).map(([, item]) => item), }, mutations: { setItem, diff --git a/src/store/workspace.js b/src/store/workspace.js new file mode 100644 index 00000000..f5bf7dde --- /dev/null +++ b/src/store/workspace.js @@ -0,0 +1,29 @@ +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, + }, + mutations: { + setCurrentWorkspaceId: (state, value) => { + state.currentWorkspaceId = value; + }, + }, + getters: { + currentWorkspace: (state, getters, rootState, rootGetters) => { + const workspaces = rootGetters['data/workspaces']; + return workspaces[state.currentWorkspaceId] || workspaces.main; + }, + }, + actions: { + }, +};