Added tests

This commit is contained in:
Benoit Schweblin 2018-06-07 23:56:11 +00:00
parent 597c747b00
commit e05e7717eb
61 changed files with 5068 additions and 718 deletions

View File

@ -8,7 +8,7 @@
"env": { "env": {
"test": { "test": {
"presets": ["env", "stage-2"], "presets": ["env", "stage-2"],
"plugins": [ "istanbul" ] "plugins": ["transform-es2015-modules-commonjs", "dynamic-import-node"]
} }
} }
} }

3
.gitignore vendored
View File

@ -3,8 +3,7 @@ node_modules/
dist/ dist/
.history .history
npm-debug.log* npm-debug.log*
yarn-debug.log*
yarn-error.log*
.vscode .vscode
stackedit_v4 stackedit_v4
chrome-app/*.zip chrome-app/*.zip
/test/unit/coverage/

3415
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,9 @@
"build": "node build/build.js && npm run build-style", "build": "node build/build.js && npm run build-style",
"build-style": "webpack --config build/webpack.style.conf.js", "build-style": "webpack --config build/webpack.style.conf.js",
"lint": "eslint --ext .js,.vue src server", "lint": "eslint --ext .js,.vue src server",
"test": "npm run lint", "unit": "jest --config test/unit/jest.conf.js --runInBand",
"unit-with-coverage": "jest --config test/unit/jest.conf.js --runInBand --coverage",
"test": "npm run lint && npm run unit",
"preversion": "npm run test", "preversion": "npm run test",
"postversion": "git push origin master --tags && npm publish", "postversion": "git push origin master --tags && npm publish",
"patch": "npm version patch -m \"Tag v%s\"", "patch": "npm version patch -m \"Tag v%s\"",
@ -22,7 +24,9 @@
"major": "npm version major -m \"Tag v%s\"" "major": "npm version major -m \"Tag v%s\""
}, },
"dependencies": { "dependencies": {
"@vue/test-utils": "^1.0.0-beta.16",
"aws-sdk": "^2.133.0", "aws-sdk": "^2.133.0",
"babel-runtime": "^6.26.0",
"bezier-easing": "^1.1.0", "bezier-easing": "^1.1.0",
"body-parser": "^1.18.2", "body-parser": "^1.18.2",
"clipboard": "^1.7.1", "clipboard": "^1.7.1",
@ -59,8 +63,11 @@
"autoprefixer": "^6.7.2", "autoprefixer": "^6.7.2",
"babel-core": "^6.26.3", "babel-core": "^6.26.3",
"babel-eslint": "^8.2.3", "babel-eslint": "^8.2.3",
"babel-jest": "^21.0.2",
"babel-loader": "^7.1.4", "babel-loader": "^7.1.4",
"babel-plugin-transform-runtime": "^6.22.0", "babel-plugin-dynamic-import-node": "^1.2.0",
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.0",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-polyfill": "^6.23.0", "babel-polyfill": "^6.23.0",
"babel-preset-env": "^1.3.2", "babel-preset-env": "^1.3.2",
"babel-preset-stage-2": "^6.22.0", "babel-preset-stage-2": "^6.22.0",
@ -86,7 +93,11 @@
"gulp-concat": "^2.6.1", "gulp-concat": "^2.6.1",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
"http-proxy-middleware": "^0.18.0", "http-proxy-middleware": "^0.18.0",
"identity-obj-proxy": "^3.0.0",
"ignore-loader": "^0.1.2", "ignore-loader": "^0.1.2",
"jest": "^23.0.0",
"jest-raw-loader": "^1.0.1",
"jest-serializer-vue": "^0.3.0",
"node-sass": "^4.9.0", "node-sass": "^4.9.0",
"npm-bump": "^0.0.23", "npm-bump": "^0.0.23",
"offline-plugin": "^5.0.3", "offline-plugin": "^5.0.3",
@ -103,6 +114,7 @@
"stylelint-processor-html": "^1.0.0", "stylelint-processor-html": "^1.0.0",
"stylelint-webpack-plugin": "^0.10.4", "stylelint-webpack-plugin": "^0.10.4",
"url-loader": "^1.0.1", "url-loader": "^1.0.1",
"vue-jest": "^1.0.2",
"vue-loader": "^15.0.9", "vue-loader": "^15.0.9",
"vue-style-loader": "^4.1.0", "vue-style-loader": "^4.1.0",
"vue-template-compiler": "^2.5.16", "vue-template-compiler": "^2.5.16",
@ -114,12 +126,12 @@
"worker-loader": "^1.1.1" "worker-loader": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">= 4.0.0", "node": ">= 8.0.0",
"npm": ">= 3.0.0" "npm": ">= 5.0.0"
}, },
"browserslist": [ "browserslist": [
"> 1%", "> 1%",
"last 2 versions", "last 2 versions",
"not ie <= 8" "not ie <= 10"
] ]
} }

View File

@ -2,14 +2,13 @@
<div class="app" :class="classes"> <div class="app" :class="classes">
<splash-screen v-if="!ready"></splash-screen> <splash-screen v-if="!ready"></splash-screen>
<layout v-else></layout> <layout v-else></layout>
<modal v-if="showModal"></modal> <modal></modal>
<notification></notification> <notification></notification>
<context-menu></context-menu> <context-menu></context-menu>
</div> </div>
</template> </template>
<script> <script>
import Vue from 'vue';
import '../styles'; import '../styles';
import '../styles/markdownHighlighting.scss'; import '../styles/markdownHighlighting.scss';
import '../styles/app.scss'; import '../styles/app.scss';
@ -22,50 +21,7 @@ import syncSvc from '../services/syncSvc';
import networkSvc from '../services/networkSvc'; import networkSvc from '../services/networkSvc';
import sponsorSvc from '../services/sponsorSvc'; import sponsorSvc from '../services/sponsorSvc';
import tempFileSvc from '../services/tempFileSvc'; import tempFileSvc from '../services/tempFileSvc';
import timeSvc from '../services/timeSvc'; import './common/globals';
import store from '../store';
// Global directives
Vue.directive('focus', {
inserted(el) {
el.focus();
const { value } = el;
if (value && el.setSelectionRange) {
el.setSelectionRange(0, value.length);
}
},
});
const setVisible = (el, value) => {
el.style.display = value ? '' : 'none';
if (value) {
el.removeAttribute('aria-hidden');
} else {
el.setAttribute('aria-hidden', 'true');
}
};
Vue.directive('show', {
bind(el, { value }) {
setVisible(el, value);
},
update(el, { value, oldValue }) {
if (value !== oldValue) {
setVisible(el, value);
}
},
});
Vue.directive('title', {
bind(el, { value }) {
el.title = value;
el.setAttribute('aria-label', value);
},
});
// Global filters
Vue.filter('formatTime', time =>
// Access the minute counter for reactive refresh
timeSvc.format(time, store.state.minuteCounter));
const themeClasses = { const themeClasses = {
light: ['app--light'], light: ['app--light'],
@ -88,9 +44,6 @@ export default {
const result = themeClasses[this.$store.getters['data/computedSettings'].colorTheme]; const result = themeClasses[this.$store.getters['data/computedSettings'].colorTheme];
return Array.isArray(result) ? result : themeClasses.light; return Array.isArray(result) ? result : themeClasses.light;
}, },
showModal() {
return !!this.$store.getters['modal/config'];
},
}, },
async created() { async created() {
try { try {

View File

@ -1,24 +1,24 @@
<template> <template>
<div class="button-bar"> <div class="button-bar">
<div class="button-bar__inner button-bar__inner--top"> <div class="button-bar__inner button-bar__inner--top">
<button class="button-bar__button button" :class="{ 'button-bar__button--on': layoutSettings.showNavigationBar }" v-if="!light" @click="toggleNavigationBar()" v-title="'Toggle navigation bar'"> <button class="button-bar__button button-bar__button--navigation-bar-toggler button" :class="{ 'button-bar__button--on': layoutSettings.showNavigationBar }" v-if="!light" @click="toggleNavigationBar()" v-title="'Toggle navigation bar'">
<icon-navigation-bar></icon-navigation-bar> <icon-navigation-bar></icon-navigation-bar>
</button> </button>
<button class="button-bar__button button" :class="{ 'button-bar__button--on': layoutSettings.showSidePreview }" tour-step-anchor="editor" @click="toggleSidePreview()" v-title="'Toggle side preview'"> <button class="button-bar__button button-bar__button--side-preview-toggler button" :class="{ 'button-bar__button--on': layoutSettings.showSidePreview }" tour-step-anchor="editor" @click="toggleSidePreview()" v-title="'Toggle side preview'">
<icon-side-preview></icon-side-preview> <icon-side-preview></icon-side-preview>
</button> </button>
<button class="button-bar__button button" @click="toggleEditor(false)" v-title="'Reader mode'"> <button class="button-bar__button button-bar__button--editor-toggler button" @click="toggleEditor(false)" v-title="'Reader mode'">
<icon-eye></icon-eye> <icon-eye></icon-eye>
</button> </button>
</div> </div>
<div class="button-bar__inner button-bar__inner--bottom"> <div class="button-bar__inner button-bar__inner--bottom">
<button class="button-bar__button button" :class="{ 'button-bar__button--on': layoutSettings.focusMode }" @click="toggleFocusMode()" v-title="'Toggle focus mode'"> <button class="button-bar__button button-bar__button--focus-mode-toggler button" :class="{ 'button-bar__button--on': layoutSettings.focusMode }" @click="toggleFocusMode()" v-title="'Toggle focus mode'">
<icon-target></icon-target> <icon-target></icon-target>
</button> </button>
<button class="button-bar__button button" :class="{ 'button-bar__button--on': layoutSettings.scrollSync }" @click="toggleScrollSync()" v-title="'Toggle scroll sync'"> <button class="button-bar__button button-bar__button--scroll-sync-toggler button" :class="{ 'button-bar__button--on': layoutSettings.scrollSync }" @click="toggleScrollSync()" v-title="'Toggle scroll sync'">
<icon-scroll-sync></icon-scroll-sync> <icon-scroll-sync></icon-scroll-sync>
</button> </button>
<button class="button-bar__button button" :class="{ 'button-bar__button--on': layoutSettings.showStatusBar }" @click="toggleStatusBar()" v-title="'Toggle status bar'"> <button class="button-bar__button button-bar__button--status-bar-toggler button" :class="{ 'button-bar__button--on': layoutSettings.showStatusBar }" @click="toggleStatusBar()" v-title="'Toggle status bar'">
<icon-status-bar></icon-status-bar> <icon-status-bar></icon-status-bar>
</button> </button>
</div> </div>

View File

@ -4,7 +4,7 @@
<div v-for="(item, idx) in items" :key="idx"> <div v-for="(item, idx) in items" :key="idx">
<div class="context-menu__separator" v-if="item.type === 'separator'"></div> <div class="context-menu__separator" v-if="item.type === 'separator'"></div>
<div class="context-menu__item context-menu__item--disabled" v-else-if="item.disabled">{{item.name}}</div> <div class="context-menu__item context-menu__item--disabled" v-else-if="item.disabled">{{item.name}}</div>
<a class="context-menu__item" href="javascript:void(0)" v-else @click.stop="close(item)">{{item.name}}</a> <a class="context-menu__item" href="javascript:void(0)" v-else @click="close(item)">{{item.name}}</a>
</div> </div>
</div> </div>
</div> </div>
@ -22,10 +22,8 @@ export default {
]), ]),
}, },
methods: { methods: {
close(item) { close(item = null) {
if (item) {
this.resolve(item); this.resolve(item);
}
this.$store.dispatch('contextMenu/close'); this.$store.dispatch('contextMenu/close');
}, },
}, },

View File

@ -2,20 +2,20 @@
<div class="explorer flex flex--column"> <div class="explorer flex flex--column">
<div class="side-title flex flex--row flex--space-between"> <div class="side-title flex flex--row flex--space-between">
<div class="flex flex--row"> <div class="flex flex--row">
<button class="side-title__button button" @click="newItem()" v-title="'New file'"> <button class="side-title__button side-title__button--new-file button" @click="newItem()" v-title="'New file'">
<icon-file-plus></icon-file-plus> <icon-file-plus></icon-file-plus>
</button> </button>
<button class="side-title__button button" @click="newItem(true)" v-title="'New folder'"> <button class="side-title__button side-title__button--new-folder button" @click="newItem(true)" v-title="'New folder'">
<icon-folder-plus></icon-folder-plus> <icon-folder-plus></icon-folder-plus>
</button> </button>
<button class="side-title__button button" @click="deleteItem()" v-title="'Delete'"> <button class="side-title__button side-title__button--delete button" @click="deleteItem()" v-title="'Delete'">
<icon-delete></icon-delete> <icon-delete></icon-delete>
</button> </button>
<button class="side-title__button button" @click="editItem()" v-title="'Rename'"> <button class="side-title__button side-title__button--rename button" @click="editItem()" v-title="'Rename'">
<icon-pen></icon-pen> <icon-pen></icon-pen>
</button> </button>
</div> </div>
<button class="side-title__button button" @click="toggleExplorer(false)" v-title="'Close explorer'"> <button class="side-title__button side-title__button--close button" @click="toggleExplorer(false)" v-title="'Close explorer'">
<icon-close></icon-close> <icon-close></icon-close>
</button> </button>
</div> </div>

View File

@ -181,8 +181,10 @@ export default {
perform: () => explorerSvc.deleteItem(), perform: () => explorerSvc.deleteItem(),
}], }],
}); });
if (item) {
item.perform(); item.perform();
} }
}
}, },
}, },
}; };

View File

@ -1,11 +1,11 @@
<template> <template>
<div class="modal" @keydown.esc="onEscape" @keydown.tab="onTab"> <div class="modal" v-if="config" @keydown.esc="onEscape" @keydown.tab="onTab">
<component v-if="currentModalComponent" :is="currentModalComponent"></component> <component v-if="currentModalComponent" :is="currentModalComponent"></component>
<modal-inner v-else aria-label="Dialog"> <modal-inner v-else aria-label="Dialog">
<div class="modal__content" v-html="config.content"></div> <div class="modal__content" v-html="simpleModal.contentHtml(config)"></div>
<div class="modal__button-bar"> <div class="modal__button-bar">
<button class="button" v-if="config.rejectText" @click="config.reject()">{{config.rejectText}}</button> <button class="button" v-if="simpleModal.rejectText" @click="config.reject()">{{simpleModal.rejectText}}</button>
<button class="button" v-if="config.resolveText" @click="config.resolve()">{{config.resolveText}}</button> <button class="button" v-if="simpleModal.resolveText" @click="config.resolve()">{{simpleModal.resolveText}}</button>
</div> </div>
</modal-inner> </modal-inner>
</div> </div>
@ -13,6 +13,7 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import simpleModals from '../data/simpleModals';
import editorSvc from '../services/editorSvc'; import editorSvc from '../services/editorSvc';
import ModalInner from './modals/common/ModalInner'; import ModalInner from './modals/common/ModalInner';
import FilePropertiesModal from './modals/FilePropertiesModal'; import FilePropertiesModal from './modals/FilePropertiesModal';
@ -112,6 +113,9 @@ export default {
} }
return null; return null;
}, },
simpleModal() {
return simpleModals[this.config.type] || {};
},
}, },
methods: { methods: {
onEscape() { onEscape() {
@ -153,14 +157,22 @@ export default {
}, },
}, },
mounted() { mounted() {
this.$watch(
() => this.config,
() => {
if (this.$el) {
window.addEventListener('focusin', this.onFocusInOut); window.addEventListener('focusin', this.onFocusInOut);
window.addEventListener('focusout', this.onFocusInOut); window.addEventListener('focusout', this.onFocusInOut);
const tabbables = getTabbables(this.$el); const tabbables = getTabbables(this.$el);
if (tabbables[0]) {
tabbables[0].focus(); tabbables[0].focus();
}, }
destroyed() { } else {
window.removeEventListener('focusin', this.onFocusInOut); window.removeEventListener('focusin', this.onFocusInOut);
window.removeEventListener('focusout', this.onFocusInOut); window.removeEventListener('focusout', this.onFocusInOut);
}
},
);
}, },
}; };
</script> </script>

View File

@ -3,7 +3,7 @@
<!-- Explorer --> <!-- Explorer -->
<div class="navigation-bar__inner navigation-bar__inner--left navigation-bar__inner--button"> <div class="navigation-bar__inner navigation-bar__inner--left navigation-bar__inner--button">
<button class="navigation-bar__button button" v-if="light" @click="close()" v-title="'Close StackEdit'"><icon-close-circle></icon-close-circle></button> <button class="navigation-bar__button button" v-if="light" @click="close()" v-title="'Close StackEdit'"><icon-close-circle></icon-close-circle></button>
<button class="navigation-bar__button button" v-else tour-step-anchor="explorer" @click="toggleExplorer()" v-title="'Toggle explorer'"><icon-folder></icon-folder></button> <button class="navigation-bar__button navigation-bar__button--explorer-toggler button" v-else tour-step-anchor="explorer" @click="toggleExplorer()" v-title="'Toggle explorer'"><icon-folder></icon-folder></button>
</div> </div>
<!-- Side bar --> <!-- Side bar -->
<div class="navigation-bar__inner navigation-bar__inner--right navigation-bar__inner--button"> <div class="navigation-bar__inner navigation-bar__inner--right navigation-bar__inner--button">
@ -64,9 +64,7 @@ const mod = /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'Meta' : 'Ctrl';
const getShortcut = (method) => { const getShortcut = (method) => {
let result = ''; let result = '';
Object.entries(store.getters['data/computedSettings'].shortcuts).some(([keys, shortcut]) => { Object.entries(store.getters['data/computedSettings'].shortcuts).some(([keys, shortcut]) => {
if (`${shortcut.method || shortcut}` !== method) { if (`${shortcut.method || shortcut}` === method) {
return false;
}
result = keys.split('+').map(key => key.toLowerCase()).map((key) => { result = keys.split('+').map(key => key.toLowerCase()).map((key) => {
if (key === 'mod') { if (key === 'mod') {
return mod; return mod;
@ -74,7 +72,8 @@ const getShortcut = (method) => {
// Capitalize // Capitalize
return key && `${key[0].toUpperCase()}${key.slice(1)}`; return key && `${key[0].toUpperCase()}${key.slice(1)}`;
}).join('+'); }).join('+');
return true; }
return result;
}); });
return result && ` ${result}`; return result && ` ${result}`;
}; };

View File

@ -1,17 +0,0 @@
<template>
<span class="provider-name">{{name}}</span>
</template>
<script>
export default {
props: ['providerId'],
computed: {
name() {
switch (this.userId) {
default:
return 'Google Drive';
}
},
},
};
</script>

View File

@ -0,0 +1,46 @@
import Vue from 'vue';
import timeSvc from '../../services/timeSvc';
import store from '../../store';
// Global directives
Vue.directive('focus', {
inserted(el) {
el.focus();
const { value } = el;
if (value && el.setSelectionRange) {
el.setSelectionRange(0, value.length);
}
},
});
const setVisible = (el, value) => {
el.style.display = value ? '' : 'none';
if (value) {
el.removeAttribute('aria-hidden');
} else {
el.setAttribute('aria-hidden', 'true');
}
};
Vue.directive('show', {
bind(el, { value }) {
setVisible(el, value);
},
update(el, { value, oldValue }) {
if (value !== oldValue) {
setVisible(el, value);
}
},
});
Vue.directive('title', {
bind(el, { value }) {
el.title = value;
el.setAttribute('aria-label', value);
},
});
// Global filters
Vue.filter('formatTime', time =>
// Access the minute counter for reactive refresh
timeSvc.format(time, store.state.minuteCounter));

View File

@ -49,7 +49,7 @@ export default {
]), ]),
async removeComment() { async removeComment() {
try { try {
await this.$store.dispatch('modal/commentDeletion'); await this.$store.dispatch('modal/open', 'commentDeletion');
this.$store.dispatch('discussion/cleanCurrentFile', { filterComment: this.comment }); this.$store.dispatch('discussion/cleanCurrentFile', { filterComment: this.comment });
} catch (e) { } catch (e) {
// Cancel // Cancel

View File

@ -95,7 +95,7 @@ export default {
}, },
async removeDiscussion() { async removeDiscussion() {
try { try {
await this.$store.dispatch('modal/discussionDeletion'); await this.$store.dispatch('modal/open', 'discussionDeletion');
this.$store.dispatch('discussion/cleanCurrentFile', { this.$store.dispatch('discussion/cleanCurrentFile', {
filterDiscussion: this.currentDiscussion, filterDiscussion: this.currentDiscussion,
}); });

View File

@ -96,7 +96,7 @@ export default {
}, },
async reset() { async reset() {
try { try {
await this.$store.dispatch('modal/reset'); await this.$store.dispatch('modal/open', 'reset');
window.location.href = '#reset=true'; window.location.href = '#reset=true';
window.location.reload(); window.location.reload();
} catch (e) { } catch (e) {

View File

@ -68,7 +68,7 @@ export default modalTemplate({
FileSaver.saveAs(body, `${currentFile.name}.${selectedFormat}`); FileSaver.saveAs(body, `${currentFile.name}.${selectedFormat}`);
} catch (err) { } catch (err) {
if (err.status === 401) { if (err.status === 401) {
this.$store.dispatch('modal/sponsorOnly'); this.$store.dispatch('modal/open', 'sponsorOnly');
} else { } else {
console.error(err); // eslint-disable-line no-console console.error(err); // eslint-disable-line no-console
this.$store.dispatch('notification/error', err); this.$store.dispatch('notification/error', err);

View File

@ -65,7 +65,7 @@ export default modalTemplate({
FileSaver.saveAs(body, `${currentFile.name}.pdf`); FileSaver.saveAs(body, `${currentFile.name}.pdf`);
} catch (err) { } catch (err) {
if (err.status === 401) { if (err.status === 401) {
this.$store.dispatch('modal/sponsorOnly'); this.$store.dispatch('modal/open', 'sponsorOnly');
} else { } else {
console.error(err); // eslint-disable-line no-console console.error(err); // eslint-disable-line no-console
this.$store.dispatch('notification/error', err); this.$store.dispatch('notification/error', err);

View File

@ -77,7 +77,7 @@ export default {
}, },
async remove(id) { async remove(id) {
try { try {
await this.$store.dispatch('modal/removeWorkspace'); await this.$store.dispatch('modal/open', 'removeWorkspace');
localDbSvc.removeWorkspace(id); localDbSvc.removeWorkspace(id);
} catch (e) { } catch (e) {
// Cancel // Cancel

View File

@ -33,7 +33,7 @@ export default {
try { try {
if (!this.$store.getters['workspace/sponsorToken']) { if (!this.$store.getters['workspace/sponsorToken']) {
// User has to sign in // User has to sign in
await this.$store.dispatch('modal/signInForSponsorship'); await this.$store.dispatch('modal/open', 'signInForSponsorship');
await googleHelper.signin(); await googleHelper.signin();
syncSvc.requestSync(); syncSvc.requestSync();
} }

View File

@ -1,12 +1,12 @@
**Where is my data stored?** **Where is my data stored?**
If your workspace is not synced, your files are only stored inside your browser (using the [IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)) and are not stored anywhere else. If your workspace is not synced, your files are only stored inside your browser using the [IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) and are not stored anywhere else.
We recommend syncing your workspace to make sure files won't be lost in case your browser data is cleared. We recommend syncing your workspace to make sure files won't be lost in case your browser data is cleared.
**Where is my data stored once I sync my workspace?** **Where is my data stored once I sync my workspace?**
If you sign in with Google, your main workspace will be stored in Google Drive (in your [app data folder](https://developers.google.com/drive/v3/web/appdata)). If you sign in with Google, your main workspace will be stored in Google Drive, in your [app data folder](https://developers.google.com/drive/v3/web/appdata).
If you open a Google Drive workspace, the files in the workspace will be stored inside a Google Drive folder which you can share with other users. If you open a Google Drive workspace, the files in the workspace will be stored inside a Google Drive folder which you can share with other users.

View File

@ -1,4 +1,5 @@
export default [{}, { export default [{
}, {
method: 'bold', method: 'bold',
title: 'Bold', title: 'Bold',
icon: 'format-bold', icon: 'format-bold',
@ -14,7 +15,8 @@ export default [{}, {
method: 'strikethrough', method: 'strikethrough',
title: 'Strikethrough', title: 'Strikethrough',
icon: 'format-strikethrough', icon: 'format-strikethrough',
}, {}, { }, {
}, {
method: 'ulist', method: 'ulist',
title: 'Unordered list', title: 'Unordered list',
icon: 'format-list-bulleted', icon: 'format-list-bulleted',
@ -26,7 +28,8 @@ export default [{}, {
method: 'clist', method: 'clist',
title: 'Check list', title: 'Check list',
icon: 'format-list-checks', icon: 'format-list-checks',
}, {}, { }, {
}, {
method: 'quote', method: 'quote',
title: 'Blockquote', title: 'Blockquote',
icon: 'format-quote-close', icon: 'format-quote-close',

97
src/data/simpleModals.js Normal file
View File

@ -0,0 +1,97 @@
const simpleModal = (contentHtml, rejectText, resolveText) => ({
contentHtml: typeof contentHtml === 'function' ? contentHtml : () => contentHtml,
rejectText,
resolveText,
});
/* eslint sort-keys: "error" */
export default {
commentDeletion: simpleModal(
'<p>You are about to delete a comment. Are you sure?</p>',
'No',
'Yes, delete',
),
discussionDeletion: simpleModal(
'<p>You are about to delete a discussion. Are you sure?</p>',
'No',
'Yes, delete',
),
fileRestoration: simpleModal(
'<p>You are about to revert some changes. Are you sure?</p>',
'No',
'Yes, revert',
),
folderDeletion: simpleModal(
config => `<p>You are about to delete the folder <b>${config.item.name}</b>. Its files will be moved to Trash. Are you sure?</p>`,
'No',
'Yes, delete',
),
pathConflict: simpleModal(
config => `<p><b>${config.item.name}</b> already exists. Do you want to add a suffix?</p>`,
'No',
'Yes, add suffix',
),
paymentSuccess: simpleModal(
'<p>Thank you for your payment! Your sponsorship will be active in a minute.</p>',
'Ok',
),
providerRedirection: simpleModal(
config => `<p>You are about to navigate to the <b>${config.name}</b> authorization page.</p>`,
'Cancel',
'Ok, go on',
),
removeWorkspace: simpleModal(
'<p>You are about to remove a workspace locally. Are you sure?</p>',
'No',
'Yes, remove',
),
reset: simpleModal(
'<p>This will clean all your workspaces locally. Are you sure?</p>',
'No',
'Yes, clean',
),
signInForComment: simpleModal(
`<p>You have to sign in with Google to start commenting.</p>
<div class="modal__info"><b>Note:</b> This will sync your main workspace.</div>`,
'Cancel',
'Ok, sign in',
),
signInForSponsorship: simpleModal(
`<p>You have to sign in with Google to sponsor.</p>
<div class="modal__info"><b>Note:</b> This will sync your main workspace.</div>`,
'Cancel',
'Ok, sign in',
),
sponsorOnly: simpleModal(
'<p>This feature is restricted to sponsors as it relies on server resources.</p>',
'Ok, I understand',
),
stripName: simpleModal(
config => `<p><b>${config.item.name}</b> contains illegal characters. Do you want to strip them?</p>`,
'No',
'Yes, strip',
),
tempFileDeletion: simpleModal(
config => `<p>You are about to permanently delete the temporary file <b>${config.item.name}</b>. Are you sure?</p>`,
'No',
'Yes, delete',
),
tempFolderDeletion: simpleModal(
'<p>You are about to permanently delete all the temporary files. Are you sure?</p>',
'No',
'Yes, delete all',
),
trashDeletion: simpleModal(
'<p>Files in the trash are automatically deleted after 7 days of inactivity.</p>',
'Ok',
),
unauthorizedName: simpleModal(
config => `<p><b>${config.item.name}</b> is an unauthorized name.</p>`,
'Ok',
),
workspaceGoogleRedirection: simpleModal(
'<p>StackEdit needs full Google Drive access to open this workspace.</p>',
'Cancel',
'Ok, grant',
),
};

View File

@ -2,9 +2,9 @@ import Vue from 'vue';
import 'babel-polyfill'; import 'babel-polyfill';
import 'indexeddbshim/dist/indexeddbshim'; import 'indexeddbshim/dist/indexeddbshim';
import * as OfflinePluginRuntime from 'offline-plugin/runtime'; import * as OfflinePluginRuntime from 'offline-plugin/runtime';
import './extensions/'; import './extensions';
import './services/optional'; import './services/optional';
import './icons/'; import './icons';
import App from './components/App'; import App from './components/App';
import store from './store'; import store from './store';
import localDbSvc from './services/localDbSvc'; import localDbSvc from './services/localDbSvc';

View File

@ -140,7 +140,8 @@ objectProperties.cl_extend = function (obj) {
function build(properties) { function build(properties) {
return objectProperties.cl_reduce.call(properties, function (memo, value, key) { return objectProperties.cl_reduce.call(properties, function (memo, value, key) {
memo[key] = { memo[key] = {
value: value value: value,
configurable: true
} }
return memo return memo
}, {}) }, {})

View File

@ -20,9 +20,10 @@ export default {
if (selectedNode.isNil) { if (selectedNode.isNil) {
return; return;
} }
if (selectedNode.isTrash || selectedNode.item.parentId === 'trash') { if (selectedNode.isTrash || selectedNode.item.parentId === 'trash') {
try { try {
await store.dispatch('modal/trashDeletion'); await store.dispatch('modal/open', 'trashDeletion');
} catch (e) { } catch (e) {
// Cancel // Cancel
} }
@ -33,13 +34,19 @@ export default {
let moveToTrash = true; let moveToTrash = true;
try { try {
if (selectedNode.isTemp) { if (selectedNode.isTemp) {
await store.dispatch('modal/tempFolderDeletion', selectedNode.item); await store.dispatch('modal/open', 'tempFolderDeletion');
moveToTrash = false; moveToTrash = false;
} else if (selectedNode.item.parentId === 'temp') { } else if (selectedNode.item.parentId === 'temp') {
await store.dispatch('modal/tempFileDeletion', selectedNode.item); await store.dispatch('modal/open', {
type: 'tempFileDeletion',
item: selectedNode.item,
});
moveToTrash = false; moveToTrash = false;
} else if (selectedNode.isFolder) { } else if (selectedNode.isFolder) {
await store.dispatch('modal/folderDeletion', selectedNode.item); await store.dispatch('modal/open', {
type: 'folderDeletion',
item: selectedNode.item,
});
} }
} catch (e) { } catch (e) {
return; // cancel return; // cancel

View File

@ -16,7 +16,7 @@ export default {
comments, comments,
} = {}, background = false) { } = {}, background = false) {
const id = utils.uid(); const id = utils.uid();
const file = { const item = {
id, id,
name: utils.sanitizeName(name), name: utils.sanitizeName(name),
parentId: parentId || null, parentId: parentId || null,
@ -34,23 +34,29 @@ export default {
// Show warning dialogs // Show warning dialogs
if (!background) { if (!background) {
// If name is being stripped // If name is being stripped
if (file.name !== utils.defaultName && file.name !== name) { if (item.name !== utils.defaultName && item.name !== name) {
await store.dispatch('modal/stripName', name); await store.dispatch('modal/open', {
type: 'stripName',
item,
});
} }
// Check if there is already a file with that path // Check if there is already a file with that path
if (workspaceUniquePaths) { if (workspaceUniquePaths) {
const parentPath = store.getters.itemPaths[file.parentId] || ''; const parentPath = store.getters.itemPaths[item.parentId] || '';
const path = parentPath + file.name; const path = parentPath + item.name;
if (store.getters.pathItems[path]) { if (store.getters.pathItems[path]) {
await store.dispatch('modal/pathConflict', name); await store.dispatch('modal/open', {
type: 'pathConflict',
item,
});
} }
} }
} }
// Save file and content in the store // Save file and content in the store
store.commit('content/setItem', content); store.commit('content/setItem', content);
store.commit('file/setItem', file); store.commit('file/setItem', item);
if (workspaceUniquePaths) { if (workspaceUniquePaths) {
this.makePathUnique(id); this.makePathUnique(id);
} }
@ -67,14 +73,20 @@ export default {
const sanitizedName = utils.sanitizeName(item.name); const sanitizedName = utils.sanitizeName(item.name);
if (item.type === 'folder' && forbiddenFolderNameMatcher.exec(sanitizedName)) { if (item.type === 'folder' && forbiddenFolderNameMatcher.exec(sanitizedName)) {
await store.dispatch('modal/unauthorizedName', item.name); await store.dispatch('modal/open', {
type: 'unauthorizedName',
item,
});
throw new Error('Unauthorized name.'); throw new Error('Unauthorized name.');
} }
// Show warning dialogs // Show warning dialogs
// If name has been stripped // If name has been stripped
if (sanitizedName !== utils.defaultName && sanitizedName !== item.name) { if (sanitizedName !== utils.defaultName && sanitizedName !== item.name) {
await store.dispatch('modal/stripName', item.name); await store.dispatch('modal/open', {
type: 'stripName',
item,
});
} }
// Check if there is a path conflict // Check if there is a path conflict
if (store.getters['workspace/hasUniquePaths']) { if (store.getters['workspace/hasUniquePaths']) {
@ -82,7 +94,10 @@ export default {
const path = parentPath + sanitizedName; const path = parentPath + sanitizedName;
const pathItems = store.getters.pathItems[path] || []; const pathItems = store.getters.pathItems[path] || [];
if (pathItems.some(itemWithSamePath => itemWithSamePath.id !== id)) { if (pathItems.some(itemWithSamePath => itemWithSamePath.id !== id)) {
await store.dispatch('modal/pathConflict', item.name); await store.dispatch('modal/open', {
type: 'pathConflict',
item,
});
} }
} }

View File

@ -399,7 +399,7 @@ const localDbSvc = {
// Enable sponsorship // Enable sponsorship
if (utils.queryParams.paymentSuccess) { if (utils.queryParams.paymentSuccess) {
window.location.hash = ''; // PaymentSuccess param is always on its own window.location.hash = ''; // PaymentSuccess param is always on its own
store.dispatch('modal/paymentSuccess') store.dispatch('modal/open', 'paymentSuccess')
.catch(() => { /* Cancel */ }); .catch(() => { /* Cancel */ });
const sponsorToken = store.getters['workspace/sponsorToken']; const sponsorToken = store.getters['workspace/sponsorToken'];
// Force check sponsorship after a few seconds // Force check sponsorship after a few seconds

View File

@ -215,13 +215,24 @@ export default {
const attempt = async () => { const attempt = async () => {
try { try {
await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
if (offlineCheck) { if (offlineCheck) {
store.commit('updateLastOfflineCheck'); store.commit('updateLastOfflineCheck');
} }
const xhr = new window.XMLHttpRequest(); const xhr = new window.XMLHttpRequest();
xhr.withCredentials = config.withCredentials || false; xhr.withCredentials = config.withCredentials || false;
let timeoutId;
const timeoutId = setTimeout(() => {
xhr.abort();
if (offlineCheck) {
isConnectionDown = true;
store.commit('setOffline', true);
reject(new Error('You are offline.'));
} else {
reject(new Error('Network request timeout.'));
}
}, config.timeout);
xhr.onload = () => { xhr.onload = () => {
if (offlineCheck) { if (offlineCheck) {
@ -242,9 +253,9 @@ export default {
} }
if (result.status >= 200 && result.status < 300) { if (result.status >= 200 && result.status < 300) {
resolve(result); resolve(result);
return; } else {
}
reject(result); reject(result);
}
}; };
xhr.onerror = () => { xhr.onerror = () => {
@ -258,21 +269,13 @@ export default {
} }
}; };
timeoutId = setTimeout(() => {
xhr.abort();
if (offlineCheck) {
isConnectionDown = true;
store.commit('setOffline', true);
reject(new Error('You are offline.'));
} else {
reject(new Error('Network request timeout.'));
}
}, config.timeout);
const url = utils.addQueryParams(config.url, config.params); const url = utils.addQueryParams(config.url, config.params);
xhr.open(config.method || 'GET', url); xhr.open(config.method || 'GET', url);
Object.entries(config.headers).forEach(([key, value]) => Object.entries(config.headers).forEach(([key, value]) => {
value && xhr.setRequestHeader(key, `${value}`)); if (value) {
xhr.setRequestHeader(key, `${value}`);
}
});
if (config.blob) { if (config.blob) {
xhr.responseType = 'blob'; xhr.responseType = 'blob';
} }
@ -286,7 +289,7 @@ export default {
// Exponential backoff // Exponential backoff
retryAfter *= 2; retryAfter *= 2;
}); });
attempt(); return attempt();
} }
throw err; throw err;
} }

View File

@ -91,7 +91,7 @@ export default new Provider({
syncLastSeq, syncLastSeq,
}); });
}, },
async saveSimpleItem(item, syncData) { async saveWorkspaceItem(item, syncData) {
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];
const { id, rev } = couchdbHelper.uploadDocument({ const { id, rev } = couchdbHelper.uploadDocument({
token: syncToken, token: syncToken,
@ -108,78 +108,85 @@ export default new Provider({
rev, rev,
}; };
}, },
removeItem(syncData) { removeWorkspaceItem(syncData) {
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];
return couchdbHelper.removeDocument(syncToken, syncData.id, syncData.rev); return couchdbHelper.removeDocument(syncToken, syncData.id, syncData.rev);
}, },
downloadContent(token, syncLocation) { async downloadWorkspaceContent(token, syncData) {
return this.downloadData(`${syncLocation.fileId}/content`); const body = await couchdbHelper.retrieveDocumentWithAttachments(token, syncData.id);
},
async downloadData(dataId) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (!syncData) {
return Promise.resolve();
}
const syncToken = store.getters['workspace/syncToken'];
const body = await couchdbHelper.retrieveDocumentWithAttachments(syncToken, syncData.id);
let item;
if (body.item.type === 'content') {
item = Provider.parseContent(body.attachments.data, body.item.id);
} else {
item = utils.addItemHash(JSON.parse(body.attachments.data));
}
const rev = body._rev; // eslint-disable-line no-underscore-dangle const rev = body._rev; // eslint-disable-line no-underscore-dangle
if (item.hash !== syncData.hash || rev !== syncData.rev) { const item = Provider.parseContent(body.attachments.data, body.item.id);
store.dispatch('data/patchSyncData', { return {
[syncData.id]: { item,
syncData: {
...syncData, ...syncData,
hash: item.hash, hash: item.hash,
rev, rev,
}, },
}); };
}
return item;
}, },
async uploadContent(token, content, syncLocation) { async downloadWorkspaceData(token, dataId, syncData) {
await this.uploadData(content); if (!syncData) {
return syncLocation; return {};
},
async uploadData(item) {
const syncData = store.getters['data/syncDataByItemId'][item.id];
if (!syncData || syncData.hash !== item.hash) {
let data;
let dataType;
if (item.type === 'content') {
data = Provider.serializeContent(item);
dataType = 'text/plain';
} else {
data = JSON.stringify(item);
dataType = 'application/json';
} }
const syncToken = store.getters['workspace/syncToken'];
const body = await couchdbHelper.retrieveDocumentWithAttachments(token, syncData.id);
const item = utils.addItemHash(JSON.parse(body.attachments.data));
const rev = body._rev; // eslint-disable-line no-underscore-dangle
return {
item,
syncData: {
...syncData,
hash: item.hash,
rev,
},
};
},
async uploadWorkspaceContent(token, item, syncData) {
const res = await couchdbHelper.uploadDocument({ const res = await couchdbHelper.uploadDocument({
token: syncToken, token,
item: { item: {
id: item.id, id: item.id,
type: item.type, type: item.type,
hash: item.hash, hash: item.hash,
}, },
data, data: Provider.serializeContent(item),
dataType, dataType: 'text/plain',
documentId: syncData && syncData.id, documentId: syncData && syncData.id,
rev: syncData && syncData.rev, rev: syncData && syncData.rev,
}); });
store.dispatch('data/patchSyncData', {
[res.id]: { // Return new sync data
// Build sync data return {
id: res.id, id: res.id,
itemId: item.id, itemId: item.id,
type: item.type, type: item.type,
hash: item.hash, hash: item.hash,
rev: res.rev, rev: res.rev,
};
}, },
async uploadWorkspaceData(token, item, syncData) {
const res = await couchdbHelper.uploadDocument({
token,
item: {
id: item.id,
type: item.type,
hash: item.hash,
},
data: JSON.stringify(item),
dataType: 'application/json',
documentId: syncData && syncData.id,
rev: syncData && syncData.rev,
}); });
}
// Return new sync data
return {
id: res.id,
itemId: item.id,
type: item.type,
hash: item.hash,
rev: res.rev,
};
}, },
async listRevisions(token, fileId) { async listRevisions(token, fileId) {
const syncData = Provider.getContentSyncData(fileId); const syncData = Provider.getContentSyncData(fileId);

View File

@ -28,6 +28,7 @@ const endsWith = (str, suffix) => str.slice(-suffix.length) === suffix;
export default new Provider({ export default new Provider({
id: 'githubWorkspace', id: 'githubWorkspace',
isGit: true,
getToken() { getToken() {
return store.getters['workspace/syncToken']; return store.getters['workspace/syncToken'];
}, },
@ -110,6 +111,9 @@ export default new Provider({
const path = blobEntry.path.slice(workspacePath.length); const path = blobEntry.path.slice(workspacePath.length);
// Collect blob sha // Collect blob sha
treeShaMap[path] = blobEntry.sha; treeShaMap[path] = blobEntry.sha;
if (path.indexOf('.stackedit-data/') === 0) {
treeDataMap[path] = true;
} else {
// Collect parents path // Collect parents path
let parentPath = ''; let parentPath = '';
path.split('/').slice(0, -1).forEach((folderName) => { path.split('/').slice(0, -1).forEach((folderName) => {
@ -118,21 +122,20 @@ export default new Provider({
parentPath = folderPath; parentPath = folderPath;
}); });
// Collect file path // Collect file path
if (path.indexOf('.stackedit-data/') === 0) { if (endsWith(path, '.md')) {
treeDataMap[path] = true;
} else if (endsWith(path, '.md')) {
treeFileMap[path] = parentPath; treeFileMap[path] = parentPath;
} else if (endsWith(path, '.sync')) { } else if (endsWith(path, '.sync')) {
treeSyncLocationMap[path] = true; treeSyncLocationMap[path] = true;
} else if (endsWith(path, '.publish')) { } else if (endsWith(path, '.publish')) {
treePublishLocationMap[path] = true; treePublishLocationMap[path] = true;
} }
}
}); });
// Collect changes // Collect changes
const changes = []; const changes = [];
const pathIds = {}; const pathIds = {};
const syncDataToIgnore = Object.create(null); const syncDataToKeep = Object.create(null);
const getId = (path) => { const getId = (path) => {
const syncData = syncDataByPath[path]; const syncData = syncDataByPath[path];
const id = syncData ? syncData.itemId : utils.uid(); const id = syncData ? syncData.itemId : utils.uid();
@ -185,7 +188,7 @@ export default new Provider({
// Content creations/updates // Content creations/updates
const contentSyncData = syncDataByItemId[`${id}/content`]; const contentSyncData = syncDataByItemId[`${id}/content`];
if (contentSyncData) { if (contentSyncData) {
syncDataToIgnore[contentSyncData.id] = true; syncDataToKeep[contentSyncData.id] = true;
} }
if (!contentSyncData || contentSyncData.sha !== treeShaMap[path]) { if (!contentSyncData || contentSyncData.sha !== treeShaMap[path]) {
// Use `/` as a prefix to get a unique syncData id // Use `/` as a prefix to get a unique syncData id
@ -211,11 +214,12 @@ export default new Provider({
// Data creations/updates // Data creations/updates
Object.keys(treeDataMap).forEach((path) => { Object.keys(treeDataMap).forEach((path) => {
try { try {
const [, id] = path.match(/^\.stackedit-data\/([\s\S]+)\.json$/); // Only template data are stored
const [, id] = path.match(/^\.stackedit-data\/(templates)\.json$/) || [];
pathIds[path] = id; pathIds[path] = id;
const syncData = syncDataByItemId[id]; const syncData = syncDataByItemId[id];
if (syncData) { if (syncData) {
syncDataToIgnore[syncData.id] = true; syncDataToKeep[syncData.id] = true;
} }
if (!syncData || syncData.sha !== treeShaMap[path]) { if (!syncData || syncData.sha !== treeShaMap[path]) {
changes.push({ changes.push({
@ -281,40 +285,28 @@ export default new Provider({
// Deletions // Deletions
Object.keys(syncDataByPath).forEach((path) => { Object.keys(syncDataByPath).forEach((path) => {
if (!pathIds[path] && !syncDataToIgnore[path]) { if (!pathIds[path] && !syncDataToKeep[path]) {
changes.push({ syncDataId: path }); changes.push({ syncDataId: path });
} }
}); });
return changes; return changes;
}, },
async saveSimpleItem(item) { async saveWorkspaceItem(item) {
const path = store.getters.itemPaths[item.fileId || item.id];
const syncToken = store.getters['workspace/syncToken'];
const syncData = { const syncData = {
id: store.getters.itemGitPaths[item.id],
itemId: item.id, itemId: item.id,
type: item.type, type: item.type,
hash: item.hash, hash: item.hash,
}; };
if (item.type === 'file') { // Files and folders are not in git, only contents
syncData.id = `${path}.md`; if (item.type === 'file' || item.type === 'folder') {
return syncData;
}
if (item.type === 'folder') {
syncData.id = path;
return syncData; return syncData;
} }
// locations are stored as paths, so we upload an empty file // locations are stored as paths, so we upload an empty file
const data = utils.encodeBase64(utils.serializeObject({ const syncToken = store.getters['workspace/syncToken'];
...item,
id: undefined,
type: undefined,
fileId: undefined,
}), true);
const extension = item.type === 'syncLocation' ? 'sync' : 'publish';
syncData.id = `${path}.${data}.${extension}`;
await githubHelper.uploadFile({ await githubHelper.uploadFile({
...getWorkspaceWithOwner(), ...getWorkspaceWithOwner(),
token: syncToken, token: syncToken,
@ -324,9 +316,8 @@ export default new Provider({
}); });
return syncData; return syncData;
}, },
async removeItem(syncData) { async removeWorkspaceItem(syncData) {
// Ignore content deletion if (treeShaMap[syncData.id]) {
if (syncData.type !== 'content') {
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];
await githubHelper.removeFile({ await githubHelper.removeFile({
...getWorkspaceWithOwner(), ...getWorkspaceWithOwner(),
@ -336,102 +327,91 @@ export default new Provider({
}); });
} }
}, },
async downloadContent(token, syncLocation) { async downloadWorkspaceContent(token, contentSyncData) {
const syncData = store.getters['data/syncDataByItemId'][syncLocation.fileId]; const [fileId] = contentSyncData.itemId.split('/');
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`]; const path = store.getters.itemGitPaths[fileId];
if (!syncData || !contentSyncData) { const syncData = store.getters['data/syncData'][path];
return null; if (!syncData) {
return {};
} }
const { sha, content } = await githubHelper.downloadFile({ const { sha, content } = await githubHelper.downloadFile({
...getWorkspaceWithOwner(), ...getWorkspaceWithOwner(),
token, token,
path: getAbsolutePath(syncData), path: getAbsolutePath(syncData),
}); });
const item = Provider.parseContent(content, `${syncLocation.fileId}/content`); treeShaMap[path] = sha;
if (item.hash !== contentSyncData.hash) { const item = Provider.parseContent(content, `${fileId}/content`);
store.dispatch('data/patchSyncData', { return {
[contentSyncData.id]: { item,
syncData: {
...contentSyncData, ...contentSyncData,
hash: item.hash, hash: item.hash,
sha, sha,
}, },
}); };
}
return item;
}, },
async downloadData(dataId) { async downloadWorkspaceData(token, dataId, syncData) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (!syncData) { if (!syncData) {
return null; return {};
} }
const syncToken = store.getters['workspace/syncToken'];
const { sha, content } = await githubHelper.downloadFile({ const { sha, content } = await githubHelper.downloadFile({
...getWorkspaceWithOwner(), ...getWorkspaceWithOwner(),
token: syncToken, token,
path: getAbsolutePath(syncData), path: getAbsolutePath(syncData),
}); });
treeShaMap[syncData.id] = sha;
const item = JSON.parse(content); const item = JSON.parse(content);
if (item.hash !== syncData.hash) { return {
store.dispatch('data/patchSyncData', { item,
[syncData.id]: { syncData: {
...syncData, ...syncData,
hash: item.hash, hash: item.hash,
sha, sha,
}, },
}); };
}
return item;
}, },
async uploadContent(token, content, syncLocation) { async uploadWorkspaceContent(token, item) {
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`]; const [fileId] = item.id.split('/');
if (!contentSyncData || contentSyncData.hash !== content.hash) { const path = store.getters.itemGitPaths[fileId];
const path = `${store.getters.itemPaths[syncLocation.fileId]}.md`;
const absolutePath = `${store.getters['workspace/currentWorkspace'].path || ''}${path}`; const absolutePath = `${store.getters['workspace/currentWorkspace'].path || ''}${path}`;
const id = `/${path}`;
const res = await githubHelper.uploadFile({ const res = await githubHelper.uploadFile({
...getWorkspaceWithOwner(), ...getWorkspaceWithOwner(),
token, token,
path: absolutePath, path: absolutePath,
content: Provider.serializeContent(content), content: Provider.serializeContent(item),
sha: treeShaMap[id], sha: treeShaMap[path],
}); });
store.dispatch('data/patchSyncData', {
[id]: { // Return new sync data
// Build sync data return {
id, id: store.getters.itemGitPaths[item.id],
itemId: content.id, itemId: item.id,
type: content.type, type: item.type,
hash: content.hash, hash: item.hash,
sha: res.content.sha, sha: res.content.sha,
};
}, },
}); async uploadWorkspaceData(token, item) {
} const path = store.getters.itemGitPaths[item.id];
return syncLocation;
},
async uploadData(item) {
const oldSyncData = store.getters['data/syncDataByItemId'][item.id];
if (!oldSyncData || oldSyncData.hash !== item.hash) {
const syncData = { const syncData = {
id: `.stackedit-data/${item.id}.json`, id: path,
itemId: item.id, itemId: item.id,
type: item.type, type: item.type,
hash: item.hash, hash: item.hash,
}; };
const syncToken = store.getters['workspace/syncToken'];
const res = await githubHelper.uploadFile({ const res = await githubHelper.uploadFile({
...getWorkspaceWithOwner(), ...getWorkspaceWithOwner(),
token: syncToken, token,
path: getAbsolutePath(syncData), path: getAbsolutePath(syncData),
content: JSON.stringify(item), content: JSON.stringify(item),
sha: oldSyncData && oldSyncData.sha, sha: treeShaMap[path],
}); });
store.dispatch('data/patchSyncData', {
[syncData.id]: { return {
...syncData, ...syncData,
sha: res.content.sha, sha: res.content.sha,
}, };
});
}
}, },
onSyncEnd() { onSyncEnd() {
// Clean up // Clean up

View File

@ -48,7 +48,7 @@ export default new Provider({
syncStartPageToken, syncStartPageToken,
}); });
}, },
async saveSimpleItem(item, syncData, ifNotTooLate) { async saveWorkspaceItem(item, syncData, ifNotTooLate) {
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];
const file = await googleHelper.uploadAppDataFile({ const file = await googleHelper.uploadAppDataFile({
token: syncToken, token: syncToken,
@ -64,41 +64,39 @@ export default new Provider({
hash: item.hash, hash: item.hash,
}; };
}, },
removeItem(syncData, ifNotTooLate) { removeWorkspaceItem(syncData, ifNotTooLate) {
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];
return googleHelper.removeAppDataFile(syncToken, syncData.id, ifNotTooLate); return googleHelper.removeAppDataFile(syncToken, syncData.id, ifNotTooLate);
}, },
downloadContent(token, syncLocation) { async downloadWorkspaceContent(token, syncData) {
return this.downloadData(`${syncLocation.fileId}/content`); const data = await googleHelper.downloadAppDataFile(token, syncData.id);
},
async downloadData(dataId) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (!syncData) {
return null;
}
const syncToken = store.getters['workspace/syncToken'];
const data = await googleHelper.downloadAppDataFile(syncToken, syncData.id);
const item = utils.addItemHash(JSON.parse(data)); const item = utils.addItemHash(JSON.parse(data));
if (item.hash !== syncData.hash) { return {
store.dispatch('data/patchSyncData', { item,
[syncData.id]: { syncData: {
...syncData, ...syncData,
hash: item.hash, hash: item.hash,
}, },
}); };
},
async downloadWorkspaceData(token, dataId, syncData) {
if (!syncData) {
return {};
} }
return item;
const data = await googleHelper.downloadAppDataFile(token, syncData.id);
const item = utils.addItemHash(JSON.parse(data));
return {
item,
syncData: {
...syncData,
hash: item.hash,
}, },
async uploadContent(token, content, syncLocation, ifNotTooLate) { };
await this.uploadData(content, ifNotTooLate);
return syncLocation;
}, },
async uploadData(item, ifNotTooLate) { async uploadWorkspaceContent(token, item, syncData, ifNotTooLate) {
const syncData = store.getters['data/syncDataByItemId'][item.id];
if (!syncData || syncData.hash !== item.hash) {
const syncToken = store.getters['workspace/syncToken'];
const file = await googleHelper.uploadAppDataFile({ const file = await googleHelper.uploadAppDataFile({
token: syncToken, token,
name: JSON.stringify({ name: JSON.stringify({
id: item.id, id: item.id,
type: item.type, type: item.type,
@ -108,16 +106,35 @@ export default new Provider({
fileId: syncData && syncData.id, fileId: syncData && syncData.id,
ifNotTooLate, ifNotTooLate,
}); });
store.dispatch('data/patchSyncData', {
[file.id]: { // Return new sync data
// Build sync data return {
id: file.id, id: file.id,
itemId: item.id, itemId: item.id,
type: item.type, type: item.type,
hash: item.hash, hash: item.hash,
};
}, },
async uploadWorkspaceData(token, item, syncData, ifNotTooLate) {
const file = await googleHelper.uploadAppDataFile({
token,
name: JSON.stringify({
id: item.id,
type: item.type,
hash: item.hash,
}),
media: JSON.stringify(item),
fileId: syncData && syncData.id,
ifNotTooLate,
}); });
}
// Return new sync data
return {
id: file.id,
itemId: item.id,
type: item.type,
hash: item.hash,
};
}, },
async listRevisions(token, fileId) { async listRevisions(token, fileId) {
const syncData = Provider.getContentSyncData(fileId); const syncData = Provider.getContentSyncData(fileId);

View File

@ -89,7 +89,7 @@ export default new Provider({
let token = store.getters['data/googleTokens'][sub]; let token = store.getters['data/googleTokens'][sub];
// If no token has been found, popup an authorize window and get one // If no token has been found, popup an authorize window and get one
if (!token || !token.isDrive || !token.driveFullAccess) { if (!token || !token.isDrive || !token.driveFullAccess) {
await store.dispatch('modal/workspaceGoogleRedirection'); await store.dispatch('modal/open', 'workspaceGoogleRedirection');
token = await googleHelper.addDriveAccount(true, utils.queryParams.sub); token = await googleHelper.addDriveAccount(true, utils.queryParams.sub);
} }
@ -312,7 +312,7 @@ export default new Provider({
syncStartPageToken, syncStartPageToken,
}); });
}, },
async saveSimpleItem(item, syncData, ifNotTooLate) { async saveWorkspaceItem(item, syncData, ifNotTooLate) {
const workspace = store.getters['workspace/currentWorkspace']; const workspace = store.getters['workspace/currentWorkspace'];
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];
let file; let file;
@ -363,63 +363,59 @@ export default new Provider({
hash: item.hash, hash: item.hash,
}; };
}, },
async removeItem(syncData, ifNotTooLate) { async removeWorkspaceItem(syncData, ifNotTooLate) {
// Ignore content deletion // Ignore content deletion
if (syncData.type !== 'content') { if (syncData.type !== 'content') {
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];
await googleHelper.removeFile(syncToken, syncData.id, ifNotTooLate); await googleHelper.removeFile(syncToken, syncData.id, ifNotTooLate);
} }
}, },
async downloadContent(token, syncLocation) { async downloadWorkspaceContent(token, contentSyncData) {
const syncData = store.getters['data/syncDataByItemId'][syncLocation.fileId]; const [fileId] = contentSyncData.itemId.split('/');
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`]; const syncData = store.getters['data/syncDataByItemId'][fileId];
if (!syncData || !contentSyncData) { if (!syncData) {
return null; return {};
} }
const content = await googleHelper.downloadFile(token, syncData.id); const content = await googleHelper.downloadFile(token, syncData.id);
const item = Provider.parseContent(content, `${syncLocation.fileId}/content`); const item = Provider.parseContent(content, contentSyncData.itemId);
if (item.hash !== contentSyncData.hash) {
store.dispatch('data/patchSyncData', {
[contentSyncData.id]: {
...contentSyncData,
hash: item.hash,
},
});
}
// Open the file requested by action if it wasn't synced yet // Open the file requested by action if it wasn't synced yet
if (fileIdToOpen && fileIdToOpen === syncData.id) { if (fileIdToOpen && fileIdToOpen === syncData.id) {
fileIdToOpen = null; fileIdToOpen = null;
// Open the file once downloaded content has been stored // Open the file once downloaded content has been stored
setTimeout(() => { setTimeout(() => {
store.commit('file/setCurrentId', syncData.itemId); store.commit('file/setCurrentId', fileId);
}, 10); }, 10);
} }
return item;
return {
item,
syncData: {
...contentSyncData,
hash: item.hash,
}, },
async downloadData(dataId) { };
const syncData = store.getters['data/syncDataByItemId'][dataId]; },
async downloadWorkspaceData(token, dataId, syncData) {
if (!syncData) { if (!syncData) {
return null; return {};
} }
const syncToken = store.getters['workspace/syncToken'];
const content = await googleHelper.downloadFile(syncToken, syncData.id); const content = await googleHelper.downloadFile(token, syncData.id);
const item = JSON.parse(content); const item = JSON.parse(content);
if (item.hash !== syncData.hash) { return {
store.dispatch('data/patchSyncData', { item,
[syncData.id]: { syncData: {
...syncData, ...syncData,
hash: item.hash, hash: item.hash,
}, },
}); };
}
return item;
}, },
async uploadContent(token, content, syncLocation, ifNotTooLate) { async uploadWorkspaceContent(token, content, contentSyncData, ifNotTooLate) {
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`]; const [fileId] = content.id.split('/');
if (!contentSyncData || contentSyncData.hash !== content.hash) { const syncData = store.getters['data/syncDataByItemId'][fileId];
const syncData = store.getters['data/syncDataByItemId'][syncLocation.fileId];
let file; let file;
if (syncData) { if (syncData) {
// Only update file media // Only update file media
file = await googleHelper.uploadFile({ file = await googleHelper.uploadFile({
@ -432,7 +428,7 @@ export default new Provider({
// Create file with media // Create file with media
const workspace = store.getters['workspace/currentWorkspace']; const workspace = store.getters['workspace/currentWorkspace'];
// Use deepCopy to freeze objects // Use deepCopy to freeze objects
const item = utils.deepCopy(store.state.file.itemMap[syncLocation.fileId]); const item = utils.deepCopy(store.state.file.itemMap[fileId]);
const parentSyncData = store.getters['data/syncDataByItemId'][item.parentId]; const parentSyncData = store.getters['data/syncDataByItemId'][item.parentId];
file = await googleHelper.uploadFile({ file = await googleHelper.uploadFile({
token, token,
@ -445,6 +441,8 @@ export default new Provider({
media: Provider.serializeContent(content), media: Provider.serializeContent(content),
ifNotTooLate, ifNotTooLate,
}); });
// Create file syncData
store.dispatch('data/patchSyncData', { store.dispatch('data/patchSyncData', {
[file.id]: { [file.id]: {
id: file.id, id: file.id,
@ -454,25 +452,19 @@ export default new Provider({
}, },
}); });
} }
store.dispatch('data/patchSyncData', {
[`${file.id}/content`]: { // Return new sync data
// Build sync data return {
id: `${file.id}/content`, id: `${file.id}/content`,
itemId: content.id, itemId: content.id,
type: content.type, type: content.type,
hash: content.hash, hash: content.hash,
};
}, },
}); async uploadWorkspaceData(token, item, syncData, ifNotTooLate) {
}
return syncLocation;
},
async uploadData(item, ifNotTooLate) {
const syncData = store.getters['data/syncDataByItemId'][item.id];
if (!syncData || syncData.hash !== item.hash) {
const workspace = store.getters['workspace/currentWorkspace']; const workspace = store.getters['workspace/currentWorkspace'];
const syncToken = store.getters['workspace/syncToken'];
const file = await googleHelper.uploadFile({ const file = await googleHelper.uploadFile({
token: syncToken, token,
name: JSON.stringify({ name: JSON.stringify({
id: item.id, id: item.id,
type: item.type, type: item.type,
@ -487,16 +479,14 @@ export default new Provider({
oldParents: syncData && syncData.parentIds, oldParents: syncData && syncData.parentIds,
ifNotTooLate, ifNotTooLate,
}); });
store.dispatch('data/patchSyncData', {
[file.id]: { // Return new sync data
// Build sync data return {
id: file.id, id: file.id,
itemId: item.id, itemId: item.id,
type: item.type, type: item.type,
hash: item.hash, hash: item.hash,
}, };
});
}
}, },
async listRevisions(token, fileId) { async listRevisions(token, fileId) {
const syncData = Provider.getContentSyncData(fileId); const syncData = Provider.getContentSyncData(fileId);

View File

@ -113,12 +113,12 @@ export default {
repo, repo,
branch, branch,
}) { }) {
const { commit } = (await repoRequest(token, owner, repo, { const { commit } = await repoRequest(token, owner, repo, {
url: `commits/${encodeURIComponent(branch)}`, url: `commits/${encodeURIComponent(branch)}`,
})).body; });
const { tree, truncated } = (await repoRequest(token, owner, repo, { const { tree, truncated } = await repoRequest(token, owner, repo, {
url: `git/trees/${encodeURIComponent(commit.tree.sha)}?recursive=1`, url: `git/trees/${encodeURIComponent(commit.tree.sha)}?recursive=1`,
})).body; });
if (truncated) { if (truncated) {
throw new Error('Git tree too big. Please remove some files in the repository.'); throw new Error('Git tree too big. Please remove some files in the repository.');
} }
@ -198,13 +198,13 @@ export default {
branch, branch,
path, path,
}) { }) {
const body = await repoRequest(token, owner, repo, { const { sha, content } = await repoRequest(token, owner, repo, {
url: `contents/${encodeURIComponent(path)}`, url: `contents/${encodeURIComponent(path)}`,
params: { ref: branch }, params: { ref: branch },
}); });
return { return {
sha: body.sha, sha,
content: utils.decodeBase64(body.content), content: utils.decodeBase64(content),
}; };
}, },

View File

@ -190,7 +190,10 @@ export default {
if (store.state.offline) { if (store.state.offline) {
throw err; throw err;
} }
await store.dispatch('modal/providerRedirection', { providerName: 'Google' }); await store.dispatch('modal/open', {
type: 'providerRedirection',
name: 'Google',
});
return this.startOauth2(mergedScopes, sub); return this.startOauth2(mergedScopes, sub);
} }
}, },
@ -231,7 +234,7 @@ export default {
mediaType = null, mediaType = null,
fileId = null, fileId = null,
oldParents = null, oldParents = null,
ifNotTooLate = cb => res => cb(res), ifNotTooLate = cb => cb(),
}) { }) {
// Refreshing a token can take a while if an oauth window pops up, make sure it's not too late // Refreshing a token can take a while if an oauth window pops up, make sure it's not too late
return ifNotTooLate(() => { return ifNotTooLate(() => {
@ -374,7 +377,7 @@ export default {
/** /**
* https://developers.google.com/drive/v3/reference/files/delete * https://developers.google.com/drive/v3/reference/files/delete
*/ */
async $removeFile(refreshedToken, id, ifNotTooLate = cb => res => cb(res)) { async $removeFile(refreshedToken, id, ifNotTooLate = cb => cb()) {
// Refreshing a token can take a while if an oauth window pops up, so check if it's too late // Refreshing a token can take a while if an oauth window pops up, so check if it's too late
return ifNotTooLate(() => this.$request(refreshedToken, { return ifNotTooLate(() => this.$request(refreshedToken, {
method: 'DELETE', method: 'DELETE',
@ -388,7 +391,7 @@ export default {
const refreshedToken = await this.refreshToken(token, getDriveScopes(token)); const refreshedToken = await this.refreshToken(token, getDriveScopes(token));
return this.$removeFile(refreshedToken, id, ifNotTooLate); return this.$removeFile(refreshedToken, id, ifNotTooLate);
}, },
async removeAppDataFile(token, id, ifNotTooLate = cb => res => cb(res)) { async removeAppDataFile(token, id, ifNotTooLate = cb => cb()) {
const refreshedToken = await this.refreshToken(token, driveAppDataScopes); const refreshedToken = await this.refreshToken(token, driveAppDataScopes);
return this.$removeFile(refreshedToken, id, ifNotTooLate); return this.$removeFile(refreshedToken, id, ifNotTooLate);
}, },
@ -599,7 +602,7 @@ export default {
} }
const refreshedToken = await this.refreshToken(token, scopes); const refreshedToken = await this.refreshToken(token, scopes);
const { google } = window; const { google } = window;
return Promise((resolve) => { return new Promise((resolve) => {
let picker; let picker;
const pickerBuilder = new google.picker.PickerBuilder() const pickerBuilder = new google.picker.PickerBuilder()
.setOAuthToken(refreshedToken.accessToken) .setOAuthToken(refreshedToken.accessToken)

View File

@ -57,7 +57,10 @@ export default {
} }
// Existing token is going to expire. // Existing token is going to expire.
// Try to get a new token in background // Try to get a new token in background
await store.dispatch('modal/providerRedirection', { providerName: 'WordPress' }); await store.dispatch('modal/open', {
type: 'providerRedirection',
name: 'WordPress',
});
return this.startOauth2(sub); return this.startOauth2(sub);
}, },
addAccount(fullAccess = false) { addAccount(fullAccess = false) {

View File

@ -103,24 +103,22 @@ const requestPublish = () => {
return; return;
} }
store.dispatch('queue/enqueuePublishRequest', () => new Promise((resolve, reject) => { store.dispatch('queue/enqueuePublishRequest', async () => {
let intervalId; let intervalId;
const attempt = () => { const attempt = async () => {
// Only start publishing when these conditions are met // Only start publishing when these conditions are met
if (networkSvc.isUserActive()) { if (networkSvc.isUserActive()) {
clearInterval(intervalId); clearInterval(intervalId);
if (!hasCurrentFilePublishLocations()) { if (!hasCurrentFilePublishLocations()) {
// Cancel sync // Cancel sync
reject(new Error('Publish not possible.')); throw new Error('Publish not possible.');
return;
} }
publishFile(store.getters['file/current'].id) await publishFile(store.getters['file/current'].id);
.then(resolve, reject);
} }
}; };
intervalId = utils.setInterval(() => attempt(), 1000); intervalId = utils.setInterval(() => attempt(), 1000);
attempt(); return attempt();
})); });
}; };
const createPublishLocation = (publishLocation) => { const createPublishLocation = (publishLocation) => {

View File

@ -206,24 +206,30 @@ const createSyncLocation = (syncLocation) => {
); );
}; };
// Prevent from sending new data too long after old data has been fetched /**
* Prevent from sending new data too long after old data has been fetched.
*/
const tooLateChecker = (timeout) => { const tooLateChecker = (timeout) => {
const tooLateAfter = Date.now() + timeout; const tooLateAfter = Date.now() + timeout;
return cb => (res) => { return (cb) => {
if (tooLateAfter < Date.now()) { if (tooLateAfter < Date.now()) {
throw new Error('TOO_LATE'); throw new Error('TOO_LATE');
} }
return cb(res); return cb();
}; };
}; };
/**
* Return true if file is in the temp folder or it's a welcome file.
*/
const isTempFile = (fileId) => { const isTempFile = (fileId) => {
if (store.getters['data/syncDataByItemId'][`${fileId}/content`]) { const contentId = `${fileId}/content`;
if (store.getters['data/syncDataByItemId'][contentId]) {
// If file has already been synced, it's not a temp file // If file has already been synced, it's not a temp file
return false; return false;
} }
const file = store.state.file.itemMap[fileId]; const file = store.state.file.itemMap[fileId];
const content = store.state.content.itemMap[`${fileId}/content`]; const content = store.state.content.itemMap[contentId];
if (!file || !content) { if (!file || !content) {
return false; return false;
} }
@ -254,15 +260,17 @@ class SyncContext {
* Sync one file with all its locations. * Sync one file with all its locations.
*/ */
const syncFile = async (fileId, syncContext = new SyncContext()) => { const syncFile = async (fileId, syncContext = new SyncContext()) => {
syncContext.attempted[`${fileId}/content`] = true; const contentId = `${fileId}/content`;
syncContext.attempted[contentId] = true;
await localDbSvc.loadSyncedContent(fileId); await localDbSvc.loadSyncedContent(fileId);
try { try {
await localDbSvc.loadItem(`${fileId}/content`); await localDbSvc.loadItem(contentId);
} catch (e) { } catch (e) {
// Item may not exist if content has not been downloaded yet // Item may not exist if content has not been downloaded yet
} }
const getContent = () => store.state.content.itemMap[`${fileId}/content`];
const getContent = () => store.state.content.itemMap[contentId];
const getSyncedContent = () => upgradeSyncedContent(store.state.syncedContent.itemMap[`${fileId}/syncedContent`]); const getSyncedContent = () => upgradeSyncedContent(store.state.syncedContent.itemMap[`${fileId}/syncedContent`]);
const getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId]; const getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId];
@ -288,28 +296,88 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
return; return;
} }
const downloadContent = async () => {
// On simple provider, call simply downloadContent
if (syncLocation.id !== 'main') {
return provider.downloadContent(token, syncLocation);
}
// On workspace provider, call downloadWorkspaceContent
const oldSyncData = provider.isGit
? store.getters['data/syncData'][store.getters.itemGitPaths[contentId]]
: store.getters['data/syncDataByItemId'][contentId];
if (!oldSyncData) {
return null;
}
const { item, syncData } = await provider.downloadWorkspaceContent(token, oldSyncData);
if (!item) {
return null;
}
// Update sync data if changed
if (syncData
&& utils.serializeObject(oldSyncData) !== utils.serializeObject(syncData)
) {
store.dispatch('data/patchSyncData', {
[syncData.id]: syncData,
});
}
return item;
};
const uploadContent = async (item, ifNotTooLate) => {
// On simple provider, call simply uploadContent
if (syncLocation.id !== 'main') {
return provider.uploadContent(token, item, syncLocation, ifNotTooLate);
}
// On workspace provider, call uploadWorkspaceContent
const oldSyncData = provider.isGit
? store.getters['data/syncData'][store.getters.itemGitPaths[contentId]]
: store.getters['data/syncDataByItemId'][contentId];
if (oldSyncData && oldSyncData.hash === item.hash) {
return syncLocation;
}
const syncData = await provider.uploadWorkspaceContent(
token,
item,
oldSyncData,
ifNotTooLate,
);
// Update sync data if changed
if (syncData
&& utils.serializeObject(oldSyncData) !== utils.serializeObject(syncData)
) {
store.dispatch('data/patchSyncData', {
[syncData.id]: syncData,
});
}
// Return syncLocation
return syncLocation;
};
const doSyncLocation = async () => { const doSyncLocation = async () => {
const serverContent = await provider.downloadContent(token, syncLocation); const serverContent = await downloadContent(token, syncLocation);
const syncedContent = getSyncedContent(); const syncedContent = getSyncedContent();
const syncHistoryItem = getSyncHistoryItem(syncLocation.id); const syncHistoryItem = getSyncHistoryItem(syncLocation.id);
let mergedContent = (() => {
// Merge content
let mergedContent;
const clientContent = utils.deepCopy(getContent()); const clientContent = utils.deepCopy(getContent());
if (!clientContent) { if (!clientContent) {
return utils.deepCopy(serverContent || null); mergedContent = utils.deepCopy(serverContent || null);
} } else if (!serverContent // If sync location has not been created yet
if (!serverContent) { // Or server and client contents are synced
// Sync location has not been created yet || serverContent.hash === clientContent.hash
return clientContent; // Or server content has not changed or has already been merged
} || syncedContent.historyData[serverContent.hash]
if (serverContent.hash === clientContent.hash) { ) {
// Server and client contents are synced mergedContent = clientContent;
return clientContent; } else {
} // Perform a merge with last merged content if any, or perform a simple fusion otherwise
if (syncedContent.historyData[serverContent.hash]) {
// Server content has not changed or has already been merged
return clientContent;
}
// Perform a merge with last merged content if any, or a simple fusion otherwise
let lastMergedContent = utils.someResult( let lastMergedContent = utils.someResult(
serverContent.history, serverContent.history,
hash => syncedContent.historyData[hash], hash => syncedContent.historyData[hash],
@ -317,16 +385,15 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
if (!lastMergedContent && syncHistoryItem) { if (!lastMergedContent && syncHistoryItem) {
lastMergedContent = syncedContent.historyData[syncHistoryItem[LAST_MERGED]]; lastMergedContent = syncedContent.historyData[syncHistoryItem[LAST_MERGED]];
} }
return diffUtils.mergeContent(serverContent, clientContent, lastMergedContent); mergedContent = diffUtils.mergeContent(serverContent, clientContent, lastMergedContent);
})(); }
if (!mergedContent) { if (!mergedContent) {
return; return;
} }
// Update or set content in store // Update or set content in store
store.commit('content/setItem', { store.commit('content/setItem', {
id: `${fileId}/content`, id: contentId,
text: utils.sanitizeText(mergedContent.text), text: utils.sanitizeText(mergedContent.text),
properties: utils.sanitizeText(mergedContent.properties), properties: utils.sanitizeText(mergedContent.properties),
discussions: mergedContent.discussions, discussions: mergedContent.discussions,
@ -356,8 +423,7 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
} }
} }
// Store last sent if it's in the server history, // Update synced content
// and merged content which will be sent if different
const newSyncedContent = utils.deepCopy(syncedContent); const newSyncedContent = utils.deepCopy(syncedContent);
const newSyncHistoryItem = newSyncedContent.syncHistory[syncLocation.id] || []; const newSyncHistoryItem = newSyncedContent.syncHistory[syncLocation.id] || [];
newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem; newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem;
@ -384,10 +450,14 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
} }
// Upload merged content // Upload merged content
const syncLocationToStore = await provider.uploadContent(token, { const item = {
...mergedContent, ...mergedContent,
history: mergedContentHistory.slice(0, maxContentHistory), history: mergedContentHistory.slice(0, maxContentHistory),
}, syncLocation, tooLateChecker(restartContentSyncAfter)); };
const syncLocationToStore = await uploadContent(
item,
tooLateChecker(restartContentSyncAfter),
);
// Replace sync location if modified // Replace sync location if modified
if (utils.serializeObject(syncLocation) !== if (utils.serializeObject(syncLocation) !==
@ -437,14 +507,30 @@ const syncDataItem = async (dataId) => {
const getItem = () => store.state.data.itemMap[dataId] const getItem = () => store.state.data.itemMap[dataId]
|| store.state.data.lsItemMap[dataId]; || store.state.data.lsItemMap[dataId];
const item = getItem(); const oldItem = getItem();
const syncData = store.getters['data/syncDataByItemId'][dataId]; const oldSyncData = store.getters['data/syncDataByItemId'][dataId];
// Sync if item hash and syncData hash are out of sync // Sync if item hash and syncData hash are out of sync
if (syncData && item && item.hash === syncData.hash) { if (oldSyncData && oldItem && oldItem.hash === oldSyncData.hash) {
return; return;
} }
const serverItem = await workspaceProvider.downloadData(dataId); const token = workspaceProvider.getToken();
const { item, syncData } = await workspaceProvider.downloadWorkspaceData(
token,
dataId,
oldSyncData,
);
// Update sync data if changed
if (syncData
&& utils.serializeObject(oldSyncData) !== utils.serializeObject(syncData)
) {
store.dispatch('data/patchSyncData', {
[syncData.id]: syncData,
});
}
const serverItem = item;
const dataSyncData = store.getters['data/dataSyncData'][dataId]; const dataSyncData = store.getters['data/dataSyncData'][dataId];
let mergedItem = (() => { let mergedItem = (() => {
const clientItem = utils.deepCopy(getItem()); const clientItem = utils.deepCopy(getItem());
@ -487,7 +573,25 @@ const syncDataItem = async (dataId) => {
if (serverItem && serverItem.hash === mergedItem.hash) { if (serverItem && serverItem.hash === mergedItem.hash) {
return; return;
} }
await workspaceProvider.uploadData(mergedItem, tooLateChecker(restartContentSyncAfter));
// Upload merged data item
const newSyncData = await workspaceProvider.uploadWorkspaceData(
token,
mergedItem,
syncData,
tooLateChecker(restartContentSyncAfter),
);
// Update sync data if changed
if (newSyncData
&& utils.serializeObject(syncData) !== utils.serializeObject(newSyncData)
) {
store.dispatch('data/patchSyncData', {
[newSyncData.id]: newSyncData,
});
}
// Update data sync data
store.dispatch('data/patchDataSyncData', { store.dispatch('data/patchDataSyncData', {
[dataId]: utils.deepCopy(store.getters['data/syncDataByItemId'][dataId]), [dataId]: utils.deepCopy(store.getters['data/syncDataByItemId'][dataId]),
}); });
@ -523,7 +627,7 @@ const syncWorkspace = async () => {
const ifNotTooLate = tooLateChecker(restartSyncAfter); const ifNotTooLate = tooLateChecker(restartSyncAfter);
// Called until no item to save // Called until no item to save
const saveNextItem = ifNotTooLate(async () => { const saveNextItem = () => ifNotTooLate(async () => {
const storeItemMap = { const storeItemMap = {
...store.state.file.itemMap, ...store.state.file.itemMap,
...store.state.folder.itemMap, ...store.state.folder.itemMap,
@ -531,16 +635,25 @@ const syncWorkspace = async () => {
...store.state.publishLocation.itemMap, ...store.state.publishLocation.itemMap,
// Deal with contents and data later // Deal with contents and data later
}; };
let getSyncData;
if (workspaceProvider.isGit) {
const syncData = store.getters['data/syncData'];
getSyncData = id => syncData[store.getters.itemGitPaths[id]];
} else {
const syncDataByItemId = store.getters['data/syncDataByItemId']; const syncDataByItemId = store.getters['data/syncDataByItemId'];
getSyncData = id => syncDataByItemId[id];
}
const [changedItem, syncDataToUpdate] = utils.someResult( const [changedItem, syncDataToUpdate] = utils.someResult(
Object.entries(storeItemMap), Object.entries(storeItemMap),
([id, item]) => { ([id, item]) => {
const existingSyncData = syncDataByItemId[id]; const existingSyncData = getSyncData(id);
if ((!existingSyncData || existingSyncData.hash !== item.hash) if ((!existingSyncData || existingSyncData.hash !== item.hash)
// Add file/folder if parent has been added // Add file/folder if parent has been added
&& (!storeItemMap[item.parentId] || syncDataByItemId[item.parentId]) && (!storeItemMap[item.parentId] || getSyncData(item.parentId))
// Add file if content has been added // Add file if content has been added
&& (item.type !== 'file' || syncDataByItemId[`${id}/content`]) && (item.type !== 'file' || getSyncData(`${id}/content`))
) { ) {
return [item, existingSyncData]; return [item, existingSyncData];
} }
@ -550,7 +663,7 @@ const syncWorkspace = async () => {
if (changedItem) { if (changedItem) {
const resultSyncData = await workspaceProvider const resultSyncData = await workspaceProvider
.saveSimpleItem( .saveWorkspaceItem(
// Use deepCopy to freeze objects // Use deepCopy to freeze objects
utils.deepCopy(changedItem), utils.deepCopy(changedItem),
utils.deepCopy(syncDataToUpdate), utils.deepCopy(syncDataToUpdate),
@ -565,29 +678,39 @@ const syncWorkspace = async () => {
await saveNextItem(); await saveNextItem();
// Called until no item to remove // Called until no item to remove
const removeNextItem = ifNotTooLate(async () => { const removeNextItem = () => ifNotTooLate(async () => {
const storeItemMap = { let getItem;
...store.state.file.itemMap, let getFileItem;
...store.state.folder.itemMap, if (workspaceProvider.isGit) {
...store.state.syncLocation.itemMap, const { gitPathItems } = store.getters;
...store.state.publishLocation.itemMap, getItem = syncData => gitPathItems[syncData.id];
...store.state.content.itemMap, getFileItem = syncData => gitPathItems[syncData.id.slice(1)]; // Remove leading /
}; } else {
const { allItemMap } = store.getters;
getItem = syncData => allItemMap[syncData.itemId];
getFileItem = syncData => allItemMap[syncData.itemId.split('/')[0]];
}
const syncData = store.getters['data/syncData']; const syncData = store.getters['data/syncData'];
const syncDataToRemove = utils.deepCopy(utils.someResult( const syncDataToRemove = utils.deepCopy(utils.someResult(
Object.values(syncData), Object.values(syncData),
existingSyncData => !storeItemMap[existingSyncData.itemId] (existingSyncData) => {
if (!getItem(existingSyncData)
// We don't want to delete data items, especially on first sync // We don't want to delete data items, especially on first sync
&& existingSyncData.type !== 'data' && existingSyncData.type !== 'data'
// Remove content only if file has been removed // Remove content only if file has been removed
&& (existingSyncData.type !== 'content' && (existingSyncData.type !== 'content'
|| !storeItemMap[existingSyncData.itemId.split('/')[0]]) || !getFileItem(existingSyncData))
&& existingSyncData, ) {
return existingSyncData;
}
return null;
},
)); ));
if (syncDataToRemove) { if (syncDataToRemove) {
// Use deepCopy to freeze objects // Use deepCopy to freeze objects
await workspaceProvider.removeItem(syncDataToRemove, ifNotTooLate); await workspaceProvider.removeWorkspaceItem(syncDataToRemove, ifNotTooLate);
const syncDataCopy = { ...store.getters['data/syncData'] }; const syncDataCopy = { ...store.getters['data/syncData'] };
delete syncDataCopy[syncDataToRemove.id]; delete syncDataCopy[syncDataToRemove.id];
store.dispatch('data/setSyncData', syncDataCopy); store.dispatch('data/setSyncData', syncDataCopy);
@ -608,11 +731,22 @@ const syncWorkspace = async () => {
...Object.keys(localDbSvc.hashMap.content), ...Object.keys(localDbSvc.hashMap.content),
...store.getters['file/items'].map(file => `${file.id}/content`), ...store.getters['file/items'].map(file => `${file.id}/content`),
])]; ])];
const contentMap = store.state.content.itemMap;
const syncDataById = store.getters['data/syncData'];
let getSyncData;
if (workspaceProvider.isGit) {
const { itemGitPaths } = store.getters;
getSyncData = contentId => syncDataById[itemGitPaths[contentId]];
} else {
const syncDataByItemId = store.getters['data/syncDataByItemId'];
getSyncData = contentId => syncDataByItemId[contentId];
}
return utils.someResult(contentIds, (contentId) => { return utils.someResult(contentIds, (contentId) => {
// Get content hash from itemMap or from localDbSvc if not loaded // Get content hash from itemMap or from localDbSvc if not loaded
const loadedContent = store.state.content.itemMap[contentId]; const loadedContent = contentMap[contentId];
const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId]; const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId];
const syncData = store.getters['data/syncDataByItemId'][contentId]; const syncData = getSyncData(contentId);
if ( if (
// Sync if content syncing was not attempted yet // Sync if content syncing was not attempted yet
!syncContext.attempted[contentId] && !syncContext.attempted[contentId] &&
@ -721,7 +855,7 @@ const requestSync = () => {
}; };
intervalId = utils.setInterval(() => attempt(), 1000); intervalId = utils.setInterval(() => attempt(), 1000);
attempt(); return attempt();
}); });
}; };

View File

@ -23,7 +23,6 @@ const parseQueryParams = (params) => {
return result; return result;
}; };
// For utils.computeProperties() // For utils.computeProperties()
const deepOverride = (obj, opt) => { const deepOverride = (obj, opt) => {
if (obj === undefined) { if (obj === undefined) {
@ -172,10 +171,12 @@ export default {
return Math.abs(this.hash(this.serializeObject(params))).toString(36); return Math.abs(this.hash(this.serializeObject(params))).toString(36);
}, },
encodeBase64(str, urlSafe = false) { encodeBase64(str, urlSafe = false) {
const result = btoa(encodeURIComponent(str).replace( const uriEncodedStr = encodeURIComponent(str);
const utf8Str = uriEncodedStr.replace(
/%([0-9A-F]{2})/g, /%([0-9A-F]{2})/g,
(match, p1) => String.fromCharCode(`0x${p1}`), (match, p1) => String.fromCharCode(`0x${p1}`),
)); );
const result = btoa(utf8Str);
if (!urlSafe) { if (!urlSafe) {
return result; return result;
} }
@ -187,10 +188,12 @@ export default {
decodeBase64(str) { decodeBase64(str) {
// In case of URL safe base64 // In case of URL safe base64
const sanitizedStr = str.replace(/_/g, '/').replace(/-/g, '+'); const sanitizedStr = str.replace(/_/g, '/').replace(/-/g, '+');
return decodeURIComponent(atob(sanitizedStr) const utf8Str = atob(sanitizedStr);
const uriEncodedStr = utf8Str
.split('') .split('')
.map(c => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`) .map(c => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`)
.join('')); .join('');
return decodeURIComponent(uriEncodedStr);
}, },
computeProperties(yamlProperties) { computeProperties(yamlProperties) {
let properties = {}; let properties = {};

View File

@ -82,7 +82,7 @@ module.actions = {
}) { }) {
const { revisionContent } = state; const { revisionContent } = state;
if (revisionContent) { if (revisionContent) {
await dispatch('modal/fileRestoration', null, { root: true }); await dispatch('modal/open', 'fileRestoration', { root: true });
// Close revision // Close revision
commit('setRevisionContent'); commit('setRevisionContent');
const currentContent = utils.deepCopy(getters.current); const currentContent = utils.deepCopy(getters.current);

View File

@ -26,6 +26,7 @@ export default {
setTimeout(() => { setTimeout(() => {
// Take the size of the context menu and place it // Take the size of the context menu and place it
const elt = document.querySelector('.context-menu__inner'); const elt = document.querySelector('.context-menu__inner');
if (elt) {
const height = elt.offsetHeight; const height = elt.offsetHeight;
if (coordinates.top + height > rootState.layout.bodyHeight) { if (coordinates.top + height > rootState.layout.bodyHeight) {
coordinates.top -= height; coordinates.top -= height;
@ -41,6 +42,7 @@ export default {
coordinates.left = 0; coordinates.left = 0;
} }
commit('setCoordinates', coordinates); commit('setCoordinates', coordinates);
}
}, 1); }, 1);
return new Promise(resolve => commit('setResolve', resolve)); return new Promise(resolve => commit('setResolve', resolve));

View File

@ -135,7 +135,7 @@ export default {
const loginToken = rootGetters['workspace/loginToken']; const loginToken = rootGetters['workspace/loginToken'];
if (!loginToken) { if (!loginToken) {
try { try {
await dispatch('modal/signInForComment', null, { root: true }); await dispatch('modal/open', 'signInForComment', { root: true });
await googleHelper.signin(); await googleHelper.signin();
syncSvc.requestSync(); syncSvc.requestSync();
await dispatch('createNewDiscussion', selection); await dispatch('createNewDiscussion', selection);

View File

@ -18,11 +18,10 @@ const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
const compare = (node1, node2) => collator.compare(node1.item.name, node2.item.name); const compare = (node1, node2) => collator.compare(node1.item.name, node2.item.name);
class Node { class Node {
constructor(item, locations = [], isFolder = false, isRoot = false) { constructor(item, locations = [], isFolder = false) {
this.item = item; this.item = item;
this.locations = locations; this.locations = locations;
this.isFolder = isFolder; this.isFolder = isFolder;
this.isRoot = isRoot;
if (isFolder) { if (isFolder) {
this.folders = []; this.folders = [];
this.files = []; this.files = [];
@ -84,7 +83,8 @@ export default {
}, },
getters: { getters: {
nodeStructure: (state, getters, rootState, rootGetters) => { nodeStructure: (state, getters, rootState, rootGetters) => {
const rootNode = new Node(emptyFolder(), [], true, true); const rootNode = new Node(emptyFolder(), [], true);
rootNode.isRoot = true;
// Create Trash node // Create Trash node
const trashFolderNode = new Node(emptyFolder(), [], true); const trashFolderNode = new Node(emptyFolder(), [], true);

View File

@ -119,6 +119,41 @@ const store = new Vuex.Store({
}); });
return result; return result;
}, },
itemGitPaths: (state, { allItemMap, itemPaths }) => {
const result = {};
Object.entries(allItemMap).forEach(([id, item]) => {
if (item.type === 'data') {
result[id] = `.stackedit-data/${id}.json`;
} else if (item.type === 'file') {
result[id] = `${itemPaths[id]}.md`;
} else if (item.type === 'content') {
const [fileId] = id.split('/');
result[id] = `/${itemPaths[fileId]}.md`;
} else if (item.type === 'folder') {
result[id] = itemPaths[id];
} else if (item.type === 'syncLocation' || item.type === 'publishLocation') {
// locations are stored as paths
const encodedItem = utils.encodeBase64(utils.serializeObject({
...item,
id: undefined,
type: undefined,
fileId: undefined,
}), true);
const extension = item.type === 'syncLocation' ? 'sync' : 'publish';
result[id] = `${itemPaths[item.fileId]}.${encodedItem}.${extension}`;
}
});
return result;
},
gitPathItems: (state, { allItemMap, itemGitPaths }) => {
const result = {};
Object.entries(itemGitPaths).forEach(([id, path]) => {
const items = result[path] || [];
items.push(allItemMap[id]);
result[path] = items;
});
return result;
},
isSponsor: ({ light, monetizeSponsor }, getters) => { isSponsor: ({ light, monetizeSponsor }, getters) => {
const sponsorToken = getters['workspace/sponsorToken']; const sponsorToken = getters['workspace/sponsorToken'];
return light || monetizeSponsor || (sponsorToken && sponsorToken.isSponsor); return light || monetizeSponsor || (sponsorToken && sponsorToken.isSponsor);

View File

@ -36,100 +36,5 @@ export default {
commit('setHidden', false); commit('setHidden', false);
} }
}, },
folderDeletion: ({ dispatch }, item) => dispatch('open', {
content: `<p>You are about to delete the folder <b>${item.name}</b>. Its files will be moved to Trash. Are you sure?</p>`,
resolveText: 'Yes, delete',
rejectText: 'No',
}),
tempFileDeletion: ({ dispatch }, item) => dispatch('open', {
content: `<p>You are about to permanently delete the temporary file <b>${item.name}</b>. Are you sure?</p>`,
resolveText: 'Yes, delete',
rejectText: 'No',
}),
tempFolderDeletion: ({ dispatch }) => dispatch('open', {
content: '<p>You are about to permanently delete all the temporary files. Are you sure?</p>',
resolveText: 'Yes, delete all',
rejectText: 'No',
}),
discussionDeletion: ({ dispatch }) => dispatch('open', {
content: '<p>You are about to delete a discussion. Are you sure?</p>',
resolveText: 'Yes, delete',
rejectText: 'No',
}),
commentDeletion: ({ dispatch }) => dispatch('open', {
content: '<p>You are about to delete a comment. Are you sure?</p>',
resolveText: 'Yes, delete',
rejectText: 'No',
}),
trashDeletion: ({ dispatch }) => dispatch('open', {
content: '<p>Files in the trash are automatically deleted after 7 days of inactivity.</p>',
rejectText: 'Ok',
}),
fileRestoration: ({ dispatch }) => dispatch('open', {
content: '<p>You are about to revert some changes. Are you sure?</p>',
resolveText: 'Yes, revert',
rejectText: 'No',
}),
unauthorizedName: ({ dispatch }, name) => dispatch('open', {
content: `<p><b>${name}</b> is not an authorized name.</p>`,
rejectText: 'Ok',
}),
stripName: ({ dispatch }, name) => dispatch('open', {
content: `<p><b>${name}</b> contains illegal characters. Do you want to strip them?</p>`,
resolveText: 'Yes, strip',
rejectText: 'No',
}),
pathConflict: ({ dispatch }, name) => dispatch('open', {
content: `<p><b>${name}</b> already exists. Do you want to add a suffix?</p>`,
resolveText: 'Yes, add suffix',
rejectText: 'No',
}),
removeWorkspace: ({ dispatch }) => dispatch('open', {
content: '<p>You are about to remove a workspace locally. Are you sure?</p>',
resolveText: 'Yes, remove',
rejectText: 'No',
}),
reset: ({ dispatch }) => dispatch('open', {
content: '<p>This will clean all your workspaces locally. Are you sure?</p>',
resolveText: 'Yes, clean',
rejectText: 'No',
}),
providerRedirection: ({ dispatch }, { providerName }) => dispatch('open', {
content: `<p>You are about to navigate to the <b>${providerName}</b> authorization page.</p>`,
resolveText: 'Ok, go on',
rejectText: 'Cancel',
}),
workspaceGoogleRedirection: ({ dispatch }) => dispatch('open', {
content: '<p>StackEdit needs full Google Drive access to open this workspace.</p>',
resolveText: 'Ok, grant',
rejectText: 'Cancel',
}),
signInForSponsorship: ({ dispatch }) => dispatch('open', {
type: 'signInForSponsorship',
content: `<p>You have to sign in with Google to sponsor.</p>
<div class="modal__info"><b>Note:</b> This will sync your main workspace.</div>`,
resolveText: 'Ok, sign in',
rejectText: 'Cancel',
}),
signInForComment: ({ dispatch }) => dispatch('open', {
content: `<p>You have to sign in with Google to start commenting.</p>
<div class="modal__info"><b>Note:</b> This will sync your main workspace.</div>`,
resolveText: 'Ok, sign in',
rejectText: 'Cancel',
}),
signInForHistory: ({ dispatch }) => dispatch('open', {
content: `<p>You have to sign in with Google to enable revision history.</p>
<div class="modal__info"><b>Note:</b> This will sync your main workspace.</div>`,
resolveText: 'Ok, sign in',
rejectText: 'Cancel',
}),
sponsorOnly: ({ dispatch }) => dispatch('open', {
content: '<p>This feature is restricted to sponsors as it relies on server resources.</p>',
rejectText: 'Ok, I understand',
}),
paymentSuccess: ({ dispatch }) => dispatch('open', {
content: '<p>Thank you for your payment! Your sponsorship will be active in a minute.</p>',
rejectText: 'Ok',
}),
}, },
}; };

View File

@ -1,4 +1,4 @@
const itemTimeout = 5000; const defaultTimeout = 5000;
export default { export default {
namespaced: true, namespaced: true,
@ -14,8 +14,10 @@ export default {
showItem({ state, commit }, item) { showItem({ state, commit }, item) {
if (state.items.every(other => other.type !== item.type || other.content !== item.content)) { if (state.items.every(other => other.type !== item.type || other.content !== item.content)) {
commit('setItems', [...state.items, item]); commit('setItems', [...state.items, item]);
setTimeout(() => setTimeout(
commit('setItems', state.items.filter(otherItem => otherItem !== item)), itemTimeout); () => commit('setItems', state.items.filter(otherItem => otherItem !== item)),
item.timeout || defaultTimeout,
);
} }
}, },
info({ dispatch }, info) { info({ dispatch }, info) {
@ -25,9 +27,7 @@ export default {
}); });
}, },
error({ dispatch, rootState }, error) { error({ dispatch, rootState }, error) {
const item = { const item = { type: 'error' };
type: 'error',
};
if (error) { if (error) {
if (error.message) { if (error.message) {
item.content = error.message; item.content = error.message;

View File

@ -15,6 +15,10 @@
<meta name="google-site-verification" content="iWDn0T2r2_bDQWp_nW23MGePbO9X0M8wQSzbOU70pFQ" /> <meta name="google-site-verification" content="iWDn0T2r2_bDQWp_nW23MGePbO9X0M8wQSzbOU70pFQ" />
<link rel="stylesheet" href="https://stackedit.io/style.css"> <link rel="stylesheet" href="https://stackedit.io/style.css">
<style> <style>
body {
background-color: #fbfbfb;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -244,7 +248,14 @@
.image { .image {
display: block; display: block;
margin: 1em auto; margin: 1em auto;
border: 2px solid #f3f3f3;
border-radius: 2px; border-radius: 2px;
background-color: #fff;
}
.image img {
display: block;
margin: 0.5em auto;
} }
</style> </style>
<script> <script>
@ -295,7 +306,9 @@
</div> </div>
</div> </div>
<div class="column"> <div class="column">
<img class="image" width="230" src="static/landing/syntax-highlighting.gif"> <div class="image" style="width: 260px">
<img width="230" src="static/landing/syntax-highlighting.gif">
</div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@ -339,7 +352,7 @@
<div class="column"> <div class="column">
<div class="feature"> <div class="feature">
<h3>Collaborate</h3> <h3>Collaborate</h3>
<p>With StackEdit, you can share collaborative workspaces, thanks to the Google Drive synchronization mechanism. If two collaborators are working on the same file at the same time, StackEdit takes care of merging the changes.</p> <p>With StackEdit, you can share collaborative workspaces, thanks to the synchronization mechanism. If two collaborators are working on the same file at the same time, StackEdit takes care of merging the changes.</p>
</div> </div>
<img class="image" width="300" src="static/landing/workspace.png"> <img class="image" width="300" src="static/landing/workspace.png">
</div> </div>
@ -361,7 +374,9 @@
<div class="row"> <div class="row">
<div class="column"> <div class="column">
<br> <br>
<img class="image" width="230" src="static/landing/gfm.png"> <div class="image" style="width: 250px">
<img width="230" src="static/landing/gfm.png">
</div>
</div> </div>
<div class="column"> <div class="column">
<div class="feature"> <div class="feature">
@ -373,7 +388,9 @@
<div class="row"> <div class="row">
<div class="column"> <div class="column">
<br> <br>
<img class="image" width="250" src="static/landing/katex.gif"> <div class="image" style="width: 270px">
<img width="250" src="static/landing/katex.gif">
</div>
</div> </div>
<div class="column"> <div class="column">
<div class="feature"> <div class="feature">
@ -384,7 +401,9 @@
</div> </div>
<div class="row"> <div class="row">
<div class="column"> <div class="column">
<img class="image" width="280" src="static/landing/mermaid.gif"> <div class="image" style="width: 300px">
<img width="280" src="static/landing/mermaid.gif">
</div>
</div> </div>
<div class="column"> <div class="column">
<div class="feature"> <div class="feature">

8
test/unit/.eslintrc Normal file
View File

@ -0,0 +1,8 @@
{
"env": {
"jest": true
},
"extends": [
"../../.eslintrc.js"
]
}

35
test/unit/jest.conf.js Normal file
View File

@ -0,0 +1,35 @@
const path = require('path');
module.exports = {
rootDir: path.resolve(__dirname, '../../'),
moduleFileExtensions: [
'js',
'json',
'vue',
],
moduleNameMapper: {
'\\.(css|scss)$': 'identity-obj-proxy',
'^!raw-loader!': 'identity-obj-proxy',
'^worker-loader!\\./templateWorker\\.js$': '<rootDir>/test/unit/mocks/templateWorkerMock',
},
transform: {
'^.+\\.js$': '<rootDir>/node_modules/babel-jest',
'.*\\.(vue)$': '<rootDir>/node_modules/vue-jest',
'.*\\.(yml|html|md)$': 'jest-raw-loader',
},
snapshotSerializers: ['<rootDir>/node_modules/jest-serializer-vue'],
setupFiles: [
'<rootDir>/test/unit/setup',
],
coverageDirectory: '<rootDir>/test/unit/coverage',
collectCoverageFrom: [
'src/**/*.{js,vue}',
'!src/main.js',
'!**/node_modules/**',
],
globals: {
GOOGLE_CLIENT_ID: 'GOOGLE_CLIENT_ID',
GITHUB_CLIENT_ID: 'GITHUB_CLIENT_ID',
NODE_ENV: 'production',
},
};

View File

@ -0,0 +1,7 @@
window.crypto = {
getRandomValues(array) {
for (let i = 0; i < array.length; i += 1) {
array[i] = Math.floor(Math.random() * 1000000);
}
},
};

View File

@ -0,0 +1,9 @@
const store = {};
window.localStorage = {
getItem(key) {
return store[key] || null;
},
setItem(key, value) {
store[key] = value.toString();
},
};

View File

@ -0,0 +1,6 @@
/* eslint-disable class-methods-use-this */
class MutationObserver {
observe() {
}
}
window.MutationObserver = MutationObserver;

View File

@ -0,0 +1 @@
module.exports = 'test-file-stub';

5
test/unit/setup.js Normal file
View File

@ -0,0 +1,5 @@
import Vue from 'vue';
import './mocks/cryptoMock';
import './mocks/mutationObserverMock';
Vue.config.productionTip = false;

View File

@ -0,0 +1,41 @@
import ButtonBar from '../../../../src/components/ButtonBar';
import store from '../../../../src/store';
import specUtils from '../specUtils';
describe('ButtonBar.vue', () => {
it('should toggle the navigation bar', () => specUtils.checkToggler(
ButtonBar,
wrapper => wrapper.find('.button-bar__button--navigation-bar-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].showNavigationBar,
));
it('should toggle the side preview', () => specUtils.checkToggler(
ButtonBar,
wrapper => wrapper.find('.button-bar__button--side-preview-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].showSidePreview,
));
it('should toggle the editor', () => specUtils.checkToggler(
ButtonBar,
wrapper => wrapper.find('.button-bar__button--editor-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].showEditor,
));
it('should toggle the focus mode', () => specUtils.checkToggler(
ButtonBar,
wrapper => wrapper.find('.button-bar__button--focus-mode-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].focusMode,
));
it('should toggle the scroll sync', () => specUtils.checkToggler(
ButtonBar,
wrapper => wrapper.find('.button-bar__button--scroll-sync-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].scrollSync,
));
it('should toggle the status bar', () => specUtils.checkToggler(
ButtonBar,
wrapper => wrapper.find('.button-bar__button--status-bar-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].showStatusBar,
));
});

View File

@ -0,0 +1,32 @@
import { shallowMount } from '@vue/test-utils';
import ContextMenu from '../../../../src/components/ContextMenu';
import store from '../../../../src/store';
import '../specUtils';
const mount = () => shallowMount(ContextMenu, { store });
describe('ContextMenu.vue', () => {
const name = 'Name';
const makeOptions = () => ({
coordinates: {
left: 0,
top: 0,
},
items: [{ name }],
});
it('should open/close itself', async () => {
const wrapper = mount();
expect(wrapper.contains('.context-menu__item')).toEqual(false);
setTimeout(() => wrapper.find('.context-menu__item').trigger('click'), 1);
const item = await store.dispatch('contextMenu/open', makeOptions());
expect(item.name).toEqual(name);
});
it('should cancel itself', async () => {
const wrapper = mount();
setTimeout(() => wrapper.trigger('click'), 1);
const item = await store.dispatch('contextMenu/open', makeOptions());
expect(item).toEqual(null);
});
});

View File

@ -0,0 +1,186 @@
import { shallowMount } from '@vue/test-utils';
import Explorer from '../../../../src/components/Explorer';
import store from '../../../../src/store';
import fileSvc from '../../../../src/services/fileSvc';
import specUtils from '../specUtils';
const mount = () => shallowMount(Explorer, { store });
const select = (id) => {
store.commit('explorer/setSelectedId', id);
expect(store.getters['explorer/selectedNode'].item.id).toEqual(id);
};
const ensureExists = file => expect(store.getters.allItemMap).toHaveProperty(file.id);
const ensureNotExists = file => expect(store.getters.allItemMap).not.toHaveProperty(file.id);
describe('Explorer.vue', () => {
it('should create new files in the root folder', () => {
expect(store.state.explorer.newChildNode.isNil).toBeTruthy();
const wrapper = mount();
wrapper.find('.side-title__button--new-file').trigger('click');
expect(store.state.explorer.newChildNode.isNil).toBeFalsy();
expect(store.state.explorer.newChildNode.item).toMatchObject({
type: 'file',
parentId: null,
});
});
it('should create new files in a folder', async () => {
const folder = await fileSvc.storeItem({ type: 'folder' });
const wrapper = mount();
select(folder.id);
wrapper.find('.side-title__button--new-file').trigger('click');
expect(store.state.explorer.newChildNode.item).toMatchObject({
type: 'file',
parentId: folder.id,
});
});
it('should not create new files in the trash folder', () => {
const wrapper = mount();
select('trash');
wrapper.find('.side-title__button--new-file').trigger('click');
expect(store.state.explorer.newChildNode.item).toMatchObject({
type: 'file',
parentId: null,
});
});
it('should create new folders in the root folder', () => {
expect(store.state.explorer.newChildNode.isNil).toBeTruthy();
const wrapper = mount();
wrapper.find('.side-title__button--new-folder').trigger('click');
expect(store.state.explorer.newChildNode.isNil).toBeFalsy();
expect(store.state.explorer.newChildNode.item).toMatchObject({
type: 'folder',
parentId: null,
});
});
it('should create new folders in a folder', async () => {
const folder = await fileSvc.storeItem({ type: 'folder' });
const wrapper = mount();
select(folder.id);
wrapper.find('.side-title__button--new-folder').trigger('click');
expect(store.state.explorer.newChildNode.item).toMatchObject({
type: 'folder',
parentId: folder.id,
});
});
it('should not create new folders in the trash folder', () => {
const wrapper = mount();
select('trash');
wrapper.find('.side-title__button--new-folder').trigger('click');
expect(store.state.explorer.newChildNode.item).toMatchObject({
type: 'folder',
parentId: null,
});
});
it('should not create new folders in the temp folder', () => {
const wrapper = mount();
select('temp');
wrapper.find('.side-title__button--new-folder').trigger('click');
expect(store.state.explorer.newChildNode.item).toMatchObject({
type: 'folder',
parentId: null,
});
});
it('should move file to the trash folder on delete', async () => {
const file = await fileSvc.createFile({}, true);
expect(file.parentId).toEqual(null);
const wrapper = mount();
select(file.id);
wrapper.find('.side-title__button--delete').trigger('click');
ensureExists(file);
expect(file.parentId).toEqual('trash');
});
it('should not delete the trash folder', async () => {
const wrapper = mount();
select('trash');
wrapper.find('.side-title__button--delete').trigger('click');
await specUtils.resolveModal('trashDeletion');
});
it('should not delete files in the trash folder', async () => {
const file = await fileSvc.createFile({ parentId: 'trash' }, true);
const wrapper = mount();
select(file.id);
wrapper.find('.side-title__button--delete').trigger('click');
await specUtils.resolveModal('trashDeletion');
ensureExists(file);
});
it('should delete the temp folder after confirmation', async () => {
const file = await fileSvc.createFile({ parentId: 'temp' }, true);
const wrapper = mount();
select('temp');
wrapper.find('.side-title__button--delete').trigger('click');
await specUtils.resolveModal('tempFolderDeletion');
ensureNotExists(file);
});
it('should delete temp files after confirmation', async () => {
const file = await fileSvc.createFile({ parentId: 'temp' }, true);
const wrapper = mount();
select(file.id);
wrapper.find('.side-title__button--delete').trigger('click');
ensureExists(file);
await specUtils.resolveModal('tempFileDeletion');
ensureNotExists(file);
});
it('should delete folder after confirmation', async () => {
const folder = await fileSvc.storeItem({ type: 'folder' });
const file = await fileSvc.createFile({ parentId: folder.id }, true);
const wrapper = mount();
select(folder.id);
wrapper.find('.side-title__button--delete').trigger('click');
await specUtils.resolveModal('folderDeletion');
ensureNotExists(folder);
// Make sure file has been moved to Trash
ensureExists(file);
expect(file.parentId).toEqual('trash');
});
it('should rename files', async () => {
const file = await fileSvc.createFile({}, true);
const wrapper = mount();
select(file.id);
wrapper.find('.side-title__button--rename').trigger('click');
expect(store.getters['explorer/editingNode'].item.id).toEqual(file.id);
});
it('should rename folders', async () => {
const folder = await fileSvc.storeItem({ type: 'folder' });
const wrapper = mount();
select(folder.id);
wrapper.find('.side-title__button--rename').trigger('click');
expect(store.getters['explorer/editingNode'].item.id).toEqual(folder.id);
});
it('should not rename the trash folder', async () => {
const wrapper = mount();
select('trash');
wrapper.find('.side-title__button--rename').trigger('click');
expect(store.getters['explorer/editingNode'].isNil).toBeTruthy();
});
it('should not rename the temp folder', async () => {
const wrapper = mount();
select('temp');
wrapper.find('.side-title__button--rename').trigger('click');
expect(store.getters['explorer/editingNode'].isNil).toBeTruthy();
});
it('should close itself', async () => {
store.dispatch('data/toggleExplorer', true);
specUtils.checkToggler(
Explorer,
wrapper => wrapper.find('.side-title__button--close').trigger('click'),
() => store.getters['data/layoutSettings'].showExplorer,
);
});
});

View File

@ -0,0 +1,273 @@
import { shallowMount } from '@vue/test-utils';
import ExplorerNode from '../../../../src/components/ExplorerNode';
import store from '../../../../src/store';
import fileSvc from '../../../../src/services/fileSvc';
import explorerSvc from '../../../../src/services/explorerSvc';
import specUtils from '../specUtils';
const makeFileNode = async () => {
const file = await fileSvc.createFile({}, true);
const node = store.getters['explorer/nodeMap'][file.id];
expect(node.item.id).toEqual(file.id);
return node;
};
const makeFolderNode = async () => {
const folder = await fileSvc.storeItem({ type: 'folder' });
const node = store.getters['explorer/nodeMap'][folder.id];
expect(node.item.id).toEqual(folder.id);
return node;
};
const mount = node => shallowMount(ExplorerNode, {
store,
propsData: { node, depth: 1 },
});
const mountAndSelect = (node) => {
const wrapper = mount(node);
wrapper.find('.explorer-node__item').trigger('click');
expect(store.getters['explorer/selectedNode'].item.id).toEqual(node.item.id);
expect(wrapper.classes()).toContain('explorer-node--selected');
return wrapper;
};
const dragAndDrop = (sourceItem, targetItem) => {
const sourceNode = store.getters['explorer/nodeMap'][sourceItem.id];
mountAndSelect(sourceNode).find('.explorer-node__item').trigger('dragstart', {
dataTransfer: { setData: () => {} },
});
expect(store.state.explorer.dragSourceId).toEqual(sourceItem.id);
const targetNode = store.getters['explorer/nodeMap'][targetItem.id];
const wrapper = mount(targetNode);
wrapper.trigger('dragenter');
expect(store.state.explorer.dragTargetId).toEqual(targetItem.id);
wrapper.trigger('drop');
const expectedParentId = targetItem.type === 'file' ? targetItem.parentId : targetItem.id;
expect(store.getters['explorer/selectedNode'].item.parentId).toEqual(expectedParentId);
};
describe('ExplorerNode.vue', () => {
const modifiedName = 'Name';
it('should open files on select after a timeout', async () => {
const node = await makeFileNode();
mountAndSelect(node);
expect(store.getters['file/current'].id).not.toEqual(node.item.id);
await new Promise(resolve => setTimeout(resolve, 10));
expect(store.getters['file/current'].id).toEqual(node.item.id);
});
it('should open folders on select after a timeout', async () => {
const node = await makeFolderNode();
const wrapper = mountAndSelect(node);
expect(wrapper.classes()).not.toContain('explorer-node--open');
await new Promise(resolve => setTimeout(resolve, 10));
expect(wrapper.classes()).toContain('explorer-node--open');
});
it('should open folders on new child', async () => {
const node = await makeFolderNode();
const wrapper = mountAndSelect(node);
// Close the folder
wrapper.find('.explorer-node__item').trigger('click');
await new Promise(resolve => setTimeout(resolve, 10));
expect(wrapper.classes()).not.toContain('explorer-node--open');
explorerSvc.newItem();
expect(wrapper.classes()).toContain('explorer-node--open');
});
it('should create new files in a folder', async () => {
const node = await makeFolderNode();
const wrapper = mount(node);
wrapper.trigger('contextmenu');
await specUtils.resolveContextMenu('New file');
expect(wrapper.contains('.explorer-node__new-child--file')).toBe(true);
store.commit('explorer/setNewItemName', modifiedName);
wrapper.find('.explorer-node__new-child--file .text-input').trigger('blur');
await new Promise(resolve => setTimeout(resolve, 1));
expect(store.getters['explorer/selectedNode'].item).toMatchObject({
name: modifiedName,
type: 'file',
parentId: node.item.id,
});
expect(wrapper.contains('.explorer-node__new-child--file')).toBe(false);
});
it('should cancel a file creation on escape', async () => {
const node = await makeFolderNode();
const wrapper = mount(node);
wrapper.trigger('contextmenu');
await specUtils.resolveContextMenu('New file');
expect(wrapper.contains('.explorer-node__new-child--file')).toBe(true);
store.commit('explorer/setNewItemName', modifiedName);
wrapper.find('.explorer-node__new-child--file .text-input').trigger('keydown', {
keyCode: 27,
});
await new Promise(resolve => setTimeout(resolve, 1));
expect(store.getters['explorer/selectedNode'].item).not.toMatchObject({
name: 'modifiedName',
type: 'file',
parentId: node.item.id,
});
expect(wrapper.contains('.explorer-node__new-child--file')).toBe(false);
});
it('should not create new files in a file', async () => {
const node = await makeFileNode();
mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('New file').disabled).toBe(true);
});
it('should not create new files in the trash folder', async () => {
const node = store.getters['explorer/nodeMap'].trash;
mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('New file').disabled).toBe(true);
});
it('should create new folder in folder', async () => {
const node = await makeFolderNode();
const wrapper = mount(node);
wrapper.trigger('contextmenu');
await specUtils.resolveContextMenu('New folder');
expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(true);
store.commit('explorer/setNewItemName', modifiedName);
wrapper.find('.explorer-node__new-child--folder .text-input').trigger('blur');
await new Promise(resolve => setTimeout(resolve, 1));
expect(store.getters['explorer/selectedNode'].item).toMatchObject({
name: modifiedName,
type: 'folder',
parentId: node.item.id,
});
expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(false);
});
it('should cancel a folder creation on escape', async () => {
const node = await makeFolderNode();
const wrapper = mount(node);
wrapper.trigger('contextmenu');
await specUtils.resolveContextMenu('New folder');
expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(true);
store.commit('explorer/setNewItemName', modifiedName);
wrapper.find('.explorer-node__new-child--folder .text-input').trigger('keydown', {
keyCode: 27,
});
await new Promise(resolve => setTimeout(resolve, 1));
expect(store.getters['explorer/selectedNode'].item).not.toMatchObject({
name: modifiedName,
type: 'folder',
parentId: node.item.id,
});
expect(wrapper.contains('.explorer-node__new-child--folder')).toBe(false);
});
it('should not create new folders in a file', async () => {
const node = await makeFileNode();
mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true);
});
it('should not create new folders in the trash folder', async () => {
const node = store.getters['explorer/nodeMap'].trash;
mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true);
});
it('should not create new folders in the temp folder', async () => {
const node = store.getters['explorer/nodeMap'].temp;
mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('New folder').disabled).toBe(true);
});
it('should rename files', async () => {
const node = await makeFileNode();
const wrapper = mount(node);
wrapper.trigger('contextmenu');
await specUtils.resolveContextMenu('Rename');
expect(wrapper.contains('.explorer-node__item-editor')).toBe(true);
wrapper.setData({ editingValue: modifiedName });
wrapper.find('.explorer-node__item-editor .text-input').trigger('blur');
expect(store.getters['explorer/selectedNode'].item.name).toEqual(modifiedName);
});
it('should rename folders', async () => {
const node = await makeFolderNode();
const wrapper = mount(node);
wrapper.trigger('contextmenu');
await specUtils.resolveContextMenu('Rename');
expect(wrapper.contains('.explorer-node__item-editor')).toBe(true);
wrapper.setData({ editingValue: modifiedName });
wrapper.find('.explorer-node__item-editor .text-input').trigger('blur');
expect(store.getters['explorer/selectedNode'].item.name).toEqual(modifiedName);
});
it('should cancel rename on escape', async () => {
const node = await makeFileNode();
const wrapper = mount(node);
wrapper.trigger('contextmenu');
await specUtils.resolveContextMenu('Rename');
expect(wrapper.contains('.explorer-node__item-editor')).toBe(true);
wrapper.setData({ editingValue: modifiedName });
wrapper.find('.explorer-node__item-editor .text-input').trigger('keydown', {
keyCode: 27,
});
expect(store.getters['explorer/selectedNode'].item.name).not.toEqual(modifiedName);
});
it('should not rename the trash folder', async () => {
const node = store.getters['explorer/nodeMap'].trash;
mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('Rename').disabled).toBe(true);
});
it('should not rename the temp folder', async () => {
const node = store.getters['explorer/nodeMap'].temp;
mount(node).trigger('contextmenu');
expect(specUtils.getContextMenuItem('Rename').disabled).toBe(true);
});
it('should move a file into a folder', async () => {
const sourceItem = await fileSvc.createFile({}, true);
const targetItem = await fileSvc.storeItem({ type: 'folder' });
dragAndDrop(sourceItem, targetItem);
});
it('should move a folder into a folder', async () => {
const sourceItem = await fileSvc.storeItem({ type: 'folder' });
const targetItem = await fileSvc.storeItem({ type: 'folder' });
dragAndDrop(sourceItem, targetItem);
});
it('should move a file into a file parent folder', async () => {
const targetItem = await fileSvc.storeItem({ type: 'folder' });
const file = await fileSvc.createFile({ parentId: targetItem.id }, true);
const sourceItem = await fileSvc.createFile({}, true);
dragAndDrop(sourceItem, file);
});
it('should not move the trash folder', async () => {
const sourceNode = store.getters['explorer/nodeMap'].trash;
mountAndSelect(sourceNode).find('.explorer-node__item').trigger('dragstart');
expect(store.state.explorer.dragSourceId).not.toEqual('trash');
});
it('should not move the temp folder', async () => {
const sourceNode = store.getters['explorer/nodeMap'].temp;
mountAndSelect(sourceNode).find('.explorer-node__item').trigger('dragstart');
expect(store.state.explorer.dragSourceId).not.toEqual('temp');
});
it('should not move a file to the temp folder', async () => {
const targetNode = store.getters['explorer/nodeMap'].temp;
const wrapper = mount(targetNode);
wrapper.trigger('dragenter');
expect(store.state.explorer.dragTargetId).not.toEqual('temp');
});
it('should not move a file to a file in the temp folder', async () => {
const file = await fileSvc.createFile({ parentId: 'temp' }, true);
const targetNode = store.getters['explorer/nodeMap'][file.id];
const wrapper = mount(targetNode);
wrapper.trigger('dragenter');
expect(store.state.explorer.dragTargetId).not.toEqual(file.id);
});
});

View File

@ -0,0 +1,17 @@
import NavigationBar from '../../../../src/components/NavigationBar';
import store from '../../../../src/store';
import specUtils from '../specUtils';
describe('NavigationBar.vue', () => {
it('should toggle the explorer', () => specUtils.checkToggler(
NavigationBar,
wrapper => wrapper.find('.navigation-bar__button--explorer-toggler').trigger('click'),
() => store.getters['data/layoutSettings'].showExplorer,
));
it('should toggle the side bar', () => specUtils.checkToggler(
NavigationBar,
wrapper => wrapper.find('.navigation-bar__button--stackedit').trigger('click'),
() => store.getters['data/layoutSettings'].showSideBar,
));
});

View File

@ -0,0 +1,38 @@
import { shallowMount } from '@vue/test-utils';
import Notification from '../../../../src/components/Notification';
import store from '../../../../src/store';
import '../specUtils';
const mount = () => shallowMount(Notification, { store });
describe('Notification.vue', () => {
it('should autoclose itself', async () => {
const wrapper = mount();
expect(wrapper.contains('.notification__item')).toBe(false);
store.dispatch('notification/showItem', {
type: 'info',
content: 'Test',
timeout: 10,
});
expect(wrapper.contains('.notification__item')).toBe(true);
await new Promise(resolve => setTimeout(resolve, 10));
expect(wrapper.contains('.notification__item')).toBe(false);
});
it('should show messages from top to bottom', async () => {
const wrapper = mount();
store.dispatch('notification/info', 'Test 1');
store.dispatch('notification/info', 'Test 2');
const items = wrapper.findAll('.notification__item');
expect(items.length).toEqual(2);
expect(items.at(0).text()).toMatch(/Test 1/);
expect(items.at(1).text()).toMatch(/Test 2/);
});
it('should not open the same message twice', async () => {
const wrapper = mount();
store.dispatch('notification/info', 'Test');
store.dispatch('notification/info', 'Test');
expect(wrapper.findAll('.notification__item').length).toEqual(1);
});
});

View File

@ -0,0 +1,51 @@
import { shallowMount } from '@vue/test-utils';
import store from '../../../src/store';
import utils from '../../../src/services/utils';
import '../../../src/icons';
import '../../../src/components/common/globals';
const clone = object => JSON.parse(JSON.stringify(object));
const deepAssign = (target, origin) => {
Object.entries(origin).forEach(([key, value]) => {
const type = Object.prototype.toString.call(value);
if (type === '[object Object]' && Object.keys(value).length) {
deepAssign(target[key], value);
} else {
target[key] = value;
}
});
};
const freshState = clone(store.state);
beforeEach(() => {
// Restore store state before each test
deepAssign(store.state, clone(freshState));
});
export default {
checkToggler(Component, toggler, checker) {
const wrapper = shallowMount(Component, { store });
const valueBefore = checker();
toggler(wrapper);
const valueAfter = checker();
expect(valueAfter).toEqual(!valueBefore);
},
async resolveModal(type) {
const config = store.getters['modal/config'];
expect(config).toBeTruthy();
expect(config.type).toEqual(type);
config.resolve();
await new Promise(resolve => setTimeout(resolve, 1));
},
getContextMenuItem(name) {
return utils.someResult(store.state.contextMenu.items, item => item.name === name && item);
},
async resolveContextMenu(name) {
const item = this.getContextMenuItem(name);
expect(item).toBeTruthy();
store.state.contextMenu.resolve(item);
await new Promise(resolve => setTimeout(resolve, 1));
}
};