mirror of
https://gitee.com/mafgwo/stackedit
synced 2024-11-16 11:42:23 +08:00
Added tests
This commit is contained in:
parent
597c747b00
commit
e05e7717eb
2
.babelrc
2
.babelrc
|
@ -8,7 +8,7 @@
|
|||
"env": {
|
||||
"test": {
|
||||
"presets": ["env", "stage-2"],
|
||||
"plugins": [ "istanbul" ]
|
||||
"plugins": ["transform-es2015-modules-commonjs", "dynamic-import-node"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -3,8 +3,7 @@ node_modules/
|
|||
dist/
|
||||
.history
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.vscode
|
||||
stackedit_v4
|
||||
chrome-app/*.zip
|
||||
/test/unit/coverage/
|
||||
|
|
3415
package-lock.json
generated
3415
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
|
@ -14,7 +14,9 @@
|
|||
"build": "node build/build.js && npm run build-style",
|
||||
"build-style": "webpack --config build/webpack.style.conf.js",
|
||||
"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",
|
||||
"postversion": "git push origin master --tags && npm publish",
|
||||
"patch": "npm version patch -m \"Tag v%s\"",
|
||||
|
@ -22,7 +24,9 @@
|
|||
"major": "npm version major -m \"Tag v%s\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@vue/test-utils": "^1.0.0-beta.16",
|
||||
"aws-sdk": "^2.133.0",
|
||||
"babel-runtime": "^6.26.0",
|
||||
"bezier-easing": "^1.1.0",
|
||||
"body-parser": "^1.18.2",
|
||||
"clipboard": "^1.7.1",
|
||||
|
@ -59,8 +63,11 @@
|
|||
"autoprefixer": "^6.7.2",
|
||||
"babel-core": "^6.26.3",
|
||||
"babel-eslint": "^8.2.3",
|
||||
"babel-jest": "^21.0.2",
|
||||
"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-preset-env": "^1.3.2",
|
||||
"babel-preset-stage-2": "^6.22.0",
|
||||
|
@ -86,7 +93,11 @@
|
|||
"gulp-concat": "^2.6.1",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"http-proxy-middleware": "^0.18.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"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",
|
||||
"npm-bump": "^0.0.23",
|
||||
"offline-plugin": "^5.0.3",
|
||||
|
@ -103,6 +114,7 @@
|
|||
"stylelint-processor-html": "^1.0.0",
|
||||
"stylelint-webpack-plugin": "^0.10.4",
|
||||
"url-loader": "^1.0.1",
|
||||
"vue-jest": "^1.0.2",
|
||||
"vue-loader": "^15.0.9",
|
||||
"vue-style-loader": "^4.1.0",
|
||||
"vue-template-compiler": "^2.5.16",
|
||||
|
@ -114,12 +126,12 @@
|
|||
"worker-loader": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
"node": ">= 8.0.0",
|
||||
"npm": ">= 5.0.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not ie <= 8"
|
||||
"not ie <= 10"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -2,14 +2,13 @@
|
|||
<div class="app" :class="classes">
|
||||
<splash-screen v-if="!ready"></splash-screen>
|
||||
<layout v-else></layout>
|
||||
<modal v-if="showModal"></modal>
|
||||
<modal></modal>
|
||||
<notification></notification>
|
||||
<context-menu></context-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue';
|
||||
import '../styles';
|
||||
import '../styles/markdownHighlighting.scss';
|
||||
import '../styles/app.scss';
|
||||
|
@ -22,50 +21,7 @@ import syncSvc from '../services/syncSvc';
|
|||
import networkSvc from '../services/networkSvc';
|
||||
import sponsorSvc from '../services/sponsorSvc';
|
||||
import tempFileSvc from '../services/tempFileSvc';
|
||||
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));
|
||||
import './common/globals';
|
||||
|
||||
const themeClasses = {
|
||||
light: ['app--light'],
|
||||
|
@ -88,9 +44,6 @@ export default {
|
|||
const result = themeClasses[this.$store.getters['data/computedSettings'].colorTheme];
|
||||
return Array.isArray(result) ? result : themeClasses.light;
|
||||
},
|
||||
showModal() {
|
||||
return !!this.$store.getters['modal/config'];
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
try {
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
<template>
|
||||
<div class="button-bar">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<div v-for="(item, idx) in items" :key="idx">
|
||||
<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>
|
||||
<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>
|
||||
|
@ -22,10 +22,8 @@ export default {
|
|||
]),
|
||||
},
|
||||
methods: {
|
||||
close(item) {
|
||||
if (item) {
|
||||
close(item = null) {
|
||||
this.resolve(item);
|
||||
}
|
||||
this.$store.dispatch('contextMenu/close');
|
||||
},
|
||||
},
|
||||
|
|
|
@ -2,20 +2,20 @@
|
|||
<div class="explorer flex flex--column">
|
||||
<div class="side-title flex flex--row flex--space-between">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</button>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -181,8 +181,10 @@ export default {
|
|||
perform: () => explorerSvc.deleteItem(),
|
||||
}],
|
||||
});
|
||||
if (item) {
|
||||
item.perform();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<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>
|
||||
<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">
|
||||
<button class="button" v-if="config.rejectText" @click="config.reject()">{{config.rejectText}}</button>
|
||||
<button class="button" v-if="config.resolveText" @click="config.resolve()">{{config.resolveText}}</button>
|
||||
<button class="button" v-if="simpleModal.rejectText" @click="config.reject()">{{simpleModal.rejectText}}</button>
|
||||
<button class="button" v-if="simpleModal.resolveText" @click="config.resolve()">{{simpleModal.resolveText}}</button>
|
||||
</div>
|
||||
</modal-inner>
|
||||
</div>
|
||||
|
@ -13,6 +13,7 @@
|
|||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import simpleModals from '../data/simpleModals';
|
||||
import editorSvc from '../services/editorSvc';
|
||||
import ModalInner from './modals/common/ModalInner';
|
||||
import FilePropertiesModal from './modals/FilePropertiesModal';
|
||||
|
@ -112,6 +113,9 @@ export default {
|
|||
}
|
||||
return null;
|
||||
},
|
||||
simpleModal() {
|
||||
return simpleModals[this.config.type] || {};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onEscape() {
|
||||
|
@ -153,14 +157,22 @@ export default {
|
|||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$watch(
|
||||
() => this.config,
|
||||
() => {
|
||||
if (this.$el) {
|
||||
window.addEventListener('focusin', this.onFocusInOut);
|
||||
window.addEventListener('focusout', this.onFocusInOut);
|
||||
const tabbables = getTabbables(this.$el);
|
||||
if (tabbables[0]) {
|
||||
tabbables[0].focus();
|
||||
},
|
||||
destroyed() {
|
||||
}
|
||||
} else {
|
||||
window.removeEventListener('focusin', this.onFocusInOut);
|
||||
window.removeEventListener('focusout', this.onFocusInOut);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<!-- Explorer -->
|
||||
<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-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>
|
||||
<!-- Side bar -->
|
||||
<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) => {
|
||||
let result = '';
|
||||
Object.entries(store.getters['data/computedSettings'].shortcuts).some(([keys, shortcut]) => {
|
||||
if (`${shortcut.method || shortcut}` !== method) {
|
||||
return false;
|
||||
}
|
||||
if (`${shortcut.method || shortcut}` === method) {
|
||||
result = keys.split('+').map(key => key.toLowerCase()).map((key) => {
|
||||
if (key === 'mod') {
|
||||
return mod;
|
||||
|
@ -74,7 +72,8 @@ const getShortcut = (method) => {
|
|||
// Capitalize
|
||||
return key && `${key[0].toUpperCase()}${key.slice(1)}`;
|
||||
}).join('+');
|
||||
return true;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
return result && ` – ${result}`;
|
||||
};
|
||||
|
|
|
@ -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>
|
46
src/components/common/globals.js
Normal file
46
src/components/common/globals.js
Normal 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));
|
||||
|
|
@ -49,7 +49,7 @@ export default {
|
|||
]),
|
||||
async removeComment() {
|
||||
try {
|
||||
await this.$store.dispatch('modal/commentDeletion');
|
||||
await this.$store.dispatch('modal/open', 'commentDeletion');
|
||||
this.$store.dispatch('discussion/cleanCurrentFile', { filterComment: this.comment });
|
||||
} catch (e) {
|
||||
// Cancel
|
||||
|
|
|
@ -95,7 +95,7 @@ export default {
|
|||
},
|
||||
async removeDiscussion() {
|
||||
try {
|
||||
await this.$store.dispatch('modal/discussionDeletion');
|
||||
await this.$store.dispatch('modal/open', 'discussionDeletion');
|
||||
this.$store.dispatch('discussion/cleanCurrentFile', {
|
||||
filterDiscussion: this.currentDiscussion,
|
||||
});
|
||||
|
|
|
@ -96,7 +96,7 @@ export default {
|
|||
},
|
||||
async reset() {
|
||||
try {
|
||||
await this.$store.dispatch('modal/reset');
|
||||
await this.$store.dispatch('modal/open', 'reset');
|
||||
window.location.href = '#reset=true';
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
|
|
|
@ -68,7 +68,7 @@ export default modalTemplate({
|
|||
FileSaver.saveAs(body, `${currentFile.name}.${selectedFormat}`);
|
||||
} catch (err) {
|
||||
if (err.status === 401) {
|
||||
this.$store.dispatch('modal/sponsorOnly');
|
||||
this.$store.dispatch('modal/open', 'sponsorOnly');
|
||||
} else {
|
||||
console.error(err); // eslint-disable-line no-console
|
||||
this.$store.dispatch('notification/error', err);
|
||||
|
|
|
@ -65,7 +65,7 @@ export default modalTemplate({
|
|||
FileSaver.saveAs(body, `${currentFile.name}.pdf`);
|
||||
} catch (err) {
|
||||
if (err.status === 401) {
|
||||
this.$store.dispatch('modal/sponsorOnly');
|
||||
this.$store.dispatch('modal/open', 'sponsorOnly');
|
||||
} else {
|
||||
console.error(err); // eslint-disable-line no-console
|
||||
this.$store.dispatch('notification/error', err);
|
||||
|
|
|
@ -77,7 +77,7 @@ export default {
|
|||
},
|
||||
async remove(id) {
|
||||
try {
|
||||
await this.$store.dispatch('modal/removeWorkspace');
|
||||
await this.$store.dispatch('modal/open', 'removeWorkspace');
|
||||
localDbSvc.removeWorkspace(id);
|
||||
} catch (e) {
|
||||
// Cancel
|
||||
|
|
|
@ -33,7 +33,7 @@ export default {
|
|||
try {
|
||||
if (!this.$store.getters['workspace/sponsorToken']) {
|
||||
// User has to sign in
|
||||
await this.$store.dispatch('modal/signInForSponsorship');
|
||||
await this.$store.dispatch('modal/open', 'signInForSponsorship');
|
||||
await googleHelper.signin();
|
||||
syncSvc.requestSync();
|
||||
}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
export default [{}, {
|
||||
export default [{
|
||||
}, {
|
||||
method: 'bold',
|
||||
title: 'Bold',
|
||||
icon: 'format-bold',
|
||||
|
@ -14,7 +15,8 @@ export default [{}, {
|
|||
method: 'strikethrough',
|
||||
title: 'Strikethrough',
|
||||
icon: 'format-strikethrough',
|
||||
}, {}, {
|
||||
}, {
|
||||
}, {
|
||||
method: 'ulist',
|
||||
title: 'Unordered list',
|
||||
icon: 'format-list-bulleted',
|
||||
|
@ -26,7 +28,8 @@ export default [{}, {
|
|||
method: 'clist',
|
||||
title: 'Check list',
|
||||
icon: 'format-list-checks',
|
||||
}, {}, {
|
||||
}, {
|
||||
}, {
|
||||
method: 'quote',
|
||||
title: 'Blockquote',
|
||||
icon: 'format-quote-close',
|
||||
|
|
97
src/data/simpleModals.js
Normal file
97
src/data/simpleModals.js
Normal 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',
|
||||
),
|
||||
};
|
|
@ -2,9 +2,9 @@ import Vue from 'vue';
|
|||
import 'babel-polyfill';
|
||||
import 'indexeddbshim/dist/indexeddbshim';
|
||||
import * as OfflinePluginRuntime from 'offline-plugin/runtime';
|
||||
import './extensions/';
|
||||
import './extensions';
|
||||
import './services/optional';
|
||||
import './icons/';
|
||||
import './icons';
|
||||
import App from './components/App';
|
||||
import store from './store';
|
||||
import localDbSvc from './services/localDbSvc';
|
||||
|
|
|
@ -140,7 +140,8 @@ objectProperties.cl_extend = function (obj) {
|
|||
function build(properties) {
|
||||
return objectProperties.cl_reduce.call(properties, function (memo, value, key) {
|
||||
memo[key] = {
|
||||
value: value
|
||||
value: value,
|
||||
configurable: true
|
||||
}
|
||||
return memo
|
||||
}, {})
|
||||
|
|
|
@ -20,9 +20,10 @@ export default {
|
|||
if (selectedNode.isNil) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedNode.isTrash || selectedNode.item.parentId === 'trash') {
|
||||
try {
|
||||
await store.dispatch('modal/trashDeletion');
|
||||
await store.dispatch('modal/open', 'trashDeletion');
|
||||
} catch (e) {
|
||||
// Cancel
|
||||
}
|
||||
|
@ -33,13 +34,19 @@ export default {
|
|||
let moveToTrash = true;
|
||||
try {
|
||||
if (selectedNode.isTemp) {
|
||||
await store.dispatch('modal/tempFolderDeletion', selectedNode.item);
|
||||
await store.dispatch('modal/open', 'tempFolderDeletion');
|
||||
moveToTrash = false;
|
||||
} 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;
|
||||
} else if (selectedNode.isFolder) {
|
||||
await store.dispatch('modal/folderDeletion', selectedNode.item);
|
||||
await store.dispatch('modal/open', {
|
||||
type: 'folderDeletion',
|
||||
item: selectedNode.item,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
return; // cancel
|
||||
|
|
|
@ -16,7 +16,7 @@ export default {
|
|||
comments,
|
||||
} = {}, background = false) {
|
||||
const id = utils.uid();
|
||||
const file = {
|
||||
const item = {
|
||||
id,
|
||||
name: utils.sanitizeName(name),
|
||||
parentId: parentId || null,
|
||||
|
@ -34,23 +34,29 @@ export default {
|
|||
// Show warning dialogs
|
||||
if (!background) {
|
||||
// If name is being stripped
|
||||
if (file.name !== utils.defaultName && file.name !== name) {
|
||||
await store.dispatch('modal/stripName', name);
|
||||
if (item.name !== utils.defaultName && item.name !== name) {
|
||||
await store.dispatch('modal/open', {
|
||||
type: 'stripName',
|
||||
item,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if there is already a file with that path
|
||||
if (workspaceUniquePaths) {
|
||||
const parentPath = store.getters.itemPaths[file.parentId] || '';
|
||||
const path = parentPath + file.name;
|
||||
const parentPath = store.getters.itemPaths[item.parentId] || '';
|
||||
const path = parentPath + item.name;
|
||||
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
|
||||
store.commit('content/setItem', content);
|
||||
store.commit('file/setItem', file);
|
||||
store.commit('file/setItem', item);
|
||||
if (workspaceUniquePaths) {
|
||||
this.makePathUnique(id);
|
||||
}
|
||||
|
@ -67,14 +73,20 @@ export default {
|
|||
const sanitizedName = utils.sanitizeName(item.name);
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
// Show warning dialogs
|
||||
// If name has been stripped
|
||||
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
|
||||
if (store.getters['workspace/hasUniquePaths']) {
|
||||
|
@ -82,7 +94,10 @@ export default {
|
|||
const path = parentPath + sanitizedName;
|
||||
const pathItems = store.getters.pathItems[path] || [];
|
||||
if (pathItems.some(itemWithSamePath => itemWithSamePath.id !== id)) {
|
||||
await store.dispatch('modal/pathConflict', item.name);
|
||||
await store.dispatch('modal/open', {
|
||||
type: 'pathConflict',
|
||||
item,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -399,7 +399,7 @@ const localDbSvc = {
|
|||
// Enable sponsorship
|
||||
if (utils.queryParams.paymentSuccess) {
|
||||
window.location.hash = ''; // PaymentSuccess param is always on its own
|
||||
store.dispatch('modal/paymentSuccess')
|
||||
store.dispatch('modal/open', 'paymentSuccess')
|
||||
.catch(() => { /* Cancel */ });
|
||||
const sponsorToken = store.getters['workspace/sponsorToken'];
|
||||
// Force check sponsorship after a few seconds
|
||||
|
|
|
@ -215,13 +215,24 @@ export default {
|
|||
|
||||
const attempt = async () => {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
if (offlineCheck) {
|
||||
store.commit('updateLastOfflineCheck');
|
||||
}
|
||||
|
||||
const xhr = new window.XMLHttpRequest();
|
||||
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 = () => {
|
||||
if (offlineCheck) {
|
||||
|
@ -242,9 +253,9 @@ export default {
|
|||
}
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
resolve(result);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
reject(result);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
xhr.open(config.method || 'GET', url);
|
||||
Object.entries(config.headers).forEach(([key, value]) =>
|
||||
value && xhr.setRequestHeader(key, `${value}`));
|
||||
Object.entries(config.headers).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
xhr.setRequestHeader(key, `${value}`);
|
||||
}
|
||||
});
|
||||
if (config.blob) {
|
||||
xhr.responseType = 'blob';
|
||||
}
|
||||
|
@ -286,7 +289,7 @@ export default {
|
|||
// Exponential backoff
|
||||
retryAfter *= 2;
|
||||
});
|
||||
attempt();
|
||||
return attempt();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
|
|
@ -91,7 +91,7 @@ export default new Provider({
|
|||
syncLastSeq,
|
||||
});
|
||||
},
|
||||
async saveSimpleItem(item, syncData) {
|
||||
async saveWorkspaceItem(item, syncData) {
|
||||
const syncToken = store.getters['workspace/syncToken'];
|
||||
const { id, rev } = couchdbHelper.uploadDocument({
|
||||
token: syncToken,
|
||||
|
@ -108,78 +108,85 @@ export default new Provider({
|
|||
rev,
|
||||
};
|
||||
},
|
||||
removeItem(syncData) {
|
||||
removeWorkspaceItem(syncData) {
|
||||
const syncToken = store.getters['workspace/syncToken'];
|
||||
return couchdbHelper.removeDocument(syncToken, syncData.id, syncData.rev);
|
||||
},
|
||||
downloadContent(token, syncLocation) {
|
||||
return this.downloadData(`${syncLocation.fileId}/content`);
|
||||
},
|
||||
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));
|
||||
}
|
||||
async downloadWorkspaceContent(token, syncData) {
|
||||
const body = await couchdbHelper.retrieveDocumentWithAttachments(token, syncData.id);
|
||||
const rev = body._rev; // eslint-disable-line no-underscore-dangle
|
||||
if (item.hash !== syncData.hash || rev !== syncData.rev) {
|
||||
store.dispatch('data/patchSyncData', {
|
||||
[syncData.id]: {
|
||||
const item = Provider.parseContent(body.attachments.data, body.item.id);
|
||||
return {
|
||||
item,
|
||||
syncData: {
|
||||
...syncData,
|
||||
hash: item.hash,
|
||||
rev,
|
||||
},
|
||||
});
|
||||
}
|
||||
return item;
|
||||
};
|
||||
},
|
||||
async uploadContent(token, content, syncLocation) {
|
||||
await this.uploadData(content);
|
||||
return syncLocation;
|
||||
},
|
||||
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';
|
||||
async downloadWorkspaceData(token, dataId, syncData) {
|
||||
if (!syncData) {
|
||||
return {};
|
||||
}
|
||||
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({
|
||||
token: syncToken,
|
||||
token,
|
||||
item: {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
hash: item.hash,
|
||||
},
|
||||
data,
|
||||
dataType,
|
||||
data: Provider.serializeContent(item),
|
||||
dataType: 'text/plain',
|
||||
documentId: syncData && syncData.id,
|
||||
rev: syncData && syncData.rev,
|
||||
});
|
||||
store.dispatch('data/patchSyncData', {
|
||||
[res.id]: {
|
||||
// Build sync data
|
||||
|
||||
// Return new sync data
|
||||
return {
|
||||
id: res.id,
|
||||
itemId: item.id,
|
||||
type: item.type,
|
||||
hash: item.hash,
|
||||
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) {
|
||||
const syncData = Provider.getContentSyncData(fileId);
|
||||
|
|
|
@ -28,6 +28,7 @@ const endsWith = (str, suffix) => str.slice(-suffix.length) === suffix;
|
|||
|
||||
export default new Provider({
|
||||
id: 'githubWorkspace',
|
||||
isGit: true,
|
||||
getToken() {
|
||||
return store.getters['workspace/syncToken'];
|
||||
},
|
||||
|
@ -110,6 +111,9 @@ export default new Provider({
|
|||
const path = blobEntry.path.slice(workspacePath.length);
|
||||
// Collect blob sha
|
||||
treeShaMap[path] = blobEntry.sha;
|
||||
if (path.indexOf('.stackedit-data/') === 0) {
|
||||
treeDataMap[path] = true;
|
||||
} else {
|
||||
// Collect parents path
|
||||
let parentPath = '';
|
||||
path.split('/').slice(0, -1).forEach((folderName) => {
|
||||
|
@ -118,21 +122,20 @@ export default new Provider({
|
|||
parentPath = folderPath;
|
||||
});
|
||||
// Collect file path
|
||||
if (path.indexOf('.stackedit-data/') === 0) {
|
||||
treeDataMap[path] = true;
|
||||
} else if (endsWith(path, '.md')) {
|
||||
if (endsWith(path, '.md')) {
|
||||
treeFileMap[path] = parentPath;
|
||||
} else if (endsWith(path, '.sync')) {
|
||||
treeSyncLocationMap[path] = true;
|
||||
} else if (endsWith(path, '.publish')) {
|
||||
treePublishLocationMap[path] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Collect changes
|
||||
const changes = [];
|
||||
const pathIds = {};
|
||||
const syncDataToIgnore = Object.create(null);
|
||||
const syncDataToKeep = Object.create(null);
|
||||
const getId = (path) => {
|
||||
const syncData = syncDataByPath[path];
|
||||
const id = syncData ? syncData.itemId : utils.uid();
|
||||
|
@ -185,7 +188,7 @@ export default new Provider({
|
|||
// Content creations/updates
|
||||
const contentSyncData = syncDataByItemId[`${id}/content`];
|
||||
if (contentSyncData) {
|
||||
syncDataToIgnore[contentSyncData.id] = true;
|
||||
syncDataToKeep[contentSyncData.id] = true;
|
||||
}
|
||||
if (!contentSyncData || contentSyncData.sha !== treeShaMap[path]) {
|
||||
// Use `/` as a prefix to get a unique syncData id
|
||||
|
@ -211,11 +214,12 @@ export default new Provider({
|
|||
// Data creations/updates
|
||||
Object.keys(treeDataMap).forEach((path) => {
|
||||
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;
|
||||
const syncData = syncDataByItemId[id];
|
||||
if (syncData) {
|
||||
syncDataToIgnore[syncData.id] = true;
|
||||
syncDataToKeep[syncData.id] = true;
|
||||
}
|
||||
if (!syncData || syncData.sha !== treeShaMap[path]) {
|
||||
changes.push({
|
||||
|
@ -281,40 +285,28 @@ export default new Provider({
|
|||
|
||||
// Deletions
|
||||
Object.keys(syncDataByPath).forEach((path) => {
|
||||
if (!pathIds[path] && !syncDataToIgnore[path]) {
|
||||
if (!pathIds[path] && !syncDataToKeep[path]) {
|
||||
changes.push({ syncDataId: path });
|
||||
}
|
||||
});
|
||||
|
||||
return changes;
|
||||
},
|
||||
async saveSimpleItem(item) {
|
||||
const path = store.getters.itemPaths[item.fileId || item.id];
|
||||
const syncToken = store.getters['workspace/syncToken'];
|
||||
async saveWorkspaceItem(item) {
|
||||
const syncData = {
|
||||
id: store.getters.itemGitPaths[item.id],
|
||||
itemId: item.id,
|
||||
type: item.type,
|
||||
hash: item.hash,
|
||||
};
|
||||
|
||||
if (item.type === 'file') {
|
||||
syncData.id = `${path}.md`;
|
||||
return syncData;
|
||||
}
|
||||
if (item.type === 'folder') {
|
||||
syncData.id = path;
|
||||
// Files and folders are not in git, only contents
|
||||
if (item.type === 'file' || item.type === 'folder') {
|
||||
return syncData;
|
||||
}
|
||||
|
||||
// locations are stored as paths, so we upload an empty file
|
||||
const data = utils.encodeBase64(utils.serializeObject({
|
||||
...item,
|
||||
id: undefined,
|
||||
type: undefined,
|
||||
fileId: undefined,
|
||||
}), true);
|
||||
const extension = item.type === 'syncLocation' ? 'sync' : 'publish';
|
||||
syncData.id = `${path}.${data}.${extension}`;
|
||||
const syncToken = store.getters['workspace/syncToken'];
|
||||
await githubHelper.uploadFile({
|
||||
...getWorkspaceWithOwner(),
|
||||
token: syncToken,
|
||||
|
@ -324,9 +316,8 @@ export default new Provider({
|
|||
});
|
||||
return syncData;
|
||||
},
|
||||
async removeItem(syncData) {
|
||||
// Ignore content deletion
|
||||
if (syncData.type !== 'content') {
|
||||
async removeWorkspaceItem(syncData) {
|
||||
if (treeShaMap[syncData.id]) {
|
||||
const syncToken = store.getters['workspace/syncToken'];
|
||||
await githubHelper.removeFile({
|
||||
...getWorkspaceWithOwner(),
|
||||
|
@ -336,102 +327,91 @@ export default new Provider({
|
|||
});
|
||||
}
|
||||
},
|
||||
async downloadContent(token, syncLocation) {
|
||||
const syncData = store.getters['data/syncDataByItemId'][syncLocation.fileId];
|
||||
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
|
||||
if (!syncData || !contentSyncData) {
|
||||
return null;
|
||||
async downloadWorkspaceContent(token, contentSyncData) {
|
||||
const [fileId] = contentSyncData.itemId.split('/');
|
||||
const path = store.getters.itemGitPaths[fileId];
|
||||
const syncData = store.getters['data/syncData'][path];
|
||||
if (!syncData) {
|
||||
return {};
|
||||
}
|
||||
const { sha, content } = await githubHelper.downloadFile({
|
||||
...getWorkspaceWithOwner(),
|
||||
token,
|
||||
path: getAbsolutePath(syncData),
|
||||
});
|
||||
const item = Provider.parseContent(content, `${syncLocation.fileId}/content`);
|
||||
if (item.hash !== contentSyncData.hash) {
|
||||
store.dispatch('data/patchSyncData', {
|
||||
[contentSyncData.id]: {
|
||||
treeShaMap[path] = sha;
|
||||
const item = Provider.parseContent(content, `${fileId}/content`);
|
||||
return {
|
||||
item,
|
||||
syncData: {
|
||||
...contentSyncData,
|
||||
hash: item.hash,
|
||||
sha,
|
||||
},
|
||||
});
|
||||
}
|
||||
return item;
|
||||
};
|
||||
},
|
||||
async downloadData(dataId) {
|
||||
const syncData = store.getters['data/syncDataByItemId'][dataId];
|
||||
async downloadWorkspaceData(token, dataId, syncData) {
|
||||
if (!syncData) {
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
const syncToken = store.getters['workspace/syncToken'];
|
||||
|
||||
const { sha, content } = await githubHelper.downloadFile({
|
||||
...getWorkspaceWithOwner(),
|
||||
token: syncToken,
|
||||
token,
|
||||
path: getAbsolutePath(syncData),
|
||||
});
|
||||
treeShaMap[syncData.id] = sha;
|
||||
const item = JSON.parse(content);
|
||||
if (item.hash !== syncData.hash) {
|
||||
store.dispatch('data/patchSyncData', {
|
||||
[syncData.id]: {
|
||||
return {
|
||||
item,
|
||||
syncData: {
|
||||
...syncData,
|
||||
hash: item.hash,
|
||||
sha,
|
||||
},
|
||||
});
|
||||
}
|
||||
return item;
|
||||
};
|
||||
},
|
||||
async uploadContent(token, content, syncLocation) {
|
||||
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
|
||||
if (!contentSyncData || contentSyncData.hash !== content.hash) {
|
||||
const path = `${store.getters.itemPaths[syncLocation.fileId]}.md`;
|
||||
async uploadWorkspaceContent(token, item) {
|
||||
const [fileId] = item.id.split('/');
|
||||
const path = store.getters.itemGitPaths[fileId];
|
||||
const absolutePath = `${store.getters['workspace/currentWorkspace'].path || ''}${path}`;
|
||||
const id = `/${path}`;
|
||||
const res = await githubHelper.uploadFile({
|
||||
...getWorkspaceWithOwner(),
|
||||
token,
|
||||
path: absolutePath,
|
||||
content: Provider.serializeContent(content),
|
||||
sha: treeShaMap[id],
|
||||
content: Provider.serializeContent(item),
|
||||
sha: treeShaMap[path],
|
||||
});
|
||||
store.dispatch('data/patchSyncData', {
|
||||
[id]: {
|
||||
// Build sync data
|
||||
id,
|
||||
itemId: content.id,
|
||||
type: content.type,
|
||||
hash: content.hash,
|
||||
|
||||
// Return new sync data
|
||||
return {
|
||||
id: store.getters.itemGitPaths[item.id],
|
||||
itemId: item.id,
|
||||
type: item.type,
|
||||
hash: item.hash,
|
||||
sha: res.content.sha,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
return syncLocation;
|
||||
},
|
||||
async uploadData(item) {
|
||||
const oldSyncData = store.getters['data/syncDataByItemId'][item.id];
|
||||
if (!oldSyncData || oldSyncData.hash !== item.hash) {
|
||||
async uploadWorkspaceData(token, item) {
|
||||
const path = store.getters.itemGitPaths[item.id];
|
||||
const syncData = {
|
||||
id: `.stackedit-data/${item.id}.json`,
|
||||
id: path,
|
||||
itemId: item.id,
|
||||
type: item.type,
|
||||
hash: item.hash,
|
||||
};
|
||||
const syncToken = store.getters['workspace/syncToken'];
|
||||
const res = await githubHelper.uploadFile({
|
||||
...getWorkspaceWithOwner(),
|
||||
token: syncToken,
|
||||
token,
|
||||
path: getAbsolutePath(syncData),
|
||||
content: JSON.stringify(item),
|
||||
sha: oldSyncData && oldSyncData.sha,
|
||||
sha: treeShaMap[path],
|
||||
});
|
||||
store.dispatch('data/patchSyncData', {
|
||||
[syncData.id]: {
|
||||
|
||||
return {
|
||||
...syncData,
|
||||
sha: res.content.sha,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
onSyncEnd() {
|
||||
// Clean up
|
||||
|
|
|
@ -48,7 +48,7 @@ export default new Provider({
|
|||
syncStartPageToken,
|
||||
});
|
||||
},
|
||||
async saveSimpleItem(item, syncData, ifNotTooLate) {
|
||||
async saveWorkspaceItem(item, syncData, ifNotTooLate) {
|
||||
const syncToken = store.getters['workspace/syncToken'];
|
||||
const file = await googleHelper.uploadAppDataFile({
|
||||
token: syncToken,
|
||||
|
@ -64,41 +64,39 @@ export default new Provider({
|
|||
hash: item.hash,
|
||||
};
|
||||
},
|
||||
removeItem(syncData, ifNotTooLate) {
|
||||
removeWorkspaceItem(syncData, ifNotTooLate) {
|
||||
const syncToken = store.getters['workspace/syncToken'];
|
||||
return googleHelper.removeAppDataFile(syncToken, syncData.id, ifNotTooLate);
|
||||
},
|
||||
downloadContent(token, syncLocation) {
|
||||
return this.downloadData(`${syncLocation.fileId}/content`);
|
||||
},
|
||||
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);
|
||||
async downloadWorkspaceContent(token, syncData) {
|
||||
const data = await googleHelper.downloadAppDataFile(token, syncData.id);
|
||||
const item = utils.addItemHash(JSON.parse(data));
|
||||
if (item.hash !== syncData.hash) {
|
||||
store.dispatch('data/patchSyncData', {
|
||||
[syncData.id]: {
|
||||
return {
|
||||
item,
|
||||
syncData: {
|
||||
...syncData,
|
||||
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) {
|
||||
const syncData = store.getters['data/syncDataByItemId'][item.id];
|
||||
if (!syncData || syncData.hash !== item.hash) {
|
||||
const syncToken = store.getters['workspace/syncToken'];
|
||||
async uploadWorkspaceContent(token, item, syncData, ifNotTooLate) {
|
||||
const file = await googleHelper.uploadAppDataFile({
|
||||
token: syncToken,
|
||||
token,
|
||||
name: JSON.stringify({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
|
@ -108,16 +106,35 @@ export default new Provider({
|
|||
fileId: syncData && syncData.id,
|
||||
ifNotTooLate,
|
||||
});
|
||||
store.dispatch('data/patchSyncData', {
|
||||
[file.id]: {
|
||||
// Build sync data
|
||||
|
||||
// Return new sync data
|
||||
return {
|
||||
id: file.id,
|
||||
itemId: item.id,
|
||||
type: item.type,
|
||||
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) {
|
||||
const syncData = Provider.getContentSyncData(fileId);
|
||||
|
|
|
@ -89,7 +89,7 @@ export default new Provider({
|
|||
let token = store.getters['data/googleTokens'][sub];
|
||||
// If no token has been found, popup an authorize window and get one
|
||||
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);
|
||||
}
|
||||
|
||||
|
@ -312,7 +312,7 @@ export default new Provider({
|
|||
syncStartPageToken,
|
||||
});
|
||||
},
|
||||
async saveSimpleItem(item, syncData, ifNotTooLate) {
|
||||
async saveWorkspaceItem(item, syncData, ifNotTooLate) {
|
||||
const workspace = store.getters['workspace/currentWorkspace'];
|
||||
const syncToken = store.getters['workspace/syncToken'];
|
||||
let file;
|
||||
|
@ -363,63 +363,59 @@ export default new Provider({
|
|||
hash: item.hash,
|
||||
};
|
||||
},
|
||||
async removeItem(syncData, ifNotTooLate) {
|
||||
async removeWorkspaceItem(syncData, ifNotTooLate) {
|
||||
// Ignore content deletion
|
||||
if (syncData.type !== 'content') {
|
||||
const syncToken = store.getters['workspace/syncToken'];
|
||||
await googleHelper.removeFile(syncToken, syncData.id, ifNotTooLate);
|
||||
}
|
||||
},
|
||||
async downloadContent(token, syncLocation) {
|
||||
const syncData = store.getters['data/syncDataByItemId'][syncLocation.fileId];
|
||||
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
|
||||
if (!syncData || !contentSyncData) {
|
||||
return null;
|
||||
async downloadWorkspaceContent(token, contentSyncData) {
|
||||
const [fileId] = contentSyncData.itemId.split('/');
|
||||
const syncData = store.getters['data/syncDataByItemId'][fileId];
|
||||
if (!syncData) {
|
||||
return {};
|
||||
}
|
||||
const content = await googleHelper.downloadFile(token, syncData.id);
|
||||
const item = Provider.parseContent(content, `${syncLocation.fileId}/content`);
|
||||
if (item.hash !== contentSyncData.hash) {
|
||||
store.dispatch('data/patchSyncData', {
|
||||
[contentSyncData.id]: {
|
||||
...contentSyncData,
|
||||
hash: item.hash,
|
||||
},
|
||||
});
|
||||
}
|
||||
const item = Provider.parseContent(content, contentSyncData.itemId);
|
||||
|
||||
// Open the file requested by action if it wasn't synced yet
|
||||
if (fileIdToOpen && fileIdToOpen === syncData.id) {
|
||||
fileIdToOpen = null;
|
||||
// Open the file once downloaded content has been stored
|
||||
setTimeout(() => {
|
||||
store.commit('file/setCurrentId', syncData.itemId);
|
||||
store.commit('file/setCurrentId', fileId);
|
||||
}, 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) {
|
||||
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);
|
||||
if (item.hash !== syncData.hash) {
|
||||
store.dispatch('data/patchSyncData', {
|
||||
[syncData.id]: {
|
||||
return {
|
||||
item,
|
||||
syncData: {
|
||||
...syncData,
|
||||
hash: item.hash,
|
||||
},
|
||||
});
|
||||
}
|
||||
return item;
|
||||
};
|
||||
},
|
||||
async uploadContent(token, content, syncLocation, ifNotTooLate) {
|
||||
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
|
||||
if (!contentSyncData || contentSyncData.hash !== content.hash) {
|
||||
const syncData = store.getters['data/syncDataByItemId'][syncLocation.fileId];
|
||||
async uploadWorkspaceContent(token, content, contentSyncData, ifNotTooLate) {
|
||||
const [fileId] = content.id.split('/');
|
||||
const syncData = store.getters['data/syncDataByItemId'][fileId];
|
||||
let file;
|
||||
|
||||
if (syncData) {
|
||||
// Only update file media
|
||||
file = await googleHelper.uploadFile({
|
||||
|
@ -432,7 +428,7 @@ export default new Provider({
|
|||
// Create file with media
|
||||
const workspace = store.getters['workspace/currentWorkspace'];
|
||||
// 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];
|
||||
file = await googleHelper.uploadFile({
|
||||
token,
|
||||
|
@ -445,6 +441,8 @@ export default new Provider({
|
|||
media: Provider.serializeContent(content),
|
||||
ifNotTooLate,
|
||||
});
|
||||
|
||||
// Create file syncData
|
||||
store.dispatch('data/patchSyncData', {
|
||||
[file.id]: {
|
||||
id: file.id,
|
||||
|
@ -454,25 +452,19 @@ export default new Provider({
|
|||
},
|
||||
});
|
||||
}
|
||||
store.dispatch('data/patchSyncData', {
|
||||
[`${file.id}/content`]: {
|
||||
// Build sync data
|
||||
|
||||
// Return new sync data
|
||||
return {
|
||||
id: `${file.id}/content`,
|
||||
itemId: content.id,
|
||||
type: content.type,
|
||||
hash: content.hash,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
return syncLocation;
|
||||
},
|
||||
async uploadData(item, ifNotTooLate) {
|
||||
const syncData = store.getters['data/syncDataByItemId'][item.id];
|
||||
if (!syncData || syncData.hash !== item.hash) {
|
||||
async uploadWorkspaceData(token, item, syncData, ifNotTooLate) {
|
||||
const workspace = store.getters['workspace/currentWorkspace'];
|
||||
const syncToken = store.getters['workspace/syncToken'];
|
||||
const file = await googleHelper.uploadFile({
|
||||
token: syncToken,
|
||||
token,
|
||||
name: JSON.stringify({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
|
@ -487,16 +479,14 @@ export default new Provider({
|
|||
oldParents: syncData && syncData.parentIds,
|
||||
ifNotTooLate,
|
||||
});
|
||||
store.dispatch('data/patchSyncData', {
|
||||
[file.id]: {
|
||||
// Build sync data
|
||||
|
||||
// Return new sync data
|
||||
return {
|
||||
id: file.id,
|
||||
itemId: item.id,
|
||||
type: item.type,
|
||||
hash: item.hash,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
async listRevisions(token, fileId) {
|
||||
const syncData = Provider.getContentSyncData(fileId);
|
||||
|
|
|
@ -113,12 +113,12 @@ export default {
|
|||
repo,
|
||||
branch,
|
||||
}) {
|
||||
const { commit } = (await repoRequest(token, owner, repo, {
|
||||
const { commit } = await repoRequest(token, owner, repo, {
|
||||
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`,
|
||||
})).body;
|
||||
});
|
||||
if (truncated) {
|
||||
throw new Error('Git tree too big. Please remove some files in the repository.');
|
||||
}
|
||||
|
@ -198,13 +198,13 @@ export default {
|
|||
branch,
|
||||
path,
|
||||
}) {
|
||||
const body = await repoRequest(token, owner, repo, {
|
||||
const { sha, content } = await repoRequest(token, owner, repo, {
|
||||
url: `contents/${encodeURIComponent(path)}`,
|
||||
params: { ref: branch },
|
||||
});
|
||||
return {
|
||||
sha: body.sha,
|
||||
content: utils.decodeBase64(body.content),
|
||||
sha,
|
||||
content: utils.decodeBase64(content),
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
@ -190,7 +190,10 @@ export default {
|
|||
if (store.state.offline) {
|
||||
throw err;
|
||||
}
|
||||
await store.dispatch('modal/providerRedirection', { providerName: 'Google' });
|
||||
await store.dispatch('modal/open', {
|
||||
type: 'providerRedirection',
|
||||
name: 'Google',
|
||||
});
|
||||
return this.startOauth2(mergedScopes, sub);
|
||||
}
|
||||
},
|
||||
|
@ -231,7 +234,7 @@ export default {
|
|||
mediaType = null,
|
||||
fileId = 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
|
||||
return ifNotTooLate(() => {
|
||||
|
@ -374,7 +377,7 @@ export default {
|
|||
/**
|
||||
* 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
|
||||
return ifNotTooLate(() => this.$request(refreshedToken, {
|
||||
method: 'DELETE',
|
||||
|
@ -388,7 +391,7 @@ export default {
|
|||
const refreshedToken = await this.refreshToken(token, getDriveScopes(token));
|
||||
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);
|
||||
return this.$removeFile(refreshedToken, id, ifNotTooLate);
|
||||
},
|
||||
|
@ -599,7 +602,7 @@ export default {
|
|||
}
|
||||
const refreshedToken = await this.refreshToken(token, scopes);
|
||||
const { google } = window;
|
||||
return Promise((resolve) => {
|
||||
return new Promise((resolve) => {
|
||||
let picker;
|
||||
const pickerBuilder = new google.picker.PickerBuilder()
|
||||
.setOAuthToken(refreshedToken.accessToken)
|
||||
|
|
|
@ -57,7 +57,10 @@ export default {
|
|||
}
|
||||
// Existing token is going to expire.
|
||||
// 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);
|
||||
},
|
||||
addAccount(fullAccess = false) {
|
||||
|
|
|
@ -103,24 +103,22 @@ const requestPublish = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
store.dispatch('queue/enqueuePublishRequest', () => new Promise((resolve, reject) => {
|
||||
store.dispatch('queue/enqueuePublishRequest', async () => {
|
||||
let intervalId;
|
||||
const attempt = () => {
|
||||
const attempt = async () => {
|
||||
// Only start publishing when these conditions are met
|
||||
if (networkSvc.isUserActive()) {
|
||||
clearInterval(intervalId);
|
||||
if (!hasCurrentFilePublishLocations()) {
|
||||
// Cancel sync
|
||||
reject(new Error('Publish not possible.'));
|
||||
return;
|
||||
throw new Error('Publish not possible.');
|
||||
}
|
||||
publishFile(store.getters['file/current'].id)
|
||||
.then(resolve, reject);
|
||||
await publishFile(store.getters['file/current'].id);
|
||||
}
|
||||
};
|
||||
intervalId = utils.setInterval(() => attempt(), 1000);
|
||||
attempt();
|
||||
}));
|
||||
return attempt();
|
||||
});
|
||||
};
|
||||
|
||||
const createPublishLocation = (publishLocation) => {
|
||||
|
|
|
@ -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 tooLateAfter = Date.now() + timeout;
|
||||
return cb => (res) => {
|
||||
return (cb) => {
|
||||
if (tooLateAfter < Date.now()) {
|
||||
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) => {
|
||||
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
|
||||
return false;
|
||||
}
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
|
@ -254,15 +260,17 @@ class SyncContext {
|
|||
* Sync one file with all its locations.
|
||||
*/
|
||||
const syncFile = async (fileId, syncContext = new SyncContext()) => {
|
||||
syncContext.attempted[`${fileId}/content`] = true;
|
||||
const contentId = `${fileId}/content`;
|
||||
syncContext.attempted[contentId] = true;
|
||||
|
||||
await localDbSvc.loadSyncedContent(fileId);
|
||||
try {
|
||||
await localDbSvc.loadItem(`${fileId}/content`);
|
||||
await localDbSvc.loadItem(contentId);
|
||||
} catch (e) {
|
||||
// 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 getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId];
|
||||
|
||||
|
@ -288,28 +296,88 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
|
|||
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 serverContent = await provider.downloadContent(token, syncLocation);
|
||||
const serverContent = await downloadContent(token, syncLocation);
|
||||
const syncedContent = getSyncedContent();
|
||||
const syncHistoryItem = getSyncHistoryItem(syncLocation.id);
|
||||
let mergedContent = (() => {
|
||||
|
||||
// Merge content
|
||||
let mergedContent;
|
||||
const clientContent = utils.deepCopy(getContent());
|
||||
if (!clientContent) {
|
||||
return utils.deepCopy(serverContent || null);
|
||||
}
|
||||
if (!serverContent) {
|
||||
// Sync location has not been created yet
|
||||
return clientContent;
|
||||
}
|
||||
if (serverContent.hash === clientContent.hash) {
|
||||
// Server and client contents are synced
|
||||
return clientContent;
|
||||
}
|
||||
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
|
||||
mergedContent = utils.deepCopy(serverContent || null);
|
||||
} else if (!serverContent // If sync location has not been created yet
|
||||
// Or server and client contents are synced
|
||||
|| serverContent.hash === clientContent.hash
|
||||
// Or server content has not changed or has already been merged
|
||||
|| syncedContent.historyData[serverContent.hash]
|
||||
) {
|
||||
mergedContent = clientContent;
|
||||
} else {
|
||||
// Perform a merge with last merged content if any, or perform a simple fusion otherwise
|
||||
let lastMergedContent = utils.someResult(
|
||||
serverContent.history,
|
||||
hash => syncedContent.historyData[hash],
|
||||
|
@ -317,16 +385,15 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
|
|||
if (!lastMergedContent && syncHistoryItem) {
|
||||
lastMergedContent = syncedContent.historyData[syncHistoryItem[LAST_MERGED]];
|
||||
}
|
||||
return diffUtils.mergeContent(serverContent, clientContent, lastMergedContent);
|
||||
})();
|
||||
|
||||
mergedContent = diffUtils.mergeContent(serverContent, clientContent, lastMergedContent);
|
||||
}
|
||||
if (!mergedContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update or set content in store
|
||||
store.commit('content/setItem', {
|
||||
id: `${fileId}/content`,
|
||||
id: contentId,
|
||||
text: utils.sanitizeText(mergedContent.text),
|
||||
properties: utils.sanitizeText(mergedContent.properties),
|
||||
discussions: mergedContent.discussions,
|
||||
|
@ -356,8 +423,7 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Store last sent if it's in the server history,
|
||||
// and merged content which will be sent if different
|
||||
// Update synced content
|
||||
const newSyncedContent = utils.deepCopy(syncedContent);
|
||||
const newSyncHistoryItem = newSyncedContent.syncHistory[syncLocation.id] || [];
|
||||
newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem;
|
||||
|
@ -384,10 +450,14 @@ const syncFile = async (fileId, syncContext = new SyncContext()) => {
|
|||
}
|
||||
|
||||
// Upload merged content
|
||||
const syncLocationToStore = await provider.uploadContent(token, {
|
||||
const item = {
|
||||
...mergedContent,
|
||||
history: mergedContentHistory.slice(0, maxContentHistory),
|
||||
}, syncLocation, tooLateChecker(restartContentSyncAfter));
|
||||
};
|
||||
const syncLocationToStore = await uploadContent(
|
||||
item,
|
||||
tooLateChecker(restartContentSyncAfter),
|
||||
);
|
||||
|
||||
// Replace sync location if modified
|
||||
if (utils.serializeObject(syncLocation) !==
|
||||
|
@ -437,14 +507,30 @@ const syncDataItem = async (dataId) => {
|
|||
const getItem = () => store.state.data.itemMap[dataId]
|
||||
|| store.state.data.lsItemMap[dataId];
|
||||
|
||||
const item = getItem();
|
||||
const syncData = store.getters['data/syncDataByItemId'][dataId];
|
||||
const oldItem = getItem();
|
||||
const oldSyncData = store.getters['data/syncDataByItemId'][dataId];
|
||||
// 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;
|
||||
}
|
||||
|
||||
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];
|
||||
let mergedItem = (() => {
|
||||
const clientItem = utils.deepCopy(getItem());
|
||||
|
@ -487,7 +573,25 @@ const syncDataItem = async (dataId) => {
|
|||
if (serverItem && serverItem.hash === mergedItem.hash) {
|
||||
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', {
|
||||
[dataId]: utils.deepCopy(store.getters['data/syncDataByItemId'][dataId]),
|
||||
});
|
||||
|
@ -523,7 +627,7 @@ const syncWorkspace = async () => {
|
|||
const ifNotTooLate = tooLateChecker(restartSyncAfter);
|
||||
|
||||
// Called until no item to save
|
||||
const saveNextItem = ifNotTooLate(async () => {
|
||||
const saveNextItem = () => ifNotTooLate(async () => {
|
||||
const storeItemMap = {
|
||||
...store.state.file.itemMap,
|
||||
...store.state.folder.itemMap,
|
||||
|
@ -531,16 +635,25 @@ const syncWorkspace = async () => {
|
|||
...store.state.publishLocation.itemMap,
|
||||
// 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'];
|
||||
getSyncData = id => syncDataByItemId[id];
|
||||
}
|
||||
|
||||
const [changedItem, syncDataToUpdate] = utils.someResult(
|
||||
Object.entries(storeItemMap),
|
||||
([id, item]) => {
|
||||
const existingSyncData = syncDataByItemId[id];
|
||||
const existingSyncData = getSyncData(id);
|
||||
if ((!existingSyncData || existingSyncData.hash !== item.hash)
|
||||
// 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
|
||||
&& (item.type !== 'file' || syncDataByItemId[`${id}/content`])
|
||||
&& (item.type !== 'file' || getSyncData(`${id}/content`))
|
||||
) {
|
||||
return [item, existingSyncData];
|
||||
}
|
||||
|
@ -550,7 +663,7 @@ const syncWorkspace = async () => {
|
|||
|
||||
if (changedItem) {
|
||||
const resultSyncData = await workspaceProvider
|
||||
.saveSimpleItem(
|
||||
.saveWorkspaceItem(
|
||||
// Use deepCopy to freeze objects
|
||||
utils.deepCopy(changedItem),
|
||||
utils.deepCopy(syncDataToUpdate),
|
||||
|
@ -565,29 +678,39 @@ const syncWorkspace = async () => {
|
|||
await saveNextItem();
|
||||
|
||||
// Called until no item to remove
|
||||
const removeNextItem = ifNotTooLate(async () => {
|
||||
const storeItemMap = {
|
||||
...store.state.file.itemMap,
|
||||
...store.state.folder.itemMap,
|
||||
...store.state.syncLocation.itemMap,
|
||||
...store.state.publishLocation.itemMap,
|
||||
...store.state.content.itemMap,
|
||||
};
|
||||
const removeNextItem = () => ifNotTooLate(async () => {
|
||||
let getItem;
|
||||
let getFileItem;
|
||||
if (workspaceProvider.isGit) {
|
||||
const { gitPathItems } = store.getters;
|
||||
getItem = syncData => gitPathItems[syncData.id];
|
||||
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 syncDataToRemove = utils.deepCopy(utils.someResult(
|
||||
Object.values(syncData),
|
||||
existingSyncData => !storeItemMap[existingSyncData.itemId]
|
||||
(existingSyncData) => {
|
||||
if (!getItem(existingSyncData)
|
||||
// We don't want to delete data items, especially on first sync
|
||||
&& existingSyncData.type !== 'data'
|
||||
// Remove content only if file has been removed
|
||||
&& (existingSyncData.type !== 'content'
|
||||
|| !storeItemMap[existingSyncData.itemId.split('/')[0]])
|
||||
&& existingSyncData,
|
||||
|| !getFileItem(existingSyncData))
|
||||
) {
|
||||
return existingSyncData;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
));
|
||||
|
||||
if (syncDataToRemove) {
|
||||
// Use deepCopy to freeze objects
|
||||
await workspaceProvider.removeItem(syncDataToRemove, ifNotTooLate);
|
||||
await workspaceProvider.removeWorkspaceItem(syncDataToRemove, ifNotTooLate);
|
||||
const syncDataCopy = { ...store.getters['data/syncData'] };
|
||||
delete syncDataCopy[syncDataToRemove.id];
|
||||
store.dispatch('data/setSyncData', syncDataCopy);
|
||||
|
@ -608,11 +731,22 @@ const syncWorkspace = async () => {
|
|||
...Object.keys(localDbSvc.hashMap.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) => {
|
||||
// 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 syncData = store.getters['data/syncDataByItemId'][contentId];
|
||||
const syncData = getSyncData(contentId);
|
||||
if (
|
||||
// Sync if content syncing was not attempted yet
|
||||
!syncContext.attempted[contentId] &&
|
||||
|
@ -721,7 +855,7 @@ const requestSync = () => {
|
|||
};
|
||||
|
||||
intervalId = utils.setInterval(() => attempt(), 1000);
|
||||
attempt();
|
||||
return attempt();
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -23,7 +23,6 @@ const parseQueryParams = (params) => {
|
|||
return result;
|
||||
};
|
||||
|
||||
|
||||
// For utils.computeProperties()
|
||||
const deepOverride = (obj, opt) => {
|
||||
if (obj === undefined) {
|
||||
|
@ -172,10 +171,12 @@ export default {
|
|||
return Math.abs(this.hash(this.serializeObject(params))).toString(36);
|
||||
},
|
||||
encodeBase64(str, urlSafe = false) {
|
||||
const result = btoa(encodeURIComponent(str).replace(
|
||||
const uriEncodedStr = encodeURIComponent(str);
|
||||
const utf8Str = uriEncodedStr.replace(
|
||||
/%([0-9A-F]{2})/g,
|
||||
(match, p1) => String.fromCharCode(`0x${p1}`),
|
||||
));
|
||||
);
|
||||
const result = btoa(utf8Str);
|
||||
if (!urlSafe) {
|
||||
return result;
|
||||
}
|
||||
|
@ -187,10 +188,12 @@ export default {
|
|||
decodeBase64(str) {
|
||||
// In case of URL safe base64
|
||||
const sanitizedStr = str.replace(/_/g, '/').replace(/-/g, '+');
|
||||
return decodeURIComponent(atob(sanitizedStr)
|
||||
const utf8Str = atob(sanitizedStr);
|
||||
const uriEncodedStr = utf8Str
|
||||
.split('')
|
||||
.map(c => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`)
|
||||
.join(''));
|
||||
.join('');
|
||||
return decodeURIComponent(uriEncodedStr);
|
||||
},
|
||||
computeProperties(yamlProperties) {
|
||||
let properties = {};
|
||||
|
|
|
@ -82,7 +82,7 @@ module.actions = {
|
|||
}) {
|
||||
const { revisionContent } = state;
|
||||
if (revisionContent) {
|
||||
await dispatch('modal/fileRestoration', null, { root: true });
|
||||
await dispatch('modal/open', 'fileRestoration', { root: true });
|
||||
// Close revision
|
||||
commit('setRevisionContent');
|
||||
const currentContent = utils.deepCopy(getters.current);
|
||||
|
|
|
@ -26,6 +26,7 @@ export default {
|
|||
setTimeout(() => {
|
||||
// Take the size of the context menu and place it
|
||||
const elt = document.querySelector('.context-menu__inner');
|
||||
if (elt) {
|
||||
const height = elt.offsetHeight;
|
||||
if (coordinates.top + height > rootState.layout.bodyHeight) {
|
||||
coordinates.top -= height;
|
||||
|
@ -41,6 +42,7 @@ export default {
|
|||
coordinates.left = 0;
|
||||
}
|
||||
commit('setCoordinates', coordinates);
|
||||
}
|
||||
}, 1);
|
||||
|
||||
return new Promise(resolve => commit('setResolve', resolve));
|
||||
|
|
|
@ -135,7 +135,7 @@ export default {
|
|||
const loginToken = rootGetters['workspace/loginToken'];
|
||||
if (!loginToken) {
|
||||
try {
|
||||
await dispatch('modal/signInForComment', null, { root: true });
|
||||
await dispatch('modal/open', 'signInForComment', { root: true });
|
||||
await googleHelper.signin();
|
||||
syncSvc.requestSync();
|
||||
await dispatch('createNewDiscussion', selection);
|
||||
|
|
|
@ -18,11 +18,10 @@ const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
|
|||
const compare = (node1, node2) => collator.compare(node1.item.name, node2.item.name);
|
||||
|
||||
class Node {
|
||||
constructor(item, locations = [], isFolder = false, isRoot = false) {
|
||||
constructor(item, locations = [], isFolder = false) {
|
||||
this.item = item;
|
||||
this.locations = locations;
|
||||
this.isFolder = isFolder;
|
||||
this.isRoot = isRoot;
|
||||
if (isFolder) {
|
||||
this.folders = [];
|
||||
this.files = [];
|
||||
|
@ -84,7 +83,8 @@ export default {
|
|||
},
|
||||
getters: {
|
||||
nodeStructure: (state, getters, rootState, rootGetters) => {
|
||||
const rootNode = new Node(emptyFolder(), [], true, true);
|
||||
const rootNode = new Node(emptyFolder(), [], true);
|
||||
rootNode.isRoot = true;
|
||||
|
||||
// Create Trash node
|
||||
const trashFolderNode = new Node(emptyFolder(), [], true);
|
||||
|
|
|
@ -119,6 +119,41 @@ const store = new Vuex.Store({
|
|||
});
|
||||
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) => {
|
||||
const sponsorToken = getters['workspace/sponsorToken'];
|
||||
return light || monetizeSponsor || (sponsorToken && sponsorToken.isSponsor);
|
||||
|
|
|
@ -36,100 +36,5 @@ export default {
|
|||
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',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
const itemTimeout = 5000;
|
||||
const defaultTimeout = 5000;
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
|
@ -14,8 +14,10 @@ export default {
|
|||
showItem({ state, commit }, item) {
|
||||
if (state.items.every(other => other.type !== item.type || other.content !== item.content)) {
|
||||
commit('setItems', [...state.items, item]);
|
||||
setTimeout(() =>
|
||||
commit('setItems', state.items.filter(otherItem => otherItem !== item)), itemTimeout);
|
||||
setTimeout(
|
||||
() => commit('setItems', state.items.filter(otherItem => otherItem !== item)),
|
||||
item.timeout || defaultTimeout,
|
||||
);
|
||||
}
|
||||
},
|
||||
info({ dispatch }, info) {
|
||||
|
@ -25,9 +27,7 @@ export default {
|
|||
});
|
||||
},
|
||||
error({ dispatch, rootState }, error) {
|
||||
const item = {
|
||||
type: 'error',
|
||||
};
|
||||
const item = { type: 'error' };
|
||||
if (error) {
|
||||
if (error.message) {
|
||||
item.content = error.message;
|
||||
|
|
|
@ -15,6 +15,10 @@
|
|||
<meta name="google-site-verification" content="iWDn0T2r2_bDQWp_nW23MGePbO9X0M8wQSzbOU70pFQ" />
|
||||
<link rel="stylesheet" href="https://stackedit.io/style.css">
|
||||
<style>
|
||||
body {
|
||||
background-color: #fbfbfb;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
@ -244,7 +248,14 @@
|
|||
.image {
|
||||
display: block;
|
||||
margin: 1em auto;
|
||||
border: 2px solid #f3f3f3;
|
||||
border-radius: 2px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.image img {
|
||||
display: block;
|
||||
margin: 0.5em auto;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
|
@ -295,7 +306,9 @@
|
|||
</div>
|
||||
</div>
|
||||
<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 class="row">
|
||||
|
@ -339,7 +352,7 @@
|
|||
<div class="column">
|
||||
<div class="feature">
|
||||
<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>
|
||||
<img class="image" width="300" src="static/landing/workspace.png">
|
||||
</div>
|
||||
|
@ -361,7 +374,9 @@
|
|||
<div class="row">
|
||||
<div class="column">
|
||||
<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 class="column">
|
||||
<div class="feature">
|
||||
|
@ -373,7 +388,9 @@
|
|||
<div class="row">
|
||||
<div class="column">
|
||||
<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 class="column">
|
||||
<div class="feature">
|
||||
|
@ -384,7 +401,9 @@
|
|||
</div>
|
||||
<div class="row">
|
||||
<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 class="column">
|
||||
<div class="feature">
|
||||
|
|
8
test/unit/.eslintrc
Normal file
8
test/unit/.eslintrc
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"env": {
|
||||
"jest": true
|
||||
},
|
||||
"extends": [
|
||||
"../../.eslintrc.js"
|
||||
]
|
||||
}
|
35
test/unit/jest.conf.js
Normal file
35
test/unit/jest.conf.js
Normal 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',
|
||||
},
|
||||
};
|
7
test/unit/mocks/cryptoMock.js
Normal file
7
test/unit/mocks/cryptoMock.js
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
9
test/unit/mocks/localStorageMock.js
Normal file
9
test/unit/mocks/localStorageMock.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
const store = {};
|
||||
window.localStorage = {
|
||||
getItem(key) {
|
||||
return store[key] || null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
store[key] = value.toString();
|
||||
},
|
||||
};
|
6
test/unit/mocks/mutationObserverMock.js
Normal file
6
test/unit/mocks/mutationObserverMock.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
/* eslint-disable class-methods-use-this */
|
||||
class MutationObserver {
|
||||
observe() {
|
||||
}
|
||||
}
|
||||
window.MutationObserver = MutationObserver;
|
1
test/unit/mocks/templateWorkerMock.js
Normal file
1
test/unit/mocks/templateWorkerMock.js
Normal file
|
@ -0,0 +1 @@
|
|||
module.exports = 'test-file-stub';
|
5
test/unit/setup.js
Normal file
5
test/unit/setup.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import Vue from 'vue';
|
||||
import './mocks/cryptoMock';
|
||||
import './mocks/mutationObserverMock';
|
||||
|
||||
Vue.config.productionTip = false;
|
41
test/unit/specs/components/ButtonBar.spec.js
Normal file
41
test/unit/specs/components/ButtonBar.spec.js
Normal 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,
|
||||
));
|
||||
});
|
32
test/unit/specs/components/ContextMenu.spec.js
Normal file
32
test/unit/specs/components/ContextMenu.spec.js
Normal 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);
|
||||
});
|
||||
});
|
186
test/unit/specs/components/Explorer.spec.js
Normal file
186
test/unit/specs/components/Explorer.spec.js
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
273
test/unit/specs/components/ExplorerNode.spec.js
Normal file
273
test/unit/specs/components/ExplorerNode.spec.js
Normal 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);
|
||||
});
|
||||
});
|
17
test/unit/specs/components/NavigationBar.spec.js
Normal file
17
test/unit/specs/components/NavigationBar.spec.js
Normal 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,
|
||||
));
|
||||
});
|
38
test/unit/specs/components/Notification.spec.js
Normal file
38
test/unit/specs/components/Notification.spec.js
Normal 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);
|
||||
});
|
||||
});
|
51
test/unit/specs/specUtils.js
Normal file
51
test/unit/specs/specUtils.js
Normal 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));
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue
Block a user