diff --git a/package-lock.json b/package-lock.json index b0d751ed..2384fa89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14498,9 +14498,9 @@ } }, "vue": { - "version": "2.5.13", - "resolved": "https://registry.npmjs.org/vue/-/vue-2.5.13.tgz", - "integrity": "sha512-3D+lY7HTkKbtswDM4BBHgqyq+qo8IAEE8lz8va1dz3LLmttjgo0FxairO4r1iN2OBqk8o1FyL4hvzzTFEdQSEw==" + "version": "2.5.16", + "resolved": "https://registry.npmjs.org/vue/-/vue-2.5.16.tgz", + "integrity": "sha512-/ffmsiVuPC8PsWcFkZngdpas19ABm5mh2wA7iDqcltyCTwlgZjHGeJYOXkBMo422iPwIcviOtrTCUpSfXmToLQ==" }, "vue-hot-reload-api": { "version": "2.2.4", @@ -14588,9 +14588,9 @@ "dev": true }, "vuex": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/vuex/-/vuex-2.5.0.tgz", - "integrity": "sha512-5oJPOJySBgSgSzoeO+gZB/BbN/XsapgIF6tz34UwJqnGZMQurzIO3B4KIBf862gfc9ya+oduY5sSkq+5/oOilQ==" + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.0.1.tgz", + "integrity": "sha512-wLoqz0B7DSZtgbWL1ShIBBCjv22GV5U+vcBFox658g6V0s4wZV9P4YjCNyoHSyIBpj1f29JBoNQIqD82cR4O3w==" }, "w3c-hr-time": { "version": "1.0.1", diff --git a/package.json b/package.json index dcd24257..ee9f15ac 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,8 @@ "serve-static": "^1.12.6", "tmp": "^0.0.33", "turndown": "^4.0.1", - "vue": "^2.3.3", - "vuex": "^2.3.1" + "vue": "^2.5.16", + "vuex": "^3.0.1" }, "devDependencies": { "autoprefixer": "^6.7.2", diff --git a/src/components/Explorer.vue b/src/components/Explorer.vue index ec8ff0d5..f412f4c0 100644 --- a/src/components/Explorer.vue +++ b/src/components/Explorer.vue @@ -28,6 +28,7 @@ diff --git a/src/components/gutters/Comment.vue b/src/components/gutters/Comment.vue index 594020ce..df554595 100644 --- a/src/components/gutters/Comment.vue +++ b/src/components/gutters/Comment.vue @@ -51,7 +51,7 @@ export default { this.$store.dispatch('modal/commentDeletion') .then( () => this.$store.dispatch('discussion/cleanCurrentFile', { filterComment: this.comment }), - () => {}); // Cancel + () => { /* Cancel */ }); }, }, mounted() { diff --git a/src/components/gutters/CurrentDiscussion.vue b/src/components/gutters/CurrentDiscussion.vue index 8fa8df66..53671090 100644 --- a/src/components/gutters/CurrentDiscussion.vue +++ b/src/components/gutters/CurrentDiscussion.vue @@ -99,7 +99,7 @@ export default { () => this.$store.dispatch('discussion/cleanCurrentFile', { filterDiscussion: this.currentDiscussion, }), - () => {}); // Cancel + () => { /* Cancel */ }); }, }, }; diff --git a/src/components/gutters/NewComment.vue b/src/components/gutters/NewComment.vue index 127c7161..48e9887b 100644 --- a/src/components/gutters/NewComment.vue +++ b/src/components/gutters/NewComment.vue @@ -3,7 +3,7 @@
© 2013-2018 Dock5 Software
StackEdit on GitHub
diff --git a/src/components/modals/PandocExportModal.vue b/src/components/modals/PandocExportModal.vue index 068a1699..6c315c8b 100644 --- a/src/components/modals/PandocExportModal.vue +++ b/src/components/modals/PandocExportModal.vue @@ -70,7 +70,8 @@ export default modalTemplate({ if (err.status !== 401) { throw err; } - this.$store.dispatch('modal/sponsorOnly'); + this.$store.dispatch('modal/sponsorOnly') + .catch(() => { /* Cancel */ }); })) .catch((err) => { console.error(err); // eslint-disable-line no-console diff --git a/src/components/modals/PdfExportModal.vue b/src/components/modals/PdfExportModal.vue index 6d4b9380..e5188732 100644 --- a/src/components/modals/PdfExportModal.vue +++ b/src/components/modals/PdfExportModal.vue @@ -63,7 +63,8 @@ export default modalTemplate({ if (err.status !== 401) { throw err; } - this.$store.dispatch('modal/sponsorOnly'); + this.$store.dispatch('modal/sponsorOnly') + .catch(() => { /* Cancel */ }); })) .catch((err) => { console.error(err); // eslint-disable-line no-console diff --git a/src/components/modals/WorkspaceManagementModal.vue b/src/components/modals/WorkspaceManagementModal.vue index 494ca035..44106651 100644 --- a/src/components/modals/WorkspaceManagementModal.vue +++ b/src/components/modals/WorkspaceManagementModal.vue @@ -79,7 +79,7 @@ export default { return this.$store.dispatch('modal/removeWorkspace') .then( () => localDbSvc.removeWorkspace(id), - () => {}, // Cancel + () => { /* Cancel */ }, ); }, }, diff --git a/src/components/modals/common/ModalInner.vue b/src/components/modals/common/ModalInner.vue index bde7012b..82643793 100644 --- a/src/components/modals/common/ModalInner.vue +++ b/src/components/modals/common/ModalInner.vue @@ -42,7 +42,7 @@ export default { this.$store.dispatch('modal/open', 'sponsor'); } }) - .catch(() => {}); // Cancel + .catch(() => { /* Cancel */ }); }, }, }; diff --git a/src/services/backupSvc.js b/src/services/backupSvc.js index 0141b124..a2be99bc 100644 --- a/src/services/backupSvc.js +++ b/src/services/backupSvc.js @@ -1,9 +1,10 @@ -import store from '../store'; +import fileSvc from './fileSvc'; import utils from './utils'; export default { - importBackup(jsonValue) { - const nameMap = {}; + async importBackup(jsonValue) { + const fileNameMap = {}; + const folderNameMap = {}; const parentIdMap = {}; const textMap = {}; const propertiesMap = {}; @@ -22,24 +23,18 @@ export default { // StackEdit v4 format const [, v4Id, type] = v4Match; if (type === 'title') { - nameMap[v4Id] = value; + fileNameMap[v4Id] = value; } else if (type === 'content') { textMap[v4Id] = value; } } else if (value.type === 'folder') { // StackEdit v5 folder - const folderId = utils.uid(); - const name = utils.sanitizeName(value.name); - const parentId = `${value.parentId || ''}` || null; - store.commit('folder/setItem', { - id: folderId, - name, - parentId, - }); - folderIdMap[id] = folderId; + folderIdMap[id] = utils.uid(); + folderNameMap[id] = value.name; + parentIdMap[id] = `${value.parentId || ''}`; } else if (value.type === 'file') { // StackEdit v5 file - nameMap[id] = utils.sanitizeName(value.name); + fileNameMap[id] = value.name; parentIdMap[id] = `${value.parentId || ''}`; } else if (value.type === 'content') { // StackEdit v5 content @@ -54,14 +49,20 @@ export default { } }); - // Go through the maps - Object.entries(nameMap).forEach(([externalId, name]) => store.dispatch('createFile', { - name, + await utils.awaitSequence(Object.keys(folderNameMap), async externalId => fileSvc.storeItem({ + id: folderIdMap[externalId], + type: 'folder', + name: folderNameMap[externalId], + parentId: folderIdMap[parentIdMap[externalId]], + }, true)); + + await utils.awaitSequence(Object.keys(fileNameMap), async externalId => fileSvc.createFile({ + name: fileNameMap[externalId], parentId: folderIdMap[parentIdMap[externalId]], text: textMap[externalId], properties: propertiesMap[externalId], discussions: discussionsMap[externalId], comments: commentsMap[externalId], - })); + }, true)); }, }; diff --git a/src/services/explorerSvc.js b/src/services/explorerSvc.js new file mode 100644 index 00000000..b89963b2 --- /dev/null +++ b/src/services/explorerSvc.js @@ -0,0 +1,85 @@ +import store from '../store'; +import fileSvc from './fileSvc'; + +export default { + newItem(isFolder = false) { + let parentId = store.getters['explorer/selectedNodeFolder'].item.id; + if (parentId === 'trash' // Not allowed to create new items in the trash + || (isFolder && parentId === 'temp') // Not allowed to create new folders in the temp folder + ) { + parentId = null; + } + store.dispatch('explorer/openNode', parentId); + store.commit('explorer/setNewItem', { + type: isFolder ? 'folder' : 'file', + parentId, + }); + }, + deleteItem() { + const selectedNode = store.getters['explorer/selectedNode']; + if (selectedNode.isNil) { + return Promise.resolve(); + } + if (selectedNode.isTrash || selectedNode.item.parentId === 'trash') { + return store.dispatch('modal/trashDeletion').catch(() => { /* Cancel */ }); + } + + // See if we have a dialog to show + let modalAction; + let moveToTrash = true; + if (selectedNode.isTemp) { + modalAction = 'modal/tempFolderDeletion'; + moveToTrash = false; + } else if (selectedNode.item.parentId === 'temp') { + modalAction = 'modal/tempFileDeletion'; + moveToTrash = false; + } else if (selectedNode.isFolder) { + modalAction = 'modal/folderDeletion'; + } + + return (modalAction + ? store.dispatch(modalAction, selectedNode.item) + : Promise.resolve()) + .then(() => { + const deleteFile = (id) => { + if (moveToTrash) { + store.commit('file/patchItem', { + id, + parentId: 'trash', + }); + } else { + fileSvc.deleteFile(id); + } + }; + + if (selectedNode === store.getters['explorer/selectedNode']) { + const currentFileId = store.getters['file/current'].id; + let doClose = selectedNode.item.id === currentFileId; + if (selectedNode.isFolder) { + const recursiveDelete = (folderNode) => { + folderNode.folders.forEach(recursiveDelete); + folderNode.files.forEach((fileNode) => { + doClose = doClose || fileNode.item.id === currentFileId; + deleteFile(fileNode.item.id); + }); + store.commit('folder/deleteItem', folderNode.item.id); + }; + recursiveDelete(selectedNode); + } else { + deleteFile(selectedNode.item.id); + } + if (doClose) { + // Close the current file by opening the last opened, not deleted one + store.getters['data/lastOpenedIds'].some((id) => { + const file = store.state.file.itemMap[id]; + if (file.parentId === 'trash') { + return false; + } + store.commit('file/setCurrentId', id); + return true; + }); + } + } + }, () => { /* Cancel */ }); + }, +}; diff --git a/src/services/fileSvc.js b/src/services/fileSvc.js new file mode 100644 index 00000000..20a30471 --- /dev/null +++ b/src/services/fileSvc.js @@ -0,0 +1,162 @@ +import store from '../store'; +import utils from './utils'; + +const forbiddenFolderNameMatcher = /^\.stackedit-data$|^\.stackedit-trash$|\.md$|\.sync$|\.publish$/; + +export default { + /** + * Create a file in the store with the specified fields. + */ + createFile(fields = {}, background = false) { + const id = utils.uid(); + const file = { + id, + name: utils.sanitizeName(fields.name), + parentId: fields.parentId || null, + }; + const content = { + id: `${id}/content`, + text: utils.sanitizeText(fields.text || store.getters['data/computedSettings'].newFileContent), + properties: utils.sanitizeText( + fields.properties || store.getters['data/computedSettings'].newFileProperties), + discussions: fields.discussions || {}, + comments: fields.comments || {}, + }; + const nameStripped = file.name !== utils.defaultName && file.name !== fields.name; + + // Check if there is a path conflict + const workspaceUniquePaths = store.getters['workspace/hasUniquePaths']; + let pathConflict; + if (workspaceUniquePaths) { + const parentPath = store.getters.itemPaths[file.parentId] || ''; + const path = parentPath + file.name; + pathConflict = !!store.getters.pathItems[path]; + } + + // Show warning dialogs and then save in the store + return Promise.resolve() + .then(() => !background && nameStripped && store.dispatch('modal/stripName', fields.name)) + .then(() => !background && pathConflict && store.dispatch('modal/pathConflict', fields.name)) + .then(() => { + store.commit('content/setItem', content); + store.commit('file/setItem', file); + if (workspaceUniquePaths) { + this.makePathUnique(id); + } + return store.state.file.itemMap[id]; + }); + }, + + /** + * Make sanity checks and then create/update the folder/file in the store. + */ + async storeItem(item, background = false) { + const id = item.id || utils.uid(); + const sanitizedName = utils.sanitizeName(item.name); + + if (item.type === 'folder' && forbiddenFolderNameMatcher.exec(sanitizedName)) { + if (background) { + return null; + } + await store.dispatch('modal/unauthorizedName', item.name); + throw new Error('Unauthorized name.'); + } + + const workspaceUniquePaths = store.getters['workspace/hasUniquePaths']; + + // Show warning dialogs + if (!background) { + // If name has been stripped + if (sanitizedName !== utils.defaultName && sanitizedName !== item.name) { + await store.dispatch('modal/stripName', item.name); + } + // Check if there is a path conflict + if (workspaceUniquePaths) { + const parentPath = store.getters.itemPaths[item.parentId] || ''; + const path = parentPath + sanitizedName; + const pathItems = store.getters.pathItems[path] || []; + if (pathItems.some(itemWithSamePath => itemWithSamePath.id !== id)) { + await store.dispatch('modal/pathConflict', item.name); + } + } + } + + // Save item in the store + store.commit(`${item.type}/setItem`, { + id, + parentId: item.parentId || null, + name: sanitizedName, + }); + + // Ensure path uniqueness + if (workspaceUniquePaths) { + this.makePathUnique(id); + } + return store.getters.allItemMap[id]; + }, + + /** + * Delete a file in the store and all its related items. + */ + deleteFile(fileId) { + // Delete the file + store.commit('file/deleteItem', fileId); + // Delete the content + store.commit('content/deleteItem', `${fileId}/content`); + // Delete the syncedContent + store.commit('syncedContent/deleteItem', `${fileId}/syncedContent`); + // Delete the contentState + store.commit('contentState/deleteItem', `${fileId}/contentState`); + // Delete sync locations + (store.getters['syncLocation/groupedByFileId'][fileId] || []) + .forEach(item => store.commit('syncLocation/deleteItem', item.id)); + // Delete publish locations + (store.getters['publishLocation/groupedByFileId'][fileId] || []) + .forEach(item => store.commit('publishLocation/deleteItem', item.id)); + }, + + /** + * Ensure two files/folders don't have the same path if the workspace doesn't support it. + */ + ensureUniquePaths() { + if (store.getters['workspace/hasUniquePaths']) { + if (Object.keys(store.getters.itemPaths).some(id => this.makePathUnique(id))) { + this.ensureUniquePaths(); + } + } + }, + + /** + * Return false if the file/folder path is unique. + * Add a prefix to its name and return true otherwise. + */ + makePathUnique(id) { + const item = store.getters.allItemMap[id]; + if (!item) { + return false; + } + let path = store.getters.itemPaths[id]; + const pathItems = store.getters.pathItems; + if (pathItems[path].length === 1) { + return false; + } + const isFolder = item.type === 'folder'; + if (isFolder) { + // Remove trailing slash + path = path.slice(0, -1); + } + for (let suffix = 1; ; suffix += 1) { + let pathWithPrefix = `${path}.${suffix}`; + if (isFolder) { + pathWithPrefix += '/'; + } + if (!pathItems[pathWithPrefix]) { + store.commit(`${item.type}/patchItem`, { + id: item.id, + name: `${item.name}.${suffix}`, + }); + return true; + } + } + }, +}; diff --git a/src/services/localDbSvc.js b/src/services/localDbSvc.js index 36af63b5..453c13da 100644 --- a/src/services/localDbSvc.js +++ b/src/services/localDbSvc.js @@ -2,6 +2,7 @@ import FileSaver from 'file-saver'; import utils from './utils'; import store from '../store'; import welcomeFile from '../data/welcomeFile.md'; +import fileSvc from './fileSvc'; const dbVersion = 1; const dbStoreName = 'objects'; @@ -186,6 +187,7 @@ const localDbSvc = { dbStore.delete(item.id); } }); + fileSvc.ensureUniquePaths(); this.lastTx = lastTx; cb(storeItemMap); } @@ -249,20 +251,20 @@ const localDbSvc = { * Read and apply one DB change. */ readDbItem(dbItem, storeItemMap) { - const existingStoreItem = storeItemMap[dbItem.id]; + const storeItem = storeItemMap[dbItem.id]; if (!dbItem.hash) { // DB item is a delete marker delete this.hashMap[dbItem.type][dbItem.id]; - if (existingStoreItem) { + if (storeItem) { // Remove item from the store - store.commit(`${existingStoreItem.type}/deleteItem`, existingStoreItem.id); - delete storeItemMap[existingStoreItem.id]; + store.commit(`${storeItem.type}/deleteItem`, storeItem.id); + delete storeItemMap[storeItem.id]; } } else if (this.hashMap[dbItem.type][dbItem.id] !== dbItem.hash) { // DB item is different from the corresponding store item this.hashMap[dbItem.type][dbItem.id] = dbItem.hash; // Update content only if it exists in the store - if (existingStoreItem || !contentTypes[dbItem.type] || exportWorkspace) { + if (storeItem || !contentTypes[dbItem.type] || exportWorkspace) { // Put item in the store dbItem.tx = undefined; store.commit(`${dbItem.type}/setItem`, dbItem); @@ -403,13 +405,14 @@ const localDbSvc = { // Clean files store.getters['file/items'] .filter(file => file.parentId === 'trash') // If file is in the trash - .forEach(file => store.dispatch('deleteFile', file.id)); + .forEach(file => fileSvc.deleteFile(file.id)); } // Enable sponsorship if (utils.queryParams.paymentSuccess) { location.hash = ''; // PaymentSuccess param is always on its own - store.dispatch('modal/paymentSuccess'); + store.dispatch('modal/paymentSuccess') + .catch(() => { /* Cancel */ }); const sponsorToken = store.getters['workspace/sponsorToken']; // Force check sponsorship after a few seconds const currentDate = Date.now(); @@ -438,10 +441,10 @@ const localDbSvc = { store.commit('file/setCurrentId', recentFile.id); } else { // If still no ID, create a new file - store.dispatch('createFile', { + fileSvc.createFile({ name: 'Welcome file', text: welcomeFile, - }) + }, true) // Set it as the current file .then(newFile => store.commit('file/setCurrentId', newFile.id)); } diff --git a/src/services/providers/common/Provider.js b/src/services/providers/common/Provider.js index 13c32a1c..1dab5605 100644 --- a/src/services/providers/common/Provider.js +++ b/src/services/providers/common/Provider.js @@ -83,7 +83,9 @@ export default class Provider { parentId: null, }); } + return true; } } + return false; } } diff --git a/src/services/providers/dropboxProvider.js b/src/services/providers/dropboxProvider.js index a95acbe7..f386fddd 100644 --- a/src/services/providers/dropboxProvider.js +++ b/src/services/providers/dropboxProvider.js @@ -2,6 +2,7 @@ import store from '../../store'; import dropboxHelper from './helpers/dropboxHelper'; import Provider from './common/Provider'; import utils from '../utils'; +import fileSvc from '../fileSvc'; const makePathAbsolute = (token, path) => { if (!token.fullAccess) { @@ -88,12 +89,6 @@ export default new Provider({ }; return this.downloadContent(token, syncLocation) .then((content) => { - const id = utils.uid(); - delete content.history; - store.commit('content/setItem', { - ...content, - id: `${id}/content`, - }); let name = path; const slashPos = name.lastIndexOf('/'); if (slashPos > -1 && slashPos < name.length - 1) { @@ -103,25 +98,30 @@ export default new Provider({ if (dotPos > 0 && slashPos < name.length) { name = name.slice(0, dotPos); } - store.commit('file/setItem', { - id, - name: utils.sanitizeName(name), + return fileSvc.createFile({ + name, parentId: store.getters['file/current'].parentId, - }); + text: content.text, + properties: content.properties, + discussions: content.discussions, + comments: content.comments, + }, true); + }) + .then((item) => { + store.commit('file/setCurrentId', item.id); store.commit('syncLocation/setItem', { ...syncLocation, id: utils.uid(), - fileId: id, + fileId: item.id, }); - store.commit('file/setCurrentId', id); store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Dropbox.`); - }, () => { + }) + .catch(() => { store.dispatch('notification/error', `Could not open file ${path}.`); }) .then(() => openOneFile()); }; - return Promise.resolve() - .then(() => openOneFile()); + return Promise.resolve(openOneFile()); }, makeLocation(token, path) { return { diff --git a/src/services/providers/githubProvider.js b/src/services/providers/githubProvider.js index 7777abba..5552abef 100644 --- a/src/services/providers/githubProvider.js +++ b/src/services/providers/githubProvider.js @@ -2,6 +2,7 @@ import store from '../../store'; import githubHelper from './helpers/githubHelper'; import Provider from './common/Provider'; import utils from '../utils'; +import fileSvc from '../fileSvc'; const savedSha = {}; @@ -75,12 +76,6 @@ export default new Provider({ // Download content from GitHub and create the file return this.downloadContent(token, syncLocation) .then((content) => { - const id = utils.uid(); - delete content.history; - store.commit('content/setItem', { - ...content, - id: `${id}/content`, - }); let name = syncLocation.path; const slashPos = name.lastIndexOf('/'); if (slashPos > -1 && slashPos < name.length - 1) { @@ -90,19 +85,25 @@ export default new Provider({ if (dotPos > 0 && slashPos < name.length) { name = name.slice(0, dotPos); } - store.commit('file/setItem', { - id, - name: utils.sanitizeName(name), + return fileSvc.createFile({ + name, parentId: store.getters['file/current'].parentId, - }); + text: content.text, + properties: content.properties, + discussions: content.discussions, + comments: content.comments, + }, true); + }) + .then((item) => { + store.commit('file/setCurrentId', item.id); store.commit('syncLocation/setItem', { ...syncLocation, id: utils.uid(), - fileId: id, + fileId: item.id, }); - store.commit('file/setCurrentId', id); store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from GitHub.`); - }, () => { + }) + .catch(() => { store.dispatch('notification/error', `Could not open file ${syncLocation.path}.`); }); }); diff --git a/src/services/providers/githubWorkspaceProvider.js b/src/services/providers/githubWorkspaceProvider.js index 38ad18e9..13967254 100644 --- a/src/services/providers/githubWorkspaceProvider.js +++ b/src/services/providers/githubWorkspaceProvider.js @@ -479,12 +479,13 @@ export default new Provider({ } else if (entry.committer && entry.committer.login) { user = entry.committer; } - userSvc.addInfo({ id: user.login, name: user.login, imageUrl: user.avatar_url }); + const sub = `gh:${user.id}`; + userSvc.addInfo({ id: sub, name: user.login, imageUrl: user.avatar_url }); const date = (entry.commit.author && entry.commit.author.date) || (entry.commit.committer && entry.commit.committer.date); return { id: entry.sha, - sub: user.login, + sub, created: date ? new Date(date).getTime() : 1, }; }) diff --git a/src/services/providers/googleDriveAppDataProvider.js b/src/services/providers/googleDriveAppDataProvider.js index fca5b53d..7c9498bc 100644 --- a/src/services/providers/googleDriveAppDataProvider.js +++ b/src/services/providers/googleDriveAppDataProvider.js @@ -136,7 +136,7 @@ export default new Provider({ return googleHelper.getAppDataFileRevisions(token, syncData.id) .then(revisions => revisions.map(revision => ({ id: revision.id, - sub: revision.lastModifyingUser && revision.lastModifyingUser.permissionId, + sub: revision.lastModifyingUser && `go:${revision.lastModifyingUser.permissionId}`, created: new Date(revision.modifiedTime).getTime(), })) .sort((revision1, revision2) => revision2.created - revision1.created)); diff --git a/src/services/providers/googleDriveProvider.js b/src/services/providers/googleDriveProvider.js index e0fc988a..3f9a4893 100644 --- a/src/services/providers/googleDriveProvider.js +++ b/src/services/providers/googleDriveProvider.js @@ -2,6 +2,7 @@ import store from '../../store'; import googleHelper from './helpers/googleHelper'; import Provider from './common/Provider'; import utils from '../utils'; +import fileSvc from '../fileSvc'; export default new Provider({ id: 'googleDrive', @@ -93,7 +94,7 @@ export default new Provider({ const token = store.getters['data/googleTokens'][state.userId]; switch (token && state.action) { case 'create': - return store.dispatch('createFile') + return fileSvc.createFile({}, true) .then((file) => { store.commit('file/setCurrentId', file.id); // Return a new syncLocation @@ -169,32 +170,29 @@ export default new Provider({ sub: token.sub, }; return this.downloadContent(token, syncLocation) - .then((content) => { - const id = utils.uid(); - delete content.history; - store.commit('content/setItem', { - ...content, - id: `${id}/content`, - }); - store.commit('file/setItem', { - id, - name: utils.sanitizeName(driveFile.name), - parentId: store.getters['file/current'].parentId, - }); + .then(content => fileSvc.createFile({ + name, + parentId: store.getters['file/current'].parentId, + text: content.text, + properties: content.properties, + discussions: content.discussions, + comments: content.comments, + }, true)) + .then((item) => { + store.commit('file/setCurrentId', item.id); store.commit('syncLocation/setItem', { ...syncLocation, id: utils.uid(), - fileId: id, + fileId: item.id, }); - store.commit('file/setCurrentId', id); store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Google Drive.`); - }, () => { + }) + .catch(() => { store.dispatch('notification/error', `Could not open file ${driveFile.id}.`); }) .then(() => openOneFile()); }; - return Promise.resolve() - .then(() => openOneFile()); + return Promise.resolve(openOneFile()); }, makeLocation(token, fileId, folderId) { const location = { diff --git a/src/services/providers/googleDriveWorkspaceProvider.js b/src/services/providers/googleDriveWorkspaceProvider.js index 6b56b00b..a1696ac9 100644 --- a/src/services/providers/googleDriveWorkspaceProvider.js +++ b/src/services/providers/googleDriveWorkspaceProvider.js @@ -2,6 +2,7 @@ import store from '../../store'; import googleHelper from './helpers/googleHelper'; import Provider from './common/Provider'; import utils from '../utils'; +import fileSvc from '../fileSvc'; const getSyncData = (fileId) => { const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`]; @@ -195,9 +196,9 @@ export default new Provider({ [syncData.id]: syncData, }); } - return store.dispatch('createFile', { + return fileSvc.createFile({ parentId: syncData && syncData.itemId, - }) + }, true) .then((file) => { store.commit('file/setCurrentId', file.id); // File will be created on next workspace sync diff --git a/src/services/providers/helpers/couchdbHelper.js b/src/services/providers/helpers/couchdbHelper.js index 50ea4e81..a681f418 100644 --- a/src/services/providers/helpers/couchdbHelper.js +++ b/src/services/providers/helpers/couchdbHelper.js @@ -91,9 +91,9 @@ export default { method: 'POST', body: { item, time: Date.now() }, }; - const loginToken = store.getters['workspace/loginToken']; - if (loginToken) { - options.body.sub = loginToken.sub; + const userId = store.getters['workspace/userId']; + if (userId) { + options.body.sub = userId; } if (documentId) { options.method = 'PUT'; diff --git a/src/services/providers/helpers/githubHelper.js b/src/services/providers/helpers/githubHelper.js index 4ec99841..1423fc19 100644 --- a/src/services/providers/helpers/githubHelper.js +++ b/src/services/providers/helpers/githubHelper.js @@ -73,6 +73,22 @@ export default { addAccount(repoFullAccess = false) { return this.startOauth2(getScopes({ repoFullAccess })); }, + getUser(userId) { + return networkSvc.request({ + url: `https://api.github.com/user/${userId}`, + params: { + t: Date.now(), // Prevent from caching + }, + }) + .then((res) => { + store.commit('userInfo/addItem', { + id: `gh:${res.body.id}`, + name: res.body.login, + imageUrl: res.body.avatar_url || '', + }); + return res.body; + }); + }, getTree(token, owner, repo, sha) { return repoRequest(token, owner, repo, { url: `git/trees/${encodeURIComponent(sha)}?recursive=1`, @@ -86,9 +102,9 @@ export default { }, getHeadTree(token, owner, repo, branch) { return repoRequest(token, owner, repo, { - url: `branches/${encodeURIComponent(branch)}`, + url: `commits/${encodeURIComponent(branch)}`, }) - .then(res => this.getTree(token, owner, repo, res.body.commit.commit.tree.sha)); + .then(res => this.getTree(token, owner, repo, res.body.commit.tree.sha)); }, getCommits(token, owner, repo, sha, path) { return repoRequest(token, owner, repo, { diff --git a/src/services/providers/helpers/googleHelper.js b/src/services/providers/helpers/googleHelper.js index 4748eb66..f579fb6a 100644 --- a/src/services/providers/helpers/googleHelper.js +++ b/src/services/providers/helpers/googleHelper.js @@ -203,7 +203,7 @@ export default { }, true) .then((res) => { store.commit('userInfo/addItem', { - id: res.body.id, + id: `go:${res.body.id}`, name: res.body.displayName, imageUrl: (res.body.image.url || '').replace(/\bsz?=\d+$/, 'sz=40'), }); diff --git a/src/services/syncSvc.js b/src/services/syncSvc.js index 15462c97..e37aae35 100644 --- a/src/services/syncSvc.js +++ b/src/services/syncSvc.js @@ -9,6 +9,7 @@ import './providers/couchdbWorkspaceProvider'; import './providers/githubWorkspaceProvider'; import './providers/googleDriveWorkspaceProvider'; import tempFileSvc from './tempFileSvc'; +import fileSvc from './fileSvc'; const minAutoSyncEvery = 60 * 1000; // 60 sec const inactivityThreshold = 3 * 1000; // 3 sec @@ -746,7 +747,7 @@ function requestSync() { Object.entries(fileHashesToClean).forEach(([fileId, fileHash]) => { const file = store.state.file.itemMap[fileId]; if (file && file.hash === fileHash) { - store.dispatch('deleteFile', fileId); + fileSvc.deleteFile(fileId); } }); }) diff --git a/src/services/tempFileSvc.js b/src/services/tempFileSvc.js index e24e075f..d2ea5098 100644 --- a/src/services/tempFileSvc.js +++ b/src/services/tempFileSvc.js @@ -2,6 +2,7 @@ import cledit from './cledit'; import store from '../store'; import utils from './utils'; import editorSvc from './editorSvc'; +import fileSvc from './fileSvc'; const origin = utils.queryParams.origin; const fileName = utils.queryParams.fileName; @@ -29,12 +30,12 @@ export default { store.commit('setLight', true); - return store.dispatch('createFile', { + return fileSvc.createFile({ name: fileName || utils.getHostname(origin), text: contentText || '\n', properties: contentProperties, parentId: 'temp', - }) + }, true) .then((file) => { const fileItemMap = store.state.file.itemMap; @@ -57,7 +58,7 @@ export default { .splice(10) .forEach(([id]) => { delete lastCreated[id]; - store.dispatch('deleteFile', id); + fileSvc.deleteFile(id); }); // Store file creations and open the file diff --git a/src/services/userSvc.js b/src/services/userSvc.js index d0566d48..d9f41a24 100644 --- a/src/services/userSvc.js +++ b/src/services/userSvc.js @@ -1,8 +1,16 @@ import googleHelper from './providers/helpers/googleHelper'; +import githubHelper from './providers/helpers/githubHelper'; +import utils from './utils'; import store from '../store'; const promised = {}; +const parseUserId = (userId) => { + const prefix = userId[2] === ':' && userId.slice(0, 2); + const type = prefix && utils.userIdPrefixes[prefix]; + return type ? [type, userId.slice(3)] : ['google', userId]; +}; + export default { addInfo({ id, name, imageUrl }) { promised[id] = true; @@ -10,8 +18,10 @@ export default { }, getInfo(userId) { if (!promised[userId]) { + const [type, sub] = parseUserId(userId); + // Try to find a token with this sub - const token = store.getters['data/googleTokens'][userId]; + const token = store.getters[`data/${type}Tokens`][sub]; if (token) { store.commit('userInfo/addItem', { id: userId, @@ -19,16 +29,31 @@ export default { }); } - // Get user info from Google + // Get user info from provider if (!store.state.offline) { promised[userId] = true; - googleHelper.getUser(userId) - .catch((err) => { - if (err.status !== 404) { - promised[userId] = false; - } - }); + switch (type) { + case 'github': { + return githubHelper.getUser(sub) + .catch((err) => { + if (err.status !== 404) { + promised[userId] = false; + } + }); + } + case 'google': + default: { + return googleHelper.getUser(sub) + .catch((err) => { + if (err.status !== 404) { + promised[userId] = false; + } + }); + } + } } } + + return null; }, }; diff --git a/src/services/utils.js b/src/services/utils.js index 7e405fec..7ae94411 100644 --- a/src/services/utils.js +++ b/src/services/utils.js @@ -23,6 +23,7 @@ const parseQueryParams = (params) => { return result; }; + // For utils.computeProperties() const deepOverride = (obj, opt) => { if (obj === undefined) { @@ -96,14 +97,23 @@ export default { 'layoutSettings', 'tokens', ], + userIdPrefixes: { + go: 'google', + gh: 'github', + }, textMaxLength: 250000, sanitizeText(text) { const result = `${text || ''}`.slice(0, this.textMaxLength); // last char must be a `\n`. return `${result}\n`.replace(/\n\n$/, '\n'); }, + defaultName: 'Untitled', sanitizeName(name) { - return `${name || ''}`.slice(0, 250) || 'Untitled'; + return `${name || ''}` + // Replace `/`, control characters and other kind of spaces with a space + .replace(/[/\x00-\x1F\x7f-\xa0\s]+/g, ' ').trim() + // Keep only 250 characters + .slice(0, 250) || this.defaultName; }, deepCopy, serializeObject(obj) { @@ -124,9 +134,8 @@ export default { // If every field fits the criteria if (Object.entries(criteria).every(([key, value]) => value === item[key])) { result = item; - return true; } - return false; + return result; }); return result; }, @@ -199,6 +208,18 @@ export default { setInterval(func, interval) { return setInterval(() => func(), this.randomize(interval)); }, + async awaitSequence(values, asyncFunc) { + const results = []; + const valuesLeft = values.slice().reverse(); + const runWithNextValue = async () => { + if (!valuesLeft.length) { + return results; + } + results.push(await asyncFunc(valuesLeft.pop())); + return runWithNextValue(); + }; + return runWithNextValue(); + }, parseQueryParams, addQueryParams(url = '', params = {}, hash = false) { const keys = Object.keys(params).filter(key => params[key] != null); @@ -239,9 +260,6 @@ export default { } return result; }, - concatPaths(...paths) { - return paths.join('/').replace(/\/+/g, '/'); - }, getHostname(url) { urlParser.href = url; return urlParser.hostname; diff --git a/src/store/discussion.js b/src/store/discussion.js index 063613f1..da9d06d2 100644 --- a/src/store/discussion.js +++ b/src/store/discussion.js @@ -130,7 +130,7 @@ export default { .then(() => syncSvc.requestSync()) .then(() => dispatch('createNewDiscussion', selection)), }, { root: true }) - .catch(() => { }); // Cancel + .catch(() => { /* Cancel */ }); } else if (selection) { let text = rootGetters['content/current'].text.slice(selection.start, selection.end).trim(); const maxLength = 80; diff --git a/src/store/explorer.js b/src/store/explorer.js index ea2066ef..651aabb5 100644 --- a/src/store/explorer.js +++ b/src/store/explorer.js @@ -190,85 +190,5 @@ export default { commit('setDragTargetId', id); dispatch('openDragTarget'); }, - newItem({ getters, commit, dispatch }, isFolder) { - let parentId = getters.selectedNodeFolder.item.id; - if (parentId === 'trash' // Not allowed to create new items in the trash - || (isFolder && parentId === 'temp') // Not allowed to create new folders in the temp folder - ) { - parentId = null; - } - dispatch('openNode', parentId); - commit('setNewItem', { - type: isFolder ? 'folder' : 'file', - parentId, - }); - }, - deleteItem({ rootState, getters, rootGetters, commit, dispatch }) { - const selectedNode = getters.selectedNode; - if (selectedNode.isNil) { - return Promise.resolve(); - } - if (selectedNode.isTrash || selectedNode.item.parentId === 'trash') { - return dispatch('modal/trashDeletion', null, { root: true }); - } - - // See if we have a dialog to show - let modalAction; - let moveToTrash = true; - if (selectedNode.isTemp) { - modalAction = 'modal/tempFolderDeletion'; - moveToTrash = false; - } else if (selectedNode.item.parentId === 'temp') { - modalAction = 'modal/tempFileDeletion'; - moveToTrash = false; - } else if (selectedNode.isFolder) { - modalAction = 'modal/folderDeletion'; - } - - return (modalAction - ? dispatch(modalAction, selectedNode.item, { root: true }) - : Promise.resolve()) - .then(() => { - const deleteFile = (id) => { - if (moveToTrash) { - commit('file/patchItem', { - id, - parentId: 'trash', - }, { root: true }); - } else { - dispatch('deleteFile', id, { root: true }); - } - }; - - if (selectedNode === getters.selectedNode) { - const currentFileId = rootGetters['file/current'].id; - let doClose = selectedNode.item.id === currentFileId; - if (selectedNode.isFolder) { - const recursiveDelete = (folderNode) => { - folderNode.folders.forEach(recursiveDelete); - folderNode.files.forEach((fileNode) => { - doClose = doClose || fileNode.item.id === currentFileId; - deleteFile(fileNode.item.id); - }); - commit('folder/deleteItem', folderNode.item.id, { root: true }); - }; - recursiveDelete(selectedNode); - } else { - deleteFile(selectedNode.item.id); - } - if (doClose) { - // Close the current file by opening the last opened, not deleted one - rootGetters['data/lastOpenedIds'].some((id) => { - const file = rootState.file.itemMap[id]; - if (file.parentId === 'trash') { - return false; - } - commit('file/setCurrentId', id, { root: true }); - return true; - }); - } - } - }, () => {}); // Cancel - }, }, }; diff --git a/src/store/index.js b/src/store/index.js index 9bb536b2..52221774 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -62,6 +62,7 @@ const store = new Vuex.Store({ }, itemPaths: (state) => { const result = {}; + const folderMap = state.folder.itemMap; const getPath = (item) => { let itemPath = result[item.id]; if (!itemPath) { @@ -69,29 +70,31 @@ const store = new Vuex.Store({ itemPath = `.stackedit-trash/${item.name}`; } else { let name = item.name; - if (item.type === 'folder') { + if (folderMap[item.id]) { name += '/'; } - const parent = state.folder.itemMap[item.parentId]; - if (!parent) { - itemPath = name; + const parentFolder = folderMap[item.parentId]; + if (parentFolder) { + itemPath = getPath(parentFolder) + name; } else { - itemPath = getPath(parent) + name; + itemPath = name; } } } result[item.id] = itemPath; return itemPath; }; + [...state.folder.items, ...state.file.items].forEach(item => getPath(item)); return result; }, pathItems: (state, getters) => { const result = {}; - const itemPaths = getters.itemPaths; const allItemMap = getters.allItemMap; - Object.entries(itemPaths).forEach(([id, path]) => { - result[path] = allItemMap[id]; + Object.entries(getters.itemPaths).forEach(([id, path]) => { + const items = result[path] || []; + items.push(allItemMap[id]); + result[path] = items; }); return result; }, @@ -131,33 +134,6 @@ const store = new Vuex.Store({ } return Promise.resolve(); }, - createFile({ state, getters, commit }, desc = {}) { - const id = utils.uid(); - commit('content/setItem', { - id: `${id}/content`, - text: utils.sanitizeText(desc.text || getters['data/computedSettings'].newFileContent), - properties: utils.sanitizeText( - desc.properties || getters['data/computedSettings'].newFileProperties), - discussions: desc.discussions || {}, - comments: desc.comments || {}, - }); - commit('file/setItem', { - id, - name: utils.sanitizeName(desc.name), - parentId: desc.parentId || null, - }); - return Promise.resolve(state.file.itemMap[id]); - }, - deleteFile({ getters, commit }, fileId) { - (getters['syncLocation/groupedByFileId'][fileId] || []) - .forEach(item => commit('syncLocation/deleteItem', item.id)); - (getters['publishLocation/groupedByFileId'][fileId] || []) - .forEach(item => commit('publishLocation/deleteItem', item.id)); - commit('file/deleteItem', fileId); - commit('content/deleteItem', `${fileId}/content`); - commit('syncedContent/deleteItem', `${fileId}/syncedContent`); - commit('contentState/deleteItem', `${fileId}/contentState`); - }, }, strict: debug, plugins: debug ? [createLogger()] : [], diff --git a/src/store/modal.js b/src/store/modal.js index ffcf989e..9814225f 100644 --- a/src/store/modal.js +++ b/src/store/modal.js @@ -74,13 +74,27 @@ export default { }), trashDeletion: ({ dispatch }) => dispatch('open', { content: '
Files in the trash are automatically deleted after 7 days of inactivity.
', - resolveText: 'Ok', + rejectText: 'Ok', }), fileRestoration: ({ dispatch }) => dispatch('open', { content: 'You are about to revert some changes. Are you sure?
', resolveText: 'Yes, revert', rejectText: 'No', }), + unauthorizedName: ({ dispatch }, name) => dispatch('open', { + content: `${name} is not an authorized name.
`, + rejectText: 'Ok', + }), + stripName: ({ dispatch }, name) => dispatch('open', { + content: `${name} contains illegal characters. Do you want to strip them?
`, + resolveText: 'Yes, strip', + rejectText: 'No', + }), + pathConflict: ({ dispatch }, name) => dispatch('open', { + content: `${name} already exists. Do you want to add a suffix?
`, + resolveText: 'Yes, add suffix', + rejectText: 'No', + }), removeWorkspace: ({ dispatch }) => dispatch('open', { content: 'You are about to remove a workspace locally. Are you sure?
', resolveText: 'Yes, remove', @@ -127,11 +141,11 @@ export default { }), sponsorOnly: ({ dispatch }) => dispatch('open', { content: 'This feature is restricted to sponsors as it relies on server resources.
', - resolveText: 'Ok, I understand', + rejectText: 'Ok, I understand', }), paymentSuccess: ({ dispatch }) => dispatch('open', { content: 'Thank you for your payment! Your sponsorship will be active in a minute.
', - resolveText: 'Ok', + rejectText: 'Ok', }), }, }; diff --git a/src/store/moduleTemplate.js b/src/store/moduleTemplate.js index 68524c03..b82cdcc2 100644 --- a/src/store/moduleTemplate.js +++ b/src/store/moduleTemplate.js @@ -11,7 +11,10 @@ export default (empty, simpleHash = false) => { itemMap: {}, }, getters: { - items: state => Object.entries(state.itemMap).map(([, item]) => item), + items: (state) => { + console.log(state.itemMap); + return Object.values(state.itemMap); + }, }, mutations: { setItem(state, value) { diff --git a/src/store/workspace.js b/src/store/workspace.js index 4d647b7f..8be204fd 100644 --- a/src/store/workspace.js +++ b/src/store/workspace.js @@ -1,3 +1,5 @@ +import utils from '../services/utils'; + export default { namespaced: true, state: { @@ -21,6 +23,10 @@ export default { const workspaces = rootGetters['data/sanitizedWorkspaces']; return workspaces[state.currentWorkspaceId] || getters.mainWorkspace; }, + hasUniquePaths: (state, getters) => { + const workspace = getters.currentWorkspace; + return workspace.providerId === 'githubWorkspace'; + }, lastSyncActivityKey: (state, getters) => `${getters.currentWorkspace.id}/lastSyncActivity`, lastFocusKey: (state, getters) => `${getters.currentWorkspace.id}/lastWindowFocus`, mainWorkspaceToken: (state, getters, rootState, rootGetters) => { @@ -55,10 +61,28 @@ export default { const googleTokens = rootGetters['data/googleTokens']; return googleTokens[workspace.sub]; } + case 'githubWorkspace': { + const githubTokens = rootGetters['data/githubTokens']; + return githubTokens[workspace.sub]; + } default: return getters.mainWorkspaceToken; } }, + userId: (state, getters, rootState, rootGetters) => { + const loginToken = getters.loginToken; + if (!loginToken) { + return null; + } + let prefix; + Object.entries(utils.userIdPrefixes).some(([key, value]) => { + if (rootGetters[`data/${value}Tokens`][loginToken.sub]) { + prefix = key; + } + return prefix; + }); + return prefix ? `${prefix}:${loginToken.sub}` : loginToken.sub; + }, sponsorToken: (state, getters) => getters.mainWorkspaceToken, }, actions: {