Github workspace (part 1)

This commit is contained in:
Benoit Schweblin 2018-04-27 15:37:05 +01:00
parent 790ac996dd
commit 53ccee0d84
59 changed files with 1056 additions and 333 deletions

View File

@ -6,11 +6,12 @@
https://stackedit.io/ https://stackedit.io/
### NEW!!! Embed StackEdit in any website! ### Ecosystem
See https://github.com/benweet/stackedit.js - [Chrome app](https://chrome.google.com/webstore/detail/iiooodelglhkcpgbajoejffhijaclcdg)
- NEW! Embed StackEdit in any website with [stackedit.js](https://github.com/benweet/stackedit.js)
Chrome extension: https://chrome.google.com/webstore/detail/ajehldoplanpchfokmeempkekhnhmoha - NEW! [Chrome extension](https://chrome.google.com/webstore/detail/ajehldoplanpchfokmeempkekhnhmoha) that uses stackedit.js
- [Community](https://community.stackedit.io/)
### Build Setup ### Build Setup

View File

@ -41,6 +41,7 @@ import DropboxPublishModal from './modals/providers/DropboxPublishModal';
import GithubAccountModal from './modals/providers/GithubAccountModal'; import GithubAccountModal from './modals/providers/GithubAccountModal';
import GithubOpenModal from './modals/providers/GithubOpenModal'; import GithubOpenModal from './modals/providers/GithubOpenModal';
import GithubSaveModal from './modals/providers/GithubSaveModal'; import GithubSaveModal from './modals/providers/GithubSaveModal';
import GithubWorkspaceModal from './modals/providers/GithubWorkspaceModal';
import GithubPublishModal from './modals/providers/GithubPublishModal'; import GithubPublishModal from './modals/providers/GithubPublishModal';
import GistSyncModal from './modals/providers/GistSyncModal'; import GistSyncModal from './modals/providers/GistSyncModal';
import GistPublishModal from './modals/providers/GistPublishModal'; import GistPublishModal from './modals/providers/GistPublishModal';
@ -84,6 +85,7 @@ export default {
GithubAccountModal, GithubAccountModal,
GithubOpenModal, GithubOpenModal,
GithubSaveModal, GithubSaveModal,
GithubWorkspaceModal,
GithubPublishModal, GithubPublishModal,
GistSyncModal, GistSyncModal,
GistPublishModal, GistPublishModal,
@ -176,6 +178,10 @@ export default {
hr { hr {
margin: 0.5em 0; margin: 0.5em 0;
} }
p {
line-height: 1.5;
}
} }
.modal__inner-1 { .modal__inner-1 {
@ -221,9 +227,9 @@ export default {
.modal__image { .modal__image {
float: left; float: left;
width: 64px; width: 60px;
height: 64px; height: 60px;
margin: 1.5em 1.5em 0.5em 0; margin: 1.5em 1.2em 0.5em 0;
& + *::after { & + *::after {
content: ''; content: '';
@ -262,6 +268,11 @@ export default {
} }
} }
.modal__info--multiline {
padding-top: 0.1em;
padding-bottom: 0.1em;
}
.modal__button-bar { .modal__button-bar {
margin-top: 1.75rem; margin-top: 1.75rem;
text-align: right; text-align: right;

View File

@ -31,7 +31,7 @@
<script> <script>
import { mapMutations, mapGetters } from 'vuex'; import { mapMutations, mapGetters } from 'vuex';
import providerRegistry from '../../services/providers/providerRegistry'; import providerRegistry from '../../services/providers/common/providerRegistry';
import MenuEntry from './common/MenuEntry'; import MenuEntry from './common/MenuEntry';
import UserImage from '../UserImage'; import UserImage from '../UserImage';
import UserName from '../UserName'; import UserName from '../UserName';
@ -48,7 +48,7 @@ let cachedFileId;
let revisionsPromise; let revisionsPromise;
let revisionContentPromises; let revisionContentPromises;
const pageSize = 30; const pageSize = 30;
const spacerThreshold = 12 * 60 * 60 * 1000; // 12h const spacerThreshold = 60 * 60 * 1000; // 1h
export default { export default {
components: { components: {

View File

@ -27,7 +27,7 @@
import TurndownService from 'turndown/lib/turndown.browser.umd'; import TurndownService from 'turndown/lib/turndown.browser.umd';
import htmlSanitizer from '../../libs/htmlSanitizer'; import htmlSanitizer from '../../libs/htmlSanitizer';
import MenuEntry from './common/MenuEntry'; import MenuEntry from './common/MenuEntry';
import providerUtils from '../../services/providers/providerUtils'; import Provider from '../../services/providers/common/Provider';
import store from '../../store'; import store from '../../store';
const turndownService = new TurndownService(store.getters['data/computedSettings'].turndown); const turndownService = new TurndownService(store.getters['data/computedSettings'].turndown);
@ -56,7 +56,7 @@ export default {
const file = evt.target.files[0]; const file = evt.target.files[0];
readFile(file) readFile(file)
.then(content => this.$store.dispatch('createFile', { .then(content => this.$store.dispatch('createFile', {
...providerUtils.parseContent(content), ...Provider.parseContent(content),
name: file.name, name: file.name,
}) })
.then(item => this.$store.commit('file/setCurrentId', item.id))); .then(item => this.$store.commit('file/setCurrentId', item.id)));
@ -65,7 +65,7 @@ export default {
const file = evt.target.files[0]; const file = evt.target.files[0];
readFile(file) readFile(file)
.then(content => this.$store.dispatch('createFile', { .then(content => this.$store.dispatch('createFile', {
...providerUtils.parseContent( ...Provider.parseContent(
turndownService.turndown( turndownService.turndown(
htmlSanitizer.sanitizeHtml(content) htmlSanitizer.sanitizeHtml(content)
.replace(/&#160;/g, ' '), // Replace non-breaking spaces with classic spaces .replace(/&#160;/g, ' '), // Replace non-breaking spaces with classic spaces

View File

@ -7,14 +7,18 @@
</menu-entry> </menu-entry>
</div> </div>
<hr> <hr>
<menu-entry @click.native="addGoogleDriveWorkspace">
<icon-provider slot="icon" provider-id="googleDriveWorkspace"></icon-provider>
<span>Add Google Drive workspace</span>
</menu-entry>
<menu-entry @click.native="addCouchdbWorkspace"> <menu-entry @click.native="addCouchdbWorkspace">
<icon-provider slot="icon" provider-id="couchdbWorkspace"></icon-provider> <icon-provider slot="icon" provider-id="couchdbWorkspace"></icon-provider>
<span>Add CouchDB workspace</span> <span>Add CouchDB workspace</span>
</menu-entry> </menu-entry>
<menu-entry @click.native="addGithubWorkspace">
<icon-provider slot="icon" provider-id="githubWorkspace"></icon-provider>
<span>Add GitHub workspace</span>
</menu-entry>
<menu-entry @click.native="addGoogleDriveWorkspace">
<icon-provider slot="icon" provider-id="googleDriveWorkspace"></icon-provider>
<span>Add Google Drive workspace</span>
</menu-entry>
<menu-entry @click.native="manageWorkspaces"> <menu-entry @click.native="manageWorkspaces">
<icon-database slot="icon"></icon-database> <icon-database slot="icon"></icon-database>
<span>Manage workspaces</span> <span>Manage workspaces</span>
@ -40,6 +44,18 @@ export default {
]), ]),
}, },
methods: { methods: {
addCouchdbWorkspace() {
return this.$store.dispatch('modal/open', {
type: 'couchdbWorkspace',
})
.catch(() => {}); // Cancel
},
addGithubWorkspace() {
return this.$store.dispatch('modal/open', {
type: 'githubWorkspace',
})
.catch(() => {}); // Cancel
},
addGoogleDriveWorkspace() { addGoogleDriveWorkspace() {
return googleHelper.addDriveAccount(true) return googleHelper.addDriveAccount(true)
.then(token => this.$store.dispatch('modal/open', { .then(token => this.$store.dispatch('modal/open', {
@ -48,12 +64,6 @@ export default {
})) }))
.catch(() => {}); // Cancel .catch(() => {}); // Cancel
}, },
addCouchdbWorkspace() {
return this.$store.dispatch('modal/open', {
type: 'couchdbWorkspace',
})
.catch(() => {}); // Cancel
},
manageWorkspaces() { manageWorkspaces() {
return this.$store.dispatch('modal/open', 'workspaceManagement'); return this.$store.dispatch('modal/open', 'workspaceManagement');
}, },

View File

@ -10,9 +10,9 @@
<br> <br>
<a target="_blank" href="https://chrome.google.com/webstore/detail/iiooodelglhkcpgbajoejffhijaclcdg">Chrome app</a> <a target="_blank" href="https://chrome.google.com/webstore/detail/ajehldoplanpchfokmeempkekhnhmoha">Chrome extension</a> <a target="_blank" href="https://chrome.google.com/webstore/detail/iiooodelglhkcpgbajoejffhijaclcdg">Chrome app</a> <a target="_blank" href="https://chrome.google.com/webstore/detail/ajehldoplanpchfokmeempkekhnhmoha">Chrome extension</a>
<br> <br>
StackEdit on <a target="_blank" href="https://twitter.com/stackedit/">Twitter</a> <a target="_blank" href="https://community.stackedit.io/">Community</a> <a target="_blank" href="https://community.stackedit.io/c/how-to">Tutos and How To</a>
<br> <br>
<a target="_blank" href="https://community.stackedit.io/">Community</a> StackEdit on <a target="_blank" href="https://twitter.com/stackedit/">Twitter</a>
<div class="modal__info"> <div class="modal__info">
For commercial support or custom development, please <a href="mailto:stackedit.project@gmail.com">send us an email</a>. For commercial support or custom development, please <a href="mailto:stackedit.project@gmail.com">send us an email</a>.
</div> </div>

View File

@ -54,7 +54,7 @@
</div> </div>
</div> </div>
<div class="modal__error modal__error--file-properties">{{error}}</div> <div class="modal__error modal__error--file-properties">{{error}}</div>
<div class="modal__info"> <div class="modal__info modal__info--multiline">
<p><strong>ProTip:</strong> You can manually toggle extensions:</p> <p><strong>ProTip:</strong> You can manually toggle extensions:</p>
<pre class=" language-yaml"><code class="prism language-yaml"><span class="token key atrule">extensions</span><span class="token punctuation">:</span> <pre class=" language-yaml"><code class="prism language-yaml"><span class="token key atrule">extensions</span><span class="token punctuation">:</span>
<span class="token key atrule">emoji</span><span class="token punctuation">:</span> <span class="token key atrule">emoji</span><span class="token punctuation">:</span>

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="bloggerPage"></icon-provider> <icon-provider provider-id="bloggerPage"></icon-provider>
</div> </div>
<p>This will publish <b>{{currentFileName}}</b> to your <b>Blogger Page</b>.</p> <p>Publish <b>{{currentFileName}}</b> to your <b>Blogger Page</b>.</p>
<form-entry label="Blog URL" error="blogUrl"> <form-entry label="Blog URL" error="blogUrl">
<input slot="field" class="textfield" type="text" v-model.trim="blogUrl" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="blogUrl" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="blogger"></icon-provider> <icon-provider provider-id="blogger"></icon-provider>
</div> </div>
<p>This will publish <b>{{currentFileName}}</b> to your <b>Blogger</b> site.</p> <p>Publish <b>{{currentFileName}}</b> to your <b>Blogger</b> site.</p>
<form-entry label="Blog URL" error="blogUrl"> <form-entry label="Blog URL" error="blogUrl">
<input slot="field" class="textfield" type="text" v-model.trim="blogUrl" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="blogUrl" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="couchdb"></icon-provider> <icon-provider provider-id="couchdb"></icon-provider>
</div> </div>
<p>This will create a workspace synchronized with a <b>CouchDB</b> database.</p> <p>Create a workspace synchronized with a <b>CouchDB</b> database.</p>
<form-entry label="Database URL" error="dbUrl"> <form-entry label="Database URL" error="dbUrl">
<input slot="field" class="textfield" type="text" v-model.trim="dbUrl" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="dbUrl" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="dropbox"></icon-provider> <icon-provider provider-id="dropbox"></icon-provider>
</div> </div>
<p>This will link your <b>Dropbox</b> account to <b>StackEdit</b>.</p> <p>Link your <b>Dropbox</b> account to <b>StackEdit</b>.</p>
<div class="form-entry"> <div class="form-entry">
<div class="form-entry__checkbox"> <div class="form-entry__checkbox">
<label> <label>

View File

@ -4,12 +4,12 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="dropbox"></icon-provider> <icon-provider provider-id="dropbox"></icon-provider>
</div> </div>
<p>This will publish <b>{{currentFileName}}</b> to your <b>Dropbox</b>.</p> <p>Publish <b>{{currentFileName}}</b> to your <b>Dropbox</b>.</p>
<form-entry label="File path" error="path"> <form-entry label="File path" error="path">
<input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
<b>Example:</b> {{config.token.fullAccess ? '' : '/Applications/StackEdit (restricted)'}}/path/to/My Document.html<br> <b>Example:</b> {{config.token.fullAccess ? '' : '/Applications/StackEdit (restricted)'}}/path/to/My Document.html<br>
If the file exists, it will be replaced. If the file exists, it will be overwritten.
</div> </div>
</form-entry> </form-entry>
<form-entry label="Template"> <form-entry label="Template">

View File

@ -4,12 +4,12 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="dropbox"></icon-provider> <icon-provider provider-id="dropbox"></icon-provider>
</div> </div>
<p>This will save <b>{{currentFileName}}</b> to your <b>Dropbox</b> and keep it synchronized.</p> <p>Save <b>{{currentFileName}}</b> to your <b>Dropbox</b> and keep it synchronized.</p>
<form-entry label="File path" error="path"> <form-entry label="File path" error="path">
<input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
<b>Example:</b> {{config.token.fullAccess ? '' : '/Applications/StackEdit (restricted)'}}/path/to/My Document.md<br> <b>Example:</b> {{config.token.fullAccess ? '' : '/Applications/StackEdit (restricted)'}}/path/to/My Document.md<br>
If the file exists, it will be replaced. If the file exists, it will be overwritten.
</div> </div>
</form-entry> </form-entry>
</div> </div>

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="gist"></icon-provider> <icon-provider provider-id="gist"></icon-provider>
</div> </div>
<p>This will publish <b>{{currentFileName}}</b> to a <b>Gist</b>.</p> <p>Publish <b>{{currentFileName}}</b> to a <b>Gist</b>.</p>
<form-entry label="Filename" error="filename"> <form-entry label="Filename" error="filename">
<input slot="field" class="textfield" type="text" v-model.trim="filename" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="filename" @keydown.enter="resolve()">
</form-entry> </form-entry>
@ -18,7 +18,7 @@
<form-entry label="Existing Gist ID" info="optional"> <form-entry label="Existing Gist ID" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="gistId" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="gistId" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
If the file exists in the Gist, it will be replaced. If the file exists in the Gist, it will be overwritten.
</div> </div>
</form-entry> </form-entry>
<form-entry label="Template"> <form-entry label="Template">

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="gist"></icon-provider> <icon-provider provider-id="gist"></icon-provider>
</div> </div>
<p>This will save <b>{{currentFileName}}</b> to a <b>Gist</b> and keep it synchronized.</p> <p>Save <b>{{currentFileName}}</b> to a <b>Gist</b> and keep it synchronized.</p>
<form-entry label="Filename" error="filename"> <form-entry label="Filename" error="filename">
<input slot="field" class="textfield" type="text" v-model.trim="filename" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="filename" @keydown.enter="resolve()">
</form-entry> </form-entry>
@ -18,7 +18,7 @@
<form-entry label="Existing Gist ID" info="optional"> <form-entry label="Existing Gist ID" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="gistId" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="gistId" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
If the file exists in the Gist, it will be replaced. If the file exists in the Gist, it will be overwritten.
</div> </div>
</form-entry> </form-entry>
</div> </div>

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="github"></icon-provider> <icon-provider provider-id="github"></icon-provider>
</div> </div>
<p>This will link your <b>GitHub</b> account to <b>StackEdit</b>.</p> <p>Link your <b>GitHub</b> account to <b>StackEdit</b>.</p>
<div class="form-entry"> <div class="form-entry">
<div class="form-entry__checkbox"> <div class="form-entry__checkbox">
<label> <label>

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="github"></icon-provider> <icon-provider provider-id="github"></icon-provider>
</div> </div>
<p>This will open a file from your <b>GitHub</b> repository and keep it synchronized.</p> <p>Open a file from your <b>GitHub</b> repository and keep it synchronized.</p>
<form-entry label="Repository URL" error="repoUrl"> <form-entry label="Repository URL" error="repoUrl">
<input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
@ -14,13 +14,13 @@
<form-entry label="Branch" info="optional"> <form-entry label="Branch" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="branch" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="branch" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
If not provided, the <code>master</code> branch will be used. If not supplied, the <code>master</code> branch will be used.
</div> </div>
</form-entry> </form-entry>
<form-entry label="File path" error="path"> <form-entry label="File path" error="path">
<input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
<b>Example:</b> docs/README.md <b>Example:</b> path/to/README.md
</div> </div>
</form-entry> </form-entry>
</div> </div>

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="github"></icon-provider> <icon-provider provider-id="github"></icon-provider>
</div> </div>
<p>This will publish <b>{{currentFileName}}</b> to your <b>GitHub</b> repository.</p> <p>Publish <b>{{currentFileName}}</b> to your <b>GitHub</b> repository.</p>
<form-entry label="Repository URL" error="repoUrl"> <form-entry label="Repository URL" error="repoUrl">
<input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
@ -14,14 +14,14 @@
<form-entry label="Branch" info="optional"> <form-entry label="Branch" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="branch" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="branch" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
If not provided, the master branch will be used. If not supplied, the <code>master</code> branch will be used.
</div> </div>
</form-entry> </form-entry>
<form-entry label="File path" error="path"> <form-entry label="File path" error="path">
<input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
<b>Example:</b> docs/README.md<br> <b>Example:</b> path/to/README.md<br>
If the file exists, it will be replaced. If the file exists, it will be overwritten.
</div> </div>
</form-entry> </form-entry>
<form-entry label="Template"> <form-entry label="Template">

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="github"></icon-provider> <icon-provider provider-id="github"></icon-provider>
</div> </div>
<p>This will save <b>{{currentFileName}}</b> to your <b>GitHub</b> repository and keep it synchronized.</p> <p>Save <b>{{currentFileName}}</b> to your <b>GitHub</b> repository and keep it synchronized.</p>
<form-entry label="Repository URL" error="repoUrl"> <form-entry label="Repository URL" error="repoUrl">
<input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
@ -14,14 +14,14 @@
<form-entry label="Branch" info="optional"> <form-entry label="Branch" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="branch" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="branch" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
If not provided, the <code>master</code> branch will be used. If not supplied, the <code>master</code> branch will be used.
</div> </div>
</form-entry> </form-entry>
<form-entry label="File path" error="path"> <form-entry label="File path" error="path">
<input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">
<b>Example:</b> docs/README.md<br> <b>Example:</b> path/to/README.md<br>
If the file exists, it will be replaced. If the file exists, it will be overwritten.
</div> </div>
</form-entry> </form-entry>
</div> </div>
@ -49,22 +49,17 @@ export default modalTemplate({
}, },
methods: { methods: {
resolve() { resolve() {
if (!this.repoUrl) { const parsedRepo = githubProvider.parseRepoUrl(this.repoUrl);
if (!parsedRepo) {
this.setError('repoUrl'); this.setError('repoUrl');
} }
if (!this.path) { if (!this.path) {
this.setError('path'); this.setError('path');
} }
if (this.repoUrl && this.path) { if (parsedRepo && this.path) {
const parsedRepo = githubProvider.parseRepoUrl(this.repoUrl); const location = githubProvider.makeLocation(
if (!parsedRepo) { this.config.token, parsedRepo.owner, parsedRepo.repo, this.branch || 'master', this.path);
this.setError('repoUrl'); this.config.resolve(location);
} else {
// Return new location
const location = githubProvider.makeLocation(
this.config.token, parsedRepo.owner, parsedRepo.repo, this.branch || 'master', this.path);
this.config.resolve(location);
}
} }
}, },
}, },

View File

@ -0,0 +1,66 @@
<template>
<modal-inner aria-label="Synchronize with GitHub">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="github"></icon-provider>
</div>
<p>Create a workspace synchronized with a <b>GitHub</b> repository folder.</p>
<form-entry label="Repository URL" error="repoUrl">
<input slot="field" class="textfield" type="text" v-model.trim="repoUrl" @keydown.enter="resolve()">
<div class="form-entry__info">
<b>Example:</b> https://github.com/benweet/stackedit
</div>
</form-entry>
<form-entry label="Branch" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="branch" @keydown.enter="resolve()">
<div class="form-entry__info">
If not supplied, the <code>master</code> branch will be used.
</div>
</form-entry>
<form-entry label="Folder path" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()">
<div class="form-entry__info">
If not supplied, the root folder will be used.
</div>
</form-entry>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
<script>
import githubProvider from '../../../services/providers/githubProvider';
import utils from '../../../services/utils';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
data: () => ({
branch: '',
path: '',
}),
computedLocalSettings: {
repoUrl: 'githubWorkspaceRepoUrl',
},
methods: {
resolve() {
const parsedRepo = githubProvider.parseRepoUrl(this.repoUrl);
if (!parsedRepo) {
this.setError('repoUrl');
} else {
const path = this.path && this.path.replace(/^\//, '');
const url = utils.addQueryParams('app', {
providerId: 'githubWorkspace',
repo: `${parsedRepo.owner}/${parsedRepo.repo}`,
branch: this.branch || 'master',
path: path || undefined,
}, true);
this.config.resolve();
window.open(url);
}
},
},
});
</script>

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider> <icon-provider provider-id="googleDrive"></icon-provider>
</div> </div>
<p>This will link your <b>Google Drive</b> account to <b>StackEdit</b>.</p> <p>Link your <b>Google Drive</b> account to <b>StackEdit</b>.</p>
<div class="form-entry"> <div class="form-entry">
<div class="form-entry__checkbox"> <div class="form-entry__checkbox">
<label> <label>

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider> <icon-provider provider-id="googleDrive"></icon-provider>
</div> </div>
<p>This will publish <b>{{currentFileName}}</b> to your <b>Google Drive</b> account.</p> <p>Publish <b>{{currentFileName}}</b> to your <b>Google Drive</b> account.</p>
<form-entry label="Folder ID" info="optional"> <form-entry label="Folder ID" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="folderId" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="folderId" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider> <icon-provider provider-id="googleDrive"></icon-provider>
</div> </div>
<p>This will save <b>{{currentFileName}}</b> to your <b>Google Drive</b> account and keep it synchronized.</p> <p>Save <b>{{currentFileName}}</b> to your <b>Google Drive</b> account and keep it synchronized.</p>
<form-entry label="Folder ID" info="optional"> <form-entry label="Folder ID" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="folderId" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="folderId" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="googleDrive"></icon-provider> <icon-provider provider-id="googleDrive"></icon-provider>
</div> </div>
<p>This will create a workspace synchronized with a <b>Google Drive</b> folder.</p> <p>Create a workspace synchronized with a <b>Google Drive</b> folder.</p>
<form-entry label="Folder ID" info="optional"> <form-entry label="Folder ID" info="optional">
<input slot="field" class="textfield" type="text" v-model.trim="folderId" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="folderId" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="wordpress"></icon-provider> <icon-provider provider-id="wordpress"></icon-provider>
</div> </div>
<p>This will publish <b>{{currentFileName}}</b> to your <b>WordPress</b> site.</p> <p>Publish <b>{{currentFileName}}</b> to your <b>WordPress</b> site.</p>
<form-entry label="Site domain" error="domain"> <form-entry label="Site domain" error="domain">
<input slot="field" class="textfield" type="text" v-model.trim="domain" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="domain" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="zendesk"></icon-provider> <icon-provider provider-id="zendesk"></icon-provider>
</div> </div>
<p>This will link your <b>Zendesk</b> account to <b>StackEdit</b>.</p> <p>Link your <b>Zendesk</b> account to <b>StackEdit</b>.</p>
<form-entry label="Site URL" error="siteUrl"> <form-entry label="Site URL" error="siteUrl">
<input slot="field" class="textfield" type="text" v-model.trim="siteUrl" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="siteUrl" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">

View File

@ -4,7 +4,7 @@
<div class="modal__image"> <div class="modal__image">
<icon-provider provider-id="zendesk"></icon-provider> <icon-provider provider-id="zendesk"></icon-provider>
</div> </div>
<p>This will publish <b>{{currentFileName}}</b> to your <b>Zendesk Help Center</b>.</p> <p>Publish <b>{{currentFileName}}</b> to your <b>Zendesk Help Center</b>.</p>
<form-entry label="Section ID" error="sectionId"> <form-entry label="Section ID" error="sectionId">
<input slot="field" class="textfield" type="text" v-model.trim="sectionId" @keydown.enter="resolve()"> <input slot="field" class="textfield" type="text" v-model.trim="sectionId" @keydown.enter="resolve()">
<div class="form-entry__info"> <div class="form-entry__info">

View File

@ -15,6 +15,7 @@ export default () => ({
dropboxPublishTemplate: 'styledHtml', dropboxPublishTemplate: 'styledHtml',
githubRepoFullAccess: false, githubRepoFullAccess: false,
githubRepoUrl: '', githubRepoUrl: '',
githubWorkspaceRepoUrl: '',
githubPublishTemplate: 'jekyllSite', githubPublishTemplate: 'jekyllSite',
gistIsPublic: false, gistIsPublic: false,
gistPublishTemplate: 'plainText', gistPublishTemplate: 'plainText',

View File

@ -1,11 +1,11 @@
# light or dark # light or dark
colorTheme: light colorTheme: light
# Auto-sync frequency (in ms). Minimum is 60000.
autoSyncEvery: 60000
# Adjust font size in editor and preview # Adjust font size in editor and preview
fontSizeFactor: 1 fontSizeFactor: 1
# Adjust maximum text width in editor and preview # Adjust maximum text width in editor and preview
maxWidthFactor: 1 maxWidthFactor: 1
# Auto-sync frequency (in ms). Minimum is 60000.
autoSyncEvery: 60000
# Editor settings # Editor settings
editor: editor:
@ -54,7 +54,7 @@ wkhtmltopdf:
marginRight: 25 marginRight: 25
marginBottom: 25 marginBottom: 25
marginLeft: 25 marginLeft: 25
# `A3`, `A4`, `Legal` or `Letter` # A3, A4, Legal or Letter
pageSize: A4 pageSize: A4
# Options passed to pandoc # Options passed to pandoc
@ -77,6 +77,11 @@ turndown:
linkStyle: inlined linkStyle: inlined
linkReferenceStyle: full linkReferenceStyle: full
github:
createFileMessage: Create {{path}} from https://stackedit.io/
updateFileMessage: Update {{path}} from https://stackedit.io/
deleteFileMessage: Delete {{path}} from https://stackedit.io/
# Default content for new files # Default content for new files
newFileContent: | newFileContent: |

View File

@ -3,5 +3,6 @@ export default (id = null) => ({
type: 'syncedContent', type: 'syncedContent',
historyData: {}, historyData: {},
syncHistory: {}, syncHistory: {},
v: 0,
hash: 0, hash: 0,
}); });

View File

@ -1,6 +1,6 @@
# Welcome to StackEdit! # Welcome to StackEdit!
Hi! I'm your first Markdown file in **StackEdit**. If you want to learn about StackEdit, you can read me. If you want to play with Markdown, you can edit me. If you have finished with me, you can just create new files by opening the **file explorer** on the left corner of the navigation bar. Hi! I'm your first Markdown file in **StackEdit**. If you want to learn about StackEdit, you can read me. If you want to play with Markdown, you can edit me. Once you have finished with me, you can create new files by opening the **file explorer** on the left corner of the navigation bar.
# Files # Files

View File

@ -16,8 +16,8 @@ export default {
return 'google-drive'; return 'google-drive';
case 'googlePhotos': case 'googlePhotos':
return 'google-photos'; return 'google-photos';
case 'dropboxRestricted': case 'githubWorkspace':
return 'dropbox'; return 'github';
case 'gist': case 'gist':
return 'github'; return 'github';
case 'bloggerPage': case 'bloggerPage':

View File

@ -1,8 +1,8 @@
import store from '../../store'; import store from '../../store';
import googleHelper from './helpers/googleHelper'; import googleHelper from './helpers/googleHelper';
import providerRegistry from './providerRegistry'; import Provider from './common/Provider';
export default providerRegistry.register({ export default new Provider({
id: 'bloggerPage', id: 'bloggerPage',
getToken(location) { getToken(location) {
const token = store.getters['data/googleTokens'][location.sub]; const token = store.getters['data/googleTokens'][location.sub];

View File

@ -1,8 +1,8 @@
import store from '../../store'; import store from '../../store';
import googleHelper from './helpers/googleHelper'; import googleHelper from './helpers/googleHelper';
import providerRegistry from './providerRegistry'; import Provider from './common/Provider';
export default providerRegistry.register({ export default new Provider({
id: 'blogger', id: 'blogger',
getToken(location) { getToken(location) {
const token = store.getters['data/googleTokens'][location.sub]; const token = store.getters['data/googleTokens'][location.sub];

View File

@ -1,11 +1,20 @@
import emptyContent from '../../data/emptyContent'; import providerRegistry from './providerRegistry';
import store from '../../store'; import emptyContent from '../../../data/emptyContent';
import utils from '../utils'; import utils from '../../utils';
import store from '../../../store';
const dataExtractor = /<!--stackedit_data:([A-Za-z0-9+/=\s]+)-->$/; const dataExtractor = /<!--stackedit_data:([A-Za-z0-9+/=\s]+)-->$/;
export default { export default class Provider {
serializeContent(content) { constructor(props) {
Object.assign(this, props);
providerRegistry.register(this);
}
/**
* Serialize content in a self contain Markdown compatible format
*/
static serializeContent(content) {
let result = content.text; let result = content.text;
const data = {}; const data = {};
if (content.properties.length > 1) { if (content.properties.length > 1) {
@ -25,8 +34,12 @@ export default {
result += `<!--stackedit_data:\n${serializedData}\n-->`; result += `<!--stackedit_data:\n${serializedData}\n-->`;
} }
return result; return result;
}, }
parseContent(serializedContent, id) {
/**
* Parse content serialized with serializeContent()
*/
static parseContent(serializedContent, id) {
const result = utils.deepCopy(store.state.content.itemMap[id]) || emptyContent(id); const result = utils.deepCopy(store.state.content.itemMap[id]) || emptyContent(id);
result.text = utils.sanitizeText(serializedContent); result.text = utils.sanitizeText(serializedContent);
result.history = []; result.history = [];
@ -51,29 +64,26 @@ export default {
} }
} }
return utils.addItemHash(result); return utils.addItemHash(result);
}, }
/** /**
* Find and open a file location that fits the criteria * Find and open a file with location that meets the criteria
*/ */
openFileWithLocation(allLocations, criteria) { static openFileWithLocation(allLocations, criteria) {
return allLocations.some((location) => { const location = utils.search(allLocations, criteria);
// If every field fits the criteria if (location) {
if (Object.entries(criteria).every(([key, value]) => value === location[key])) { // Found one, open it if it exists
// Found one location that fits, open it if it exists const file = store.state.file.itemMap[location.fileId];
const file = store.state.file.itemMap[location.fileId]; if (file) {
if (file) { store.commit('file/setCurrentId', file.id);
store.commit('file/setCurrentId', file.id); // If file is in the trash, restore it
// If file is in the trash, restore it if (file.parentId === 'trash') {
if (file.parentId === 'trash') { store.commit('file/patchItem', {
store.commit('file/patchItem', { ...file,
...file, parentId: null,
parentId: null, });
});
}
return true;
} }
} }
return false; }
}); }
}, }
};

View File

@ -1,7 +1,6 @@
import store from '../../store'; import store from '../../store';
import couchdbHelper from './helpers/couchdbHelper'; import couchdbHelper from './helpers/couchdbHelper';
import providerRegistry from './providerRegistry'; import Provider from './common/Provider';
import providerUtils from './providerUtils';
import utils from '../utils'; import utils from '../utils';
const getSyncData = (fileId) => { const getSyncData = (fileId) => {
@ -11,18 +10,20 @@ const getSyncData = (fileId) => {
: Promise.reject(); // No need for a proper error message. : Promise.reject(); // No need for a proper error message.
}; };
export default providerRegistry.register({ let syncLastSeq;
export default new Provider({
id: 'couchdbWorkspace', id: 'couchdbWorkspace',
getToken() { getToken() {
return store.getters['workspace/syncToken']; return store.getters['workspace/syncToken'];
}, },
initWorkspace() { initWorkspace() {
const dbUrl = (utils.queryParams.dbUrl || '').replace(/\/?$/, ''); // Remove trailing / const dbUrl = (utils.queryParams.dbUrl || '').replace(/\/?$/, ''); // Remove trailing /
const workspaceIdParams = { const workspaceParams = {
providerId: this.id, providerId: this.id,
dbUrl, dbUrl,
}; };
const workspaceId = utils.makeWorkspaceId(workspaceIdParams); const workspaceId = utils.makeWorkspaceId(workspaceParams);
const getToken = () => store.getters['data/couchdbTokens'][workspaceId]; const getToken = () => store.getters['data/couchdbTokens'][workspaceId];
const getWorkspace = () => store.getters['data/sanitizedWorkspaces'][workspaceId]; const getWorkspace = () => store.getters['data/sanitizedWorkspaces'][workspaceId];
@ -51,7 +52,7 @@ export default providerRegistry.register({
})) }))
.then((workspace) => { .then((workspace) => {
// Fix the URL hash // Fix the URL hash
utils.setQueryParams(workspaceIdParams); utils.setQueryParams(workspaceParams);
if (workspace.url !== location.href) { if (workspace.url !== location.href) {
store.dispatch('data/patchWorkspaces', { store.dispatch('data/patchWorkspaces', {
[workspace.id]: { [workspace.id]: {
@ -86,13 +87,13 @@ export default providerRegistry.register({
change.syncDataId = change.id; change.syncDataId = change.id;
return true; return true;
}); });
changes.lastSeq = result.lastSeq; syncLastSeq = result.lastSeq;
return changes; return changes;
}); });
}, },
setAppliedChanges(changes) { onChangesApplied() {
store.dispatch('data/patchLocalSettings', { store.dispatch('data/patchLocalSettings', {
syncLastSeq: changes.lastSeq, syncLastSeq,
}); });
}, },
saveSimpleItem(item, syncData) { saveSimpleItem(item, syncData) {
@ -131,9 +132,9 @@ export default providerRegistry.register({
.then((body) => { .then((body) => {
let item; let item;
if (body.item.type === 'content') { if (body.item.type === 'content') {
item = providerUtils.parseContent(body.attachments.data, body.item.id); item = Provider.parseContent(body.attachments.data, body.item.id);
} else { } else {
item = JSON.parse(body.attachments.data); item = utils.addItemHash(JSON.parse(body.attachments.data));
} }
const rev = body._rev; // eslint-disable-line no-underscore-dangle const rev = body._rev; // eslint-disable-line no-underscore-dangle
if (item.hash !== syncData.hash || rev !== syncData.rev) { if (item.hash !== syncData.hash || rev !== syncData.rev) {
@ -149,18 +150,18 @@ export default providerRegistry.register({
}); });
}, },
uploadContent(token, content, syncLocation) { uploadContent(token, content, syncLocation) {
return this.uploadData(content, `${syncLocation.fileId}/content`) return this.uploadData(content)
.then(() => syncLocation); .then(() => syncLocation);
}, },
uploadData(item, dataId) { uploadData(item) {
const syncData = store.getters['data/syncDataByItemId'][dataId]; const syncData = store.getters['data/syncDataByItemId'][item.id];
if (syncData && syncData.hash === item.hash) { if (syncData && syncData.hash === item.hash) {
return Promise.resolve(); return Promise.resolve();
} }
let data; let data;
let dataType; let dataType;
if (item.type === 'content') { if (item.type === 'content') {
data = providerUtils.serializeContent(item); data = Provider.serializeContent(item);
dataType = 'text/plain'; dataType = 'text/plain';
} else { } else {
data = JSON.stringify(item); data = JSON.stringify(item);
@ -219,6 +220,6 @@ export default providerRegistry.register({
return getSyncData(fileId) return getSyncData(fileId)
.then(syncData => couchdbHelper .then(syncData => couchdbHelper
.retrieveDocumentWithAttachments(token, syncData.id, revisionId)) .retrieveDocumentWithAttachments(token, syncData.id, revisionId))
.then(body => providerUtils.parseContent(body.attachments.data, body.item.id)); .then(body => Provider.parseContent(body.attachments.data, body.item.id));
}, },
}); });

View File

@ -1,7 +1,6 @@
import store from '../../store'; import store from '../../store';
import dropboxHelper from './helpers/dropboxHelper'; import dropboxHelper from './helpers/dropboxHelper';
import providerUtils from './providerUtils'; import Provider from './common/Provider';
import providerRegistry from './providerRegistry';
import utils from '../utils'; import utils from '../utils';
const makePathAbsolute = (token, path) => { const makePathAbsolute = (token, path) => {
@ -17,7 +16,7 @@ const makePathRelative = (token, path) => {
return path; return path;
}; };
export default providerRegistry.register({ export default new Provider({
id: 'dropbox', id: 'dropbox',
getToken(location) { getToken(location) {
return store.getters['data/dropboxTokens'][location.sub]; return store.getters['data/dropboxTokens'][location.sub];
@ -40,13 +39,13 @@ export default providerRegistry.register({
makePathRelative(token, syncLocation.path), makePathRelative(token, syncLocation.path),
syncLocation.dropboxFileId, syncLocation.dropboxFileId,
) )
.then(({ content }) => providerUtils.parseContent(content, `${syncLocation.fileId}/content`)); .then(({ content }) => Provider.parseContent(content, `${syncLocation.fileId}/content`));
}, },
uploadContent(token, content, syncLocation) { uploadContent(token, content, syncLocation) {
return dropboxHelper.uploadFile( return dropboxHelper.uploadFile(
token, token,
makePathRelative(token, syncLocation.path), makePathRelative(token, syncLocation.path),
providerUtils.serializeContent(content), Provider.serializeContent(content),
syncLocation.dropboxFileId, syncLocation.dropboxFileId,
) )
.then(dropboxFile => ({ .then(dropboxFile => ({
@ -74,7 +73,7 @@ export default providerRegistry.register({
if (!path) { if (!path) {
return null; return null;
} }
if (providerUtils.openFileWithLocation(store.getters['syncLocation/items'], { if (Provider.openFileWithLocation(store.getters['syncLocation/items'], {
providerId: this.id, providerId: this.id,
path, path,
})) { })) {

View File

@ -1,10 +1,9 @@
import store from '../../store'; import store from '../../store';
import githubHelper from './helpers/githubHelper'; import githubHelper from './helpers/githubHelper';
import providerUtils from './providerUtils'; import Provider from './common/Provider';
import providerRegistry from './providerRegistry';
import utils from '../utils'; import utils from '../utils';
export default providerRegistry.register({ export default new Provider({
id: 'gist', id: 'gist',
getToken(location) { getToken(location) {
return store.getters['data/githubTokens'][location.sub]; return store.getters['data/githubTokens'][location.sub];
@ -18,7 +17,7 @@ export default providerRegistry.register({
}, },
downloadContent(token, syncLocation) { downloadContent(token, syncLocation) {
return githubHelper.downloadGist(token, syncLocation.gistId, syncLocation.filename) return githubHelper.downloadGist(token, syncLocation.gistId, syncLocation.filename)
.then(content => providerUtils.parseContent(content, `${syncLocation.fileId}/content`)); .then(content => Provider.parseContent(content, `${syncLocation.fileId}/content`));
}, },
uploadContent(token, content, syncLocation) { uploadContent(token, content, syncLocation) {
const file = store.state.file.itemMap[syncLocation.fileId]; const file = store.state.file.itemMap[syncLocation.fileId];
@ -27,7 +26,7 @@ export default providerRegistry.register({
token, token,
description, description,
syncLocation.filename, syncLocation.filename,
providerUtils.serializeContent(content), Provider.serializeContent(content),
syncLocation.isPublic, syncLocation.isPublic,
syncLocation.gistId, syncLocation.gistId,
) )

View File

@ -1,12 +1,11 @@
import store from '../../store'; import store from '../../store';
import githubHelper from './helpers/githubHelper'; import githubHelper from './helpers/githubHelper';
import providerUtils from './providerUtils'; import Provider from './common/Provider';
import providerRegistry from './providerRegistry';
import utils from '../utils'; import utils from '../utils';
const savedSha = {}; const savedSha = {};
export default providerRegistry.register({ export default new Provider({
id: 'github', id: 'github',
getToken(location) { getToken(location) {
return store.getters['data/githubTokens'][location.sub]; return store.getters['data/githubTokens'][location.sub];
@ -24,9 +23,9 @@ export default providerRegistry.register({
) )
.then(({ sha, content }) => { .then(({ sha, content }) => {
savedSha[syncLocation.id] = sha; savedSha[syncLocation.id] = sha;
return providerUtils.parseContent(content, `${syncLocation.fileId}/content`); return Provider.parseContent(content, `${syncLocation.fileId}/content`);
}) })
.catch(() => null); // Ignore error, without the sha upload is going to fail anyway .catch(() => null); // Ignore error, upload is going to fail anyway
}, },
uploadContent(token, content, syncLocation) { uploadContent(token, content, syncLocation) {
let result = Promise.resolve(); let result = Promise.resolve();
@ -43,7 +42,7 @@ export default providerRegistry.register({
syncLocation.repo, syncLocation.repo,
syncLocation.branch, syncLocation.branch,
syncLocation.path, syncLocation.path,
providerUtils.serializeContent(content), Provider.serializeContent(content),
sha, sha,
); );
}) })
@ -69,7 +68,7 @@ export default providerRegistry.register({
openFile(token, syncLocation) { openFile(token, syncLocation) {
return Promise.resolve() return Promise.resolve()
.then(() => { .then(() => {
if (providerUtils.openFileWithLocation(store.getters['syncLocation/items'], syncLocation)) { if (Provider.openFileWithLocation(store.getters['syncLocation/items'], syncLocation)) {
// File exists and has just been opened. Next... // File exists and has just been opened. Next...
return null; return null;
} }
@ -109,7 +108,7 @@ export default providerRegistry.register({
}); });
}, },
parseRepoUrl(url) { parseRepoUrl(url) {
const parsedRepo = url.match(/[/:]?([^/:]+)\/([^/]+?)(?:\.git|\/)?$/); const parsedRepo = url && url.match(/([^/:]+)\/([^/]+?)(?:\.git|\/)?$/);
return parsedRepo && { return parsedRepo && {
owner: parsedRepo[1], owner: parsedRepo[1],
repo: parsedRepo[2], repo: parsedRepo[2],

View File

@ -0,0 +1,500 @@
import store from '../../store';
import githubHelper from './helpers/githubHelper';
import Provider from './common/Provider';
import utils from '../utils';
import userSvc from '../userSvc';
const getSyncData = (fileId) => {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
return syncData
? Promise.resolve(syncData)
: Promise.reject(); // No need for a proper error message.
};
const getAbsolutePath = syncData =>
(store.getters['workspace/currentWorkspace'].path || '') + syncData.id;
const getWorkspaceWithOwner = () => {
const workspace = store.getters['workspace/currentWorkspace'];
const [owner, repo] = workspace.repo.split('/');
return {
...workspace,
owner,
repo,
};
};
let treeShaMap;
let treeFolderMap;
let treeFileMap;
let treeDataMap;
let treeSyncLocationMap;
let treePublishLocationMap;
const endsWith = (str, suffix) => str.slice(-suffix.length) === suffix;
export default new Provider({
id: 'githubWorkspace',
getToken() {
return store.getters['workspace/syncToken'];
},
initWorkspace() {
const [owner, repo] = (utils.queryParams.repo || '').split('/');
const branch = utils.queryParams.branch;
const workspaceParams = {
providerId: this.id,
repo: `${owner}/${repo}`,
branch,
};
const path = (utils.queryParams.path || '')
.replace(/^\/*/, '') // Remove leading `/`
.replace(/\/*$/, '/'); // Add trailing `/`
if (path !== '/') {
workspaceParams.path = path;
}
const workspaceId = utils.makeWorkspaceId(workspaceParams);
let workspace = store.getters['data/sanitizedWorkspaces'][workspaceId];
return Promise.resolve()
.then(() => {
// See if we already have a token
if (workspace) {
// Token sub is in the workspace
const token = store.getters['data/githubTokens'][workspace.sub];
if (token) {
return token;
}
}
// If no token has been found, popup an authorize window and get one
return store.dispatch('modal/open', {
type: 'githubAccount',
onResolve: () => githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess),
});
})
.then((token) => {
if (!workspace) {
const pathEntries = (path || '').split('/');
const name = pathEntries[pathEntries.length - 2] || repo; // path ends with `/`
workspace = {
...workspaceParams,
id: workspaceId,
sub: token.sub,
name,
};
}
// Fix the URL hash
utils.setQueryParams(workspaceParams);
if (workspace.url !== location.href) {
store.dispatch('data/patchWorkspaces', {
[workspaceId]: {
...workspace,
url: location.href,
},
});
}
return store.getters['data/sanitizedWorkspaces'][workspaceId];
});
},
getChanges() {
const syncToken = store.getters['workspace/syncToken'];
const { owner, repo, branch } = getWorkspaceWithOwner();
return githubHelper.getHeadTree(syncToken, owner, repo, branch)
.then((tree) => {
const workspacePath = store.getters['workspace/currentWorkspace'].path || '';
const syncDataByPath = store.getters['data/syncData'];
const syncDataByItemId = store.getters['data/syncDataByItemId'];
// Store all blobs sha
treeShaMap = Object.create(null);
// Store interesting paths
treeFolderMap = Object.create(null);
treeFileMap = Object.create(null);
treeDataMap = Object.create(null);
treeSyncLocationMap = Object.create(null);
treePublishLocationMap = Object.create(null);
tree.filter(({ type, path }) => type === 'blob' && path.indexOf(workspacePath) === 0)
.forEach((blobEntry) => {
// Make path relative
const path = blobEntry.path.slice(workspacePath.length);
// Collect blob sha
treeShaMap[path] = blobEntry.sha;
// Collect parents path
let parentPath = '';
path.split('/').slice(0, -1).forEach((folderName) => {
const folderPath = `${parentPath}${folderName}/`;
treeFolderMap[folderPath] = parentPath;
parentPath = folderPath;
});
// Collect file path
if (path.indexOf('.stackedit-data/') === 0) {
treeDataMap[path] = true;
} else 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 getId = (path) => {
const syncData = syncDataByPath[path];
const id = syncData ? syncData.itemId : utils.uid();
pathIds[path] = id;
return id;
};
// Folder creations/updates
// Assume map entries are sorted from top to bottom
Object.entries(treeFolderMap).forEach(([path, parentPath]) => {
const id = getId(path);
const item = utils.addItemHash({
id,
type: 'folder',
name: path.slice(parentPath.length, -1),
parentId: pathIds[parentPath] || null,
});
changes.push({
syncDataId: path,
item,
syncData: {
id: path,
itemId: id,
type: item.type,
hash: item.hash,
},
});
});
// File creations/updates
Object.entries(treeFileMap).forEach(([path, parentPath]) => {
const id = getId(path);
const item = utils.addItemHash({
id,
type: 'file',
name: path.slice(parentPath.length, -'.md'.length),
parentId: pathIds[parentPath] || null,
});
changes.push({
syncDataId: path,
item,
syncData: {
id: path,
itemId: id,
type: item.type,
hash: item.hash,
},
});
// Content creations/updates
const contentSyncData = syncDataByItemId[`${id}/content`];
if (contentSyncData) {
syncDataToIgnore[contentSyncData.id] = true;
}
if (!contentSyncData || contentSyncData.sha !== treeShaMap[path]) {
// Use `/` as a prefix to get a unique syncData id
changes.push({
syncDataId: `/${path}`,
item: {
id: `${id}/content`,
type: 'content',
// Need a truthy value to force saving sync data
hash: 1,
},
syncData: {
id: `/${path}`,
itemId: `${id}/content`,
type: 'content',
// Need a truthy value to force downloading the content
hash: 1,
},
});
}
});
// Data creations/updates
Object.keys(treeDataMap).forEach((path) => {
try {
const [, id] = path.match(/^\.stackedit-data\/([\s\S]+)\.json$/);
pathIds[path] = id;
const syncData = syncDataByItemId[id];
if (syncData) {
syncDataToIgnore[syncData.id] = true;
}
if (!syncData || syncData.sha !== treeShaMap[path]) {
changes.push({
syncDataId: path,
item: {
id,
type: 'data',
// Need a truthy value to force saving sync data
hash: 1,
},
syncData: {
id: path,
itemId: id,
type: 'data',
// Need a truthy value to force downloading the content
hash: 1,
},
});
}
} catch (e) {
// Ignore parsing errors
}
});
// Location creations/updates
[{
type: 'syncLocation',
map: treeSyncLocationMap,
pathMatcher: /^([\s\S]+)\.([\w-]+)\.sync$/,
}, {
type: 'publishLocation',
map: treePublishLocationMap,
pathMatcher: /^([\s\S]+)\.([\w-]+)\.publish$/,
}]
.forEach(({ type, map, pathMatcher }) => Object.keys(map).forEach((path) => {
try {
const [, filePath, data] = path.match(pathMatcher);
// If there is a corresponding md file in the tree
const fileId = pathIds[`${filePath}.md`];
if (fileId) {
const id = getId(path);
const item = utils.addItemHash({
...JSON.parse(utils.decodeBase64(data)),
id,
type,
fileId,
});
changes.push({
syncDataId: path,
item,
syncData: {
id: path,
itemId: id,
type: item.type,
hash: item.hash,
},
});
}
} catch (e) {
// Ignore parsing errors
}
}));
// Deletions
Object.keys(syncDataByPath).forEach((path) => {
if (!pathIds[path] && !syncDataToIgnore[path]) {
changes.push({ syncDataId: path });
}
});
return changes;
});
},
saveSimpleItem(item) {
const path = store.getters.itemPaths[item.fileId || item.id];
return Promise.resolve()
.then(() => {
const syncToken = store.getters['workspace/syncToken'];
const { owner, repo, branch } = getWorkspaceWithOwner();
const syncData = {
itemId: item.id,
type: item.type,
hash: item.hash,
};
if (item.type === 'file') {
syncData.id = `${path}.md`;
} else if (item.type === 'folder') {
syncData.id = path;
}
if (syncData.id) {
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}`;
return githubHelper.uploadFile(
syncToken,
owner,
repo,
branch,
getAbsolutePath(syncData),
'',
treeShaMap[syncData.id],
).then(() => syncData);
});
},
removeItem(syncData) {
// Ignore content deletion
if (syncData.type === 'content') {
return Promise.resolve();
}
const syncToken = store.getters['workspace/syncToken'];
const { owner, repo, branch } = getWorkspaceWithOwner();
return githubHelper.removeFile(
syncToken,
owner,
repo,
branch,
getAbsolutePath(syncData),
treeShaMap[syncData.id],
);
},
downloadContent(token, syncLocation) {
const syncData = store.getters['data/syncDataByItemId'][syncLocation.fileId];
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
if (!syncData || !contentSyncData) {
return Promise.resolve();
}
const { owner, repo, branch } = getWorkspaceWithOwner();
return githubHelper.downloadFile(token, owner, repo, branch, getAbsolutePath(syncData))
.then(({ sha, content }) => {
const item = Provider.parseContent(content, `${syncLocation.fileId}/content`);
if (item.hash !== contentSyncData.hash) {
store.dispatch('data/patchSyncData', {
[contentSyncData.id]: {
...contentSyncData,
hash: item.hash,
sha,
},
});
}
return item;
});
},
downloadData(dataId) {
const syncData = store.getters['data/syncDataByItemId'][dataId];
if (!syncData) {
return Promise.resolve();
}
const syncToken = store.getters['workspace/syncToken'];
const { owner, repo, branch } = getWorkspaceWithOwner();
return githubHelper.downloadFile(syncToken, owner, repo, branch, getAbsolutePath(syncData))
.then(({ sha, content }) => {
const item = JSON.parse(content);
if (item.hash !== syncData.hash) {
store.dispatch('data/patchSyncData', {
[syncData.id]: {
...syncData,
hash: item.hash,
sha,
},
});
}
return item;
});
},
uploadContent(token, content, syncLocation) {
const contentSyncData = store.getters['data/syncDataByItemId'][`${syncLocation.fileId}/content`];
if (contentSyncData && contentSyncData.hash === content.hash) {
return Promise.resolve(syncLocation);
}
const syncData = store.getters['data/syncDataByItemId'][syncLocation.fileId];
const { owner, repo, branch } = getWorkspaceWithOwner();
return githubHelper.uploadFile(
token,
owner,
repo,
branch,
getAbsolutePath(syncData),
Provider.serializeContent(content),
treeShaMap[syncData.id],
)
.then((res) => {
const id = `/${syncData.id}`;
store.dispatch('data/patchSyncData', {
[id]: {
// Build sync data
id,
itemId: content.id,
type: content.type,
hash: content.hash,
sha: res.content.sha,
},
});
return syncLocation;
});
},
uploadData(item) {
const oldSyncData = store.getters['data/syncDataByItemId'][item.id];
if (oldSyncData && oldSyncData.hash === item.hash) {
return Promise.resolve();
}
const syncData = {
id: `.stackedit-data/${item.id}.json`,
itemId: item.id,
type: item.type,
hash: item.hash,
};
const syncToken = store.getters['workspace/syncToken'];
const { owner, repo, branch } = getWorkspaceWithOwner();
return githubHelper.uploadFile(
syncToken,
owner,
repo,
branch,
getAbsolutePath(syncData),
JSON.stringify(item),
oldSyncData && oldSyncData.sha,
)
.then(res => store.dispatch('data/patchSyncData', {
[syncData.id]: {
...syncData,
sha: res.content.sha,
},
}));
},
onSyncEnd() {
// Clean up
treeShaMap = null;
treeFolderMap = null;
treeFileMap = null;
treeDataMap = null;
treeSyncLocationMap = null;
treePublishLocationMap = null;
},
listRevisions(token, fileId) {
const { owner, repo, branch } = getWorkspaceWithOwner();
return getSyncData(fileId)
.then(syncData => githubHelper.getCommits(token, owner, repo, branch, syncData.id))
.then(entries => entries.map((entry) => {
let user;
if (entry.author && entry.author.login) {
user = entry.author;
} else if (entry.committer && entry.committer.login) {
user = entry.committer;
}
userSvc.addInfo({ id: user.login, name: user.login, imageUrl: user.avatar_url });
const date = (entry.commit.author && entry.commit.author.date)
|| (entry.commit.committer && entry.commit.committer.date);
return {
id: entry.sha,
sub: user.login,
created: date ? new Date(date).getTime() : 1,
};
})
.sort((revision1, revision2) => revision2.created - revision1.created));
},
getRevisionContent(token, fileId, revisionId) {
const { owner, repo } = getWorkspaceWithOwner();
return getSyncData(fileId)
.then(syncData => githubHelper.downloadFile(
token, owner, repo, revisionId, getAbsolutePath(syncData)))
.then(({ content }) => Provider.parseContent(content, `${fileId}/content`));
},
});

View File

@ -1,9 +1,11 @@
import store from '../../store'; import store from '../../store';
import googleHelper from './helpers/googleHelper'; import googleHelper from './helpers/googleHelper';
import providerRegistry from './providerRegistry'; import Provider from './common/Provider';
import utils from '../utils'; import utils from '../utils';
export default providerRegistry.register({ let syncStartPageToken;
export default new Provider({
id: 'googleDriveAppData', id: 'googleDriveAppData',
getToken() { getToken() {
return store.getters['workspace/syncToken']; return store.getters['workspace/syncToken'];
@ -42,13 +44,13 @@ export default providerRegistry.register({
change.syncDataId = change.fileId; change.syncDataId = change.fileId;
return true; return true;
}); });
changes.startPageToken = result.startPageToken; syncStartPageToken = result.startPageToken;
return changes; return changes;
}); });
}, },
setAppliedChanges(changes) { onChangesApplied() {
store.dispatch('data/patchLocalSettings', { store.dispatch('data/patchLocalSettings', {
syncStartPageToken: changes.startPageToken, syncStartPageToken,
}); });
}, },
saveSimpleItem(item, syncData, ifNotTooLate) { saveSimpleItem(item, syncData, ifNotTooLate) {
@ -83,7 +85,7 @@ export default providerRegistry.register({
const syncToken = store.getters['workspace/syncToken']; const syncToken = store.getters['workspace/syncToken'];
return googleHelper.downloadAppDataFile(syncToken, syncData.id) return googleHelper.downloadAppDataFile(syncToken, syncData.id)
.then((data) => { .then((data) => {
const item = JSON.parse(data); const item = utils.addItemHash(JSON.parse(data));
if (item.hash !== syncData.hash) { if (item.hash !== syncData.hash) {
store.dispatch('data/patchSyncData', { store.dispatch('data/patchSyncData', {
[syncData.id]: { [syncData.id]: {
@ -96,11 +98,11 @@ export default providerRegistry.register({
}); });
}, },
uploadContent(token, content, syncLocation, ifNotTooLate) { uploadContent(token, content, syncLocation, ifNotTooLate) {
return this.uploadData(content, `${syncLocation.fileId}/content`, ifNotTooLate) return this.uploadData(content, ifNotTooLate)
.then(() => syncLocation); .then(() => syncLocation);
}, },
uploadData(item, dataId, ifNotTooLate) { uploadData(item, ifNotTooLate) {
const syncData = store.getters['data/syncDataByItemId'][dataId]; const syncData = store.getters['data/syncDataByItemId'][item.id];
if (syncData && syncData.hash === item.hash) { if (syncData && syncData.hash === item.hash) {
return Promise.resolve(); return Promise.resolve();
} }

View File

@ -1,10 +1,9 @@
import store from '../../store'; import store from '../../store';
import googleHelper from './helpers/googleHelper'; import googleHelper from './helpers/googleHelper';
import providerUtils from './providerUtils'; import Provider from './common/Provider';
import providerRegistry from './providerRegistry';
import utils from '../utils'; import utils from '../utils';
export default providerRegistry.register({ export default new Provider({
id: 'googleDrive', id: 'googleDrive',
getToken(location) { getToken(location) {
const token = store.getters['data/googleTokens'][location.sub]; const token = store.getters['data/googleTokens'][location.sub];
@ -110,7 +109,7 @@ export default providerRegistry.register({
}, },
downloadContent(token, syncLocation) { downloadContent(token, syncLocation) {
return googleHelper.downloadFile(token, syncLocation.driveFileId) return googleHelper.downloadFile(token, syncLocation.driveFileId)
.then(content => providerUtils.parseContent(content, `${syncLocation.fileId}/content`)); .then(content => Provider.parseContent(content, `${syncLocation.fileId}/content`));
}, },
uploadContent(token, content, syncLocation, ifNotTooLate) { uploadContent(token, content, syncLocation, ifNotTooLate) {
const file = store.state.file.itemMap[syncLocation.fileId]; const file = store.state.file.itemMap[syncLocation.fileId];
@ -124,7 +123,7 @@ export default providerRegistry.register({
name, name,
parents, parents,
undefined, undefined,
providerUtils.serializeContent(content), Provider.serializeContent(content),
undefined, undefined,
syncLocation.driveFileId, syncLocation.driveFileId,
undefined, undefined,
@ -156,7 +155,7 @@ export default providerRegistry.register({
if (!driveFile) { if (!driveFile) {
return null; return null;
} }
if (providerUtils.openFileWithLocation(store.getters['syncLocation/items'], { if (Provider.openFileWithLocation(store.getters['syncLocation/items'], {
providerId: this.id, providerId: this.id,
driveFileId: driveFile.id, driveFileId: driveFile.id,
})) { })) {

View File

@ -1,11 +1,8 @@
import store from '../../store'; import store from '../../store';
import googleHelper from './helpers/googleHelper'; import googleHelper from './helpers/googleHelper';
import providerRegistry from './providerRegistry'; import Provider from './common/Provider';
import providerUtils from './providerUtils';
import utils from '../utils'; import utils from '../utils';
let fileIdToOpen;
const getSyncData = (fileId) => { const getSyncData = (fileId) => {
const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`]; const syncData = store.getters['data/syncDataByItemId'][`${fileId}/content`];
return syncData return syncData
@ -13,19 +10,22 @@ const getSyncData = (fileId) => {
: Promise.reject(); // No need for a proper error message. : Promise.reject(); // No need for a proper error message.
}; };
export default providerRegistry.register({ let fileIdToOpen;
let syncStartPageToken;
export default new Provider({
id: 'googleDriveWorkspace', id: 'googleDriveWorkspace',
getToken() { getToken() {
return store.getters['workspace/syncToken']; return store.getters['workspace/syncToken'];
}, },
initWorkspace() { initWorkspace() {
const makeWorkspaceIdParams = folderId => ({ const makeWorkspaceParams = folderId => ({
providerId: this.id, providerId: this.id,
folderId, folderId,
}); });
const makeWorkspaceId = folderId => folderId && utils.makeWorkspaceId( const makeWorkspaceId = folderId => folderId && utils.makeWorkspaceId(
makeWorkspaceIdParams(folderId)); makeWorkspaceParams(folderId));
const getWorkspace = folderId => const getWorkspace = folderId =>
store.getters['data/sanitizedWorkspaces'][makeWorkspaceId(folderId)]; store.getters['data/sanitizedWorkspaces'][makeWorkspaceId(folderId)];
@ -155,7 +155,7 @@ export default providerRegistry.register({
})) }))
.then((workspace) => { .then((workspace) => {
// Fix the URL hash // Fix the URL hash
utils.setQueryParams(makeWorkspaceIdParams(workspace.folderId)); utils.setQueryParams(makeWorkspaceParams(workspace.folderId));
if (workspace.url !== location.href) { if (workspace.url !== location.href) {
store.dispatch('data/patchWorkspaces', { store.dispatch('data/patchWorkspaces', {
[workspace.id]: { [workspace.id]: {
@ -339,13 +339,13 @@ export default providerRegistry.register({
changes.push(contentChange); changes.push(contentChange);
} }
}); });
changes.startPageToken = result.startPageToken; syncStartPageToken = result.startPageToken;
return changes; return changes;
}); });
}, },
setAppliedChanges(changes) { onChangesApplied() {
store.dispatch('data/patchLocalSettings', { store.dispatch('data/patchLocalSettings', {
syncStartPageToken: changes.startPageToken, syncStartPageToken,
}); });
}, },
saveSimpleItem(item, syncData, ifNotTooLate) { saveSimpleItem(item, syncData, ifNotTooLate) {
@ -419,7 +419,7 @@ export default providerRegistry.register({
} }
return googleHelper.downloadFile(token, syncData.id) return googleHelper.downloadFile(token, syncData.id)
.then((content) => { .then((content) => {
const item = providerUtils.parseContent(content, `${syncLocation.fileId}/content`); const item = Provider.parseContent(content, `${syncLocation.fileId}/content`);
if (item.hash !== contentSyncData.hash) { if (item.hash !== contentSyncData.hash) {
store.dispatch('data/patchSyncData', { store.dispatch('data/patchSyncData', {
[contentSyncData.id]: { [contentSyncData.id]: {
@ -428,7 +428,7 @@ export default providerRegistry.register({
}, },
}); });
} }
// Open the file requested by action if it was to synced yet // Open the file requested by action if it wasn't synced yet
if (fileIdToOpen && fileIdToOpen === syncData.id) { if (fileIdToOpen && fileIdToOpen === syncData.id) {
fileIdToOpen = null; fileIdToOpen = null;
// Open the file once downloaded content has been stored // Open the file once downloaded content has been stored
@ -474,7 +474,7 @@ export default providerRegistry.register({
undefined, undefined,
undefined, undefined,
undefined, undefined,
providerUtils.serializeContent(content), Provider.serializeContent(content),
undefined, undefined,
syncData.id, syncData.id,
undefined, undefined,
@ -494,7 +494,7 @@ export default providerRegistry.register({
id: item.id, id: item.id,
folderId: workspace.folderId, folderId: workspace.folderId,
}, },
providerUtils.serializeContent(content), Provider.serializeContent(content),
undefined, undefined,
undefined, undefined,
undefined, undefined,
@ -523,8 +523,8 @@ export default providerRegistry.register({
})) }))
.then(() => syncLocation); .then(() => syncLocation);
}, },
uploadData(item, dataId, ifNotTooLate) { uploadData(item, ifNotTooLate) {
const syncData = store.getters['data/syncDataByItemId'][dataId]; const syncData = store.getters['data/syncDataByItemId'][item.id];
if (syncData && syncData.hash === item.hash) { if (syncData && syncData.hash === item.hash) {
return Promise.resolve(); return Promise.resolve();
} }
@ -570,6 +570,6 @@ export default providerRegistry.register({
getRevisionContent(token, fileId, revisionId) { getRevisionContent(token, fileId, revisionId) {
return getSyncData(fileId) return getSyncData(fileId)
.then(syncData => googleHelper.downloadFileRevision(token, syncData.id, revisionId)) .then(syncData => googleHelper.downloadFileRevision(token, syncData.id, revisionId))
.then(content => providerUtils.parseContent(content, `${fileId}/content`)); .then(content => Provider.parseContent(content, `${fileId}/content`));
}, },
}); });

View File

@ -17,6 +17,16 @@ const request = (token, options) => networkSvc.request({
}, },
}); });
const repoRequest = (token, owner, repo, options) => request(token, {
...options,
url: `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${options.url}`,
});
const getCommitMessage = (name, path) => {
const message = store.getters['data/computedSettings'].github[name];
return message.replace(/{{path}}/g, path);
};
export default { export default {
startOauth2(scopes, sub = null, silent = false) { startOauth2(scopes, sub = null, silent = false) {
return networkSvc.startOauth2( return networkSvc.startOauth2(
@ -51,7 +61,7 @@ export default {
const token = { const token = {
scopes, scopes,
accessToken, accessToken,
name: res.body.name, name: res.body.login,
sub: `${res.body.id}`, sub: `${res.body.id}`,
repoFullAccess: scopes.indexOf('repo') !== -1, repoFullAccess: scopes.indexOf('repo') !== -1,
}; };
@ -63,21 +73,58 @@ export default {
addAccount(repoFullAccess = false) { addAccount(repoFullAccess = false) {
return this.startOauth2(getScopes({ repoFullAccess })); return this.startOauth2(getScopes({ repoFullAccess }));
}, },
getTree(token, owner, repo, sha) {
return repoRequest(token, owner, repo, {
url: `git/trees/${encodeURIComponent(sha)}?recursive=1`,
})
.then((res) => {
if (res.body.truncated) {
throw new Error('Git tree too big. Please remove some files in the repository.');
}
return res.body.tree;
});
},
getHeadTree(token, owner, repo, branch) {
return repoRequest(token, owner, repo, {
url: `branches/${encodeURIComponent(branch)}`,
})
.then(res => this.getTree(token, owner, repo, res.body.commit.commit.tree.sha));
},
getCommits(token, owner, repo, sha, path) {
return repoRequest(token, owner, repo, {
url: 'commits',
params: { sha, path },
})
.then(res => res.body);
},
uploadFile(token, owner, repo, branch, path, content, sha) { uploadFile(token, owner, repo, branch, path, content, sha) {
return request(token, { return repoRequest(token, owner, repo, {
method: 'PUT', method: 'PUT',
url: `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodeURIComponent(path)}`, url: `contents/${encodeURIComponent(path)}`,
body: { body: {
message: 'Uploaded by https://stackedit.io/', message: getCommitMessage(sha ? 'updateFileMessage' : 'createFileMessage', path),
content: utils.encodeBase64(content), content: utils.encodeBase64(content),
sha, sha,
branch, branch,
}, },
}); })
.then(res => res.body);
},
removeFile(token, owner, repo, branch, path, sha) {
return repoRequest(token, owner, repo, {
method: 'DELETE',
url: `contents/${encodeURIComponent(path)}`,
body: {
message: getCommitMessage('deleteFileMessage', path),
sha,
branch,
},
})
.then(res => res.body);
}, },
downloadFile(token, owner, repo, branch, path) { downloadFile(token, owner, repo, branch, path) {
return request(token, { return repoRequest(token, owner, repo, {
url: `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodeURIComponent(path)}`, url: `contents/${encodeURIComponent(path)}`,
params: { ref: branch }, params: { ref: branch },
}) })
.then(res => ({ .then(res => ({

View File

@ -614,11 +614,8 @@ export default {
break; break;
} }
case 'img': { case 'img': {
let view = new google.picker.PhotosView(); const view = new google.picker.PhotosView();
view.setType('flat'); view.setType('highlights');
pickerBuilder.addView(view);
view = new google.picker.PhotosView();
view.setType('ofuser');
pickerBuilder.addView(view); pickerBuilder.addView(view);
pickerBuilder.addView(google.picker.ViewId.PHOTO_UPLOAD); pickerBuilder.addView(google.picker.ViewId.PHOTO_UPLOAD);
break; break;

View File

@ -1,8 +1,8 @@
import store from '../../store'; import store from '../../store';
import wordpressHelper from './helpers/wordpressHelper'; import wordpressHelper from './helpers/wordpressHelper';
import providerRegistry from './providerRegistry'; import Provider from './common/Provider';
export default providerRegistry.register({ export default new Provider({
id: 'wordpress', id: 'wordpress',
getToken(location) { getToken(location) {
return store.getters['data/wordpressTokens'][location.sub]; return store.getters['data/wordpressTokens'][location.sub];

View File

@ -1,8 +1,8 @@
import store from '../../store'; import store from '../../store';
import zendeskHelper from './helpers/zendeskHelper'; import zendeskHelper from './helpers/zendeskHelper';
import providerRegistry from './providerRegistry'; import Provider from './common/Provider';
export default providerRegistry.register({ export default new Provider({
id: 'zendesk', id: 'zendesk',
getToken(location) { getToken(location) {
return store.getters['data/zendeskTokens'][location.sub]; return store.getters['data/zendeskTokens'][location.sub];

View File

@ -3,7 +3,7 @@ import store from '../store';
import utils from './utils'; import utils from './utils';
import networkSvc from './networkSvc'; import networkSvc from './networkSvc';
import exportSvc from './exportSvc'; import exportSvc from './exportSvc';
import providerRegistry from './providers/providerRegistry'; import providerRegistry from './providers/common/providerRegistry';
const hasCurrentFilePublishLocations = () => !!store.getters['publishLocation/current'].length; const hasCurrentFilePublishLocations = () => !!store.getters['publishLocation/current'].length;
@ -67,7 +67,7 @@ function publishFile(fileId) {
return loadContent(fileId) return loadContent(fileId)
.then(() => { .then(() => {
const publishLocations = [ const publishLocations = [
...store.getters['publishLocation/groupedByFileId'][fileId] || [], ...store.getters['publishLocation/filteredGroupedByFileId'][fileId] || [],
]; ];
const publishOneContentLocation = () => { const publishOneContentLocation = () => {
const publishLocation = publishLocations.shift(); const publishLocation = publishLocations.shift();

View File

@ -3,17 +3,23 @@ import store from '../store';
import utils from './utils'; import utils from './utils';
import diffUtils from './diffUtils'; import diffUtils from './diffUtils';
import networkSvc from './networkSvc'; import networkSvc from './networkSvc';
import providerRegistry from './providers/providerRegistry'; import providerRegistry from './providers/common/providerRegistry';
import googleDriveAppDataProvider from './providers/googleDriveAppDataProvider'; import googleDriveAppDataProvider from './providers/googleDriveAppDataProvider';
import './providers/googleDriveWorkspaceProvider';
import './providers/couchdbWorkspaceProvider'; import './providers/couchdbWorkspaceProvider';
import './providers/githubWorkspaceProvider';
import './providers/googleDriveWorkspaceProvider';
import tempFileSvc from './tempFileSvc'; import tempFileSvc from './tempFileSvc';
const minAutoSyncEvery = 60 * 1000; // 60 sec
const inactivityThreshold = 3 * 1000; // 3 sec const inactivityThreshold = 3 * 1000; // 3 sec
const restartSyncAfter = 30 * 1000; // 30 sec const restartSyncAfter = 30 * 1000; // 30 sec
const minAutoSyncEvery = 60 * 1000; // 60 sec const restartContentSyncAfter = 500; // Restart if an authorize window pops up
const maxContentHistory = 20; const maxContentHistory = 20;
const LAST_SEEN = 0;
const LAST_MERGED = 1;
const LAST_SENT = 2;
let actionProvider; let actionProvider;
let workspaceProvider; let workspaceProvider;
@ -69,6 +75,32 @@ function setLastSyncActivity() {
localStorage.setItem(store.getters['workspace/lastSyncActivityKey'], currentDate); localStorage.setItem(store.getters['workspace/lastSyncActivityKey'], currentDate);
} }
/**
* Upgrade hashes if syncedContent is from an old version
*/
function upgradeSyncedContent(syncedContent) {
if (syncedContent.v) {
return syncedContent;
}
const hashUpgrades = {};
const historyData = {};
const syncHistory = {};
Object.entries(syncedContent.historyData).forEach(([hash, content]) => {
const newContent = utils.addItemHash(content);
historyData[newContent.hash] = newContent;
hashUpgrades[hash] = newContent.hash;
});
Object.entries(syncedContent.syncHistory).forEach(([id, hashEntries]) => {
syncHistory[id] = hashEntries.map(hash => hashUpgrades[hash]);
});
return {
...syncedContent,
historyData,
syncHistory,
v: 1,
};
}
/** /**
* Clean a syncedContent. * Clean a syncedContent.
*/ */
@ -103,8 +135,10 @@ function applyChanges(changes) {
const existingItem = existingSyncData && storeItemMap[existingSyncData.itemId]; const existingItem = existingSyncData && storeItemMap[existingSyncData.itemId];
if (!change.item && existingSyncData) { if (!change.item && existingSyncData) {
// Item was removed // Item was removed
delete syncData[change.syncDataId]; if (syncData[change.syncDataId]) {
saveSyncData = true; delete syncData[change.syncDataId];
saveSyncData = true;
}
if (existingItem) { if (existingItem) {
// Remove object from the store // Remove object from the store
store.commit(`${existingItem.type}/deleteItem`, existingItem.id); store.commit(`${existingItem.type}/deleteItem`, existingItem.id);
@ -112,8 +146,10 @@ function applyChanges(changes) {
} }
} else if (change.item && change.item.hash) { } else if (change.item && change.item.hash) {
// Item was modifed // Item was modifed
syncData[change.syncDataId] = change.syncData; if ((existingSyncData || {}).hash !== change.syncData.hash) {
saveSyncData = true; syncData[change.syncDataId] = change.syncData;
saveSyncData = true;
}
if ( if (
// If no sync data or existing one is different // If no sync data or existing one is different
(existingSyncData || {}).hash !== change.item.hash (existingSyncData || {}).hash !== change.item.hash
@ -133,10 +169,6 @@ function applyChanges(changes) {
} }
} }
const LAST_SEEN = 0;
const LAST_MERGED = 1;
const LAST_SENT = 2;
/** /**
* Create a sync location by uploading the current file content. * Create a sync location by uploading the current file content.
*/ */
@ -157,8 +189,8 @@ function createSyncLocation(syncLocation) {
}, syncLocation) }, syncLocation)
.then(syncLocationToStore => localDbSvc.loadSyncedContent(fileId) .then(syncLocationToStore => localDbSvc.loadSyncedContent(fileId)
.then(() => { .then(() => {
const newSyncedContent = utils.deepCopy( const newSyncedContent = utils.deepCopy(upgradeSyncedContent(
store.state.syncedContent.itemMap[`${fileId}/syncedContent`]); store.state.syncedContent.itemMap[`${fileId}/syncedContent`]));
const newSyncHistoryItem = []; const newSyncHistoryItem = [];
newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem; newSyncedContent.syncHistory[syncLocation.id] = newSyncHistoryItem;
newSyncHistoryItem[LAST_SEEN] = content.hash; newSyncHistoryItem[LAST_SEEN] = content.hash;
@ -172,12 +204,23 @@ function createSyncLocation(syncLocation) {
}); });
} }
// Prevent from sending new data too long after old data has been fetched
const tooLateChecker = (timeout) => {
const tooLateAfter = Date.now() + timeout;
return cb => (res) => {
if (tooLateAfter < Date.now()) {
throw new Error('TOO_LATE');
}
return cb(res);
};
};
class SyncContext { class SyncContext {
restart = false; restart = false;
attempted = {}; attempted = {};
} }
/** /**
* Sync one file with all its locations. * Sync one file with all its locations.
*/ */
function syncFile(fileId, syncContext = new SyncContext()) { function syncFile(fileId, syncContext = new SyncContext()) {
@ -189,7 +232,8 @@ function syncFile(fileId, syncContext = new SyncContext()) {
.then(() => { .then(() => {
const getFile = () => store.state.file.itemMap[fileId]; const getFile = () => store.state.file.itemMap[fileId];
const getContent = () => store.state.content.itemMap[`${fileId}/content`]; const getContent = () => store.state.content.itemMap[`${fileId}/content`];
const getSyncedContent = () => store.state.syncedContent.itemMap[`${fileId}/syncedContent`]; const getSyncedContent = () => upgradeSyncedContent(
store.state.syncedContent.itemMap[`${fileId}/syncedContent`]);
const getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId]; const getSyncHistoryItem = syncLocationId => getSyncedContent().syncHistory[syncLocationId];
const isTempFile = () => { const isTempFile = () => {
@ -206,8 +250,8 @@ function syncFile(fileId, syncContext = new SyncContext()) {
return true; return true;
} }
const locations = [ const locations = [
...store.getters['syncLocation/groupedByFileId'][fileId] || [], ...store.getters['syncLocation/filteredGroupedByFileId'][fileId] || [],
...store.getters['publishLocation/groupedByFileId'][fileId] || [], ...store.getters['publishLocation/filteredGroupedByFileId'][fileId] || [],
]; ];
if (locations.length) { if (locations.length) {
// If file has explicit sync/publish locations, it's not a temp file // If file has explicit sync/publish locations, it's not a temp file
@ -227,7 +271,7 @@ function syncFile(fileId, syncContext = new SyncContext()) {
const attemptedLocations = {}; const attemptedLocations = {};
const syncOneContentLocation = () => { const syncOneContentLocation = () => {
const syncLocations = [ const syncLocations = [
...store.getters['syncLocation/groupedByFileId'][fileId] || [], ...store.getters['syncLocation/filteredGroupedByFileId'][fileId] || [],
]; ];
if (isWorkspaceSyncPossible()) { if (isWorkspaceSyncPossible()) {
syncLocations.unshift({ id: 'main', providerId: workspaceProvider.id, fileId }); syncLocations.unshift({ id: 'main', providerId: workspaceProvider.id, fileId });
@ -289,7 +333,6 @@ function syncFile(fileId, syncContext = new SyncContext()) {
properties: utils.sanitizeText(mergedContent.properties), properties: utils.sanitizeText(mergedContent.properties),
discussions: mergedContent.discussions, discussions: mergedContent.discussions,
comments: mergedContent.comments, comments: mergedContent.comments,
hash: 0,
}); });
// Retrieve content with new `hash` and freeze it // Retrieve content with new `hash` and freeze it
@ -342,21 +385,11 @@ function syncFile(fileId, syncContext = new SyncContext()) {
return null; return null;
} }
// Prevent from sending new content too long after old content has been fetched
const syncStartTime = Date.now();
const ifNotTooLate = cb => (res) => {
// No time to refresh a token...
if (syncStartTime + 500 < Date.now()) {
throw new Error('TOO_LATE');
}
return cb(res);
};
// Upload merged content // Upload merged content
return provider.uploadContent(token, { return provider.uploadContent(token, {
...mergedContent, ...mergedContent,
history: mergedContentHistory.slice(0, maxContentHistory), history: mergedContentHistory.slice(0, maxContentHistory),
}, syncLocation, ifNotTooLate) }, syncLocation, tooLateChecker(restartContentSyncAfter))
.then((syncLocationToStore) => { .then((syncLocationToStore) => {
// Replace sync location if modified // Replace sync location if modified
if (utils.serializeObject(syncLocation) !== if (utils.serializeObject(syncLocation) !==
@ -465,13 +498,11 @@ function syncDataItem(dataId) {
if (serverItem && serverItem.hash === mergedItem.hash) { if (serverItem && serverItem.hash === mergedItem.hash) {
return null; return null;
} }
return workspaceProvider.uploadData(mergedItem, dataId); return workspaceProvider.uploadData(mergedItem, tooLateChecker(restartContentSyncAfter));
}) })
.then(() => { .then(() => store.dispatch('data/patchDataSyncData', {
store.dispatch('data/patchDataSyncData', { [dataId]: utils.deepCopy(store.getters['data/syncDataByItemId'][dataId]),
[dataId]: utils.deepCopy(store.getters['data/syncDataByItemId'][dataId]), }));
});
});
}); });
} }
@ -499,16 +530,12 @@ function syncWorkspace() {
.then((changes) => { .then((changes) => {
// Apply changes // Apply changes
applyChanges(changes); applyChanges(changes);
workspaceProvider.setAppliedChanges(changes); if (workspaceProvider.onChangesApplied) {
workspaceProvider.onChangesApplied();
}
// Prevent from sending items too long after changes have been retrieved // Prevent from sending items too long after changes have been retrieved
const syncStartTime = Date.now(); const ifNotTooLate = tooLateChecker(restartSyncAfter);
const ifNotTooLate = cb => (res) => {
if (syncStartTime + restartSyncAfter < Date.now()) {
throw new Error('TOO_LATE');
}
return cb(res);
};
// Called until no item to save // Called until no item to save
const saveNextItem = ifNotTooLate(() => { const saveNextItem = ifNotTooLate(() => {
@ -529,12 +556,13 @@ function syncWorkspace() {
// Add file if content has been added // Add file if content has been added
&& (item.type !== 'file' || syncDataByItemId[`${id}/content`]) && (item.type !== 'file' || syncDataByItemId[`${id}/content`])
) { ) {
promise = workspaceProvider.saveSimpleItem( promise = workspaceProvider
// Use deepCopy to freeze objects .saveSimpleItem(
utils.deepCopy(item), // Use deepCopy to freeze objects
utils.deepCopy(existingSyncData), utils.deepCopy(item),
ifNotTooLate, utils.deepCopy(existingSyncData),
) ifNotTooLate,
)
.then(resultSyncData => store.dispatch('data/patchSyncData', { .then(resultSyncData => store.dispatch('data/patchSyncData', {
[resultSyncData.id]: resultSyncData, [resultSyncData.id]: resultSyncData,
})) }))
@ -612,6 +640,9 @@ function syncWorkspace() {
.then(() => syncNextFile()); .then(() => syncNextFile());
}; };
const onSyncEnd = () => Promise.resolve(
workspaceProvider.onSyncEnd && workspaceProvider.onSyncEnd());
return Promise.resolve() return Promise.resolve()
.then(() => saveNextItem()) .then(() => saveNextItem())
.then(() => removeNextItem()) .then(() => removeNextItem())
@ -629,6 +660,13 @@ function syncWorkspace() {
} }
return syncNextFile(); return syncNextFile();
}) })
.then(
() => onSyncEnd(),
err => onSyncEnd().then(() => {
throw err;
}, () => {
throw err;
}))
.then( .then(
() => { () => {
if (syncContext.restart) { if (syncContext.restart) {

View File

@ -4,6 +4,10 @@ import store from '../store';
const promised = {}; const promised = {};
export default { export default {
addInfo({ id, name, imageUrl }) {
promised[id] = true;
store.commit('userInfo/addItem', { id, name, imageUrl });
},
getInfo(userId) { getInfo(userId) {
if (!promised[userId]) { if (!promised[userId]) {
// Try to find a token with this sub // Try to find a token with this sub

View File

@ -118,6 +118,18 @@ export default {
}, {}); }, {});
}); });
}, },
search(items, criteria) {
let result;
items.some((item) => {
// If every field fits the criteria
if (Object.entries(criteria).every(([key, value]) => value === item[key])) {
result = item;
return true;
}
return false;
});
return result;
},
uid() { uid() {
crypto.getRandomValues(array); crypto.getRandomValues(array);
return array.cl_map(value => alphabet[value % radix]).join(''); return array.cl_map(value => alphabet[value % radix]).join('');
@ -132,26 +144,39 @@ export default {
} }
return hash; return hash;
}, },
getItemHash(item) {
return this.hash(this.serializeObject({
...item,
// These properties must not be part of the hash
id: undefined,
hash: undefined,
history: undefined,
}));
},
addItemHash(item) { addItemHash(item) {
return { return {
...item, ...item,
hash: this.hash(this.serializeObject({ hash: this.getItemHash(item),
...item,
// These properties must not be part of the hash
history: undefined,
hash: undefined,
})),
}; };
}, },
makeWorkspaceId(params) { makeWorkspaceId(params) {
return Math.abs(this.hash(this.serializeObject(params))).toString(36); return Math.abs(this.hash(this.serializeObject(params))).toString(36);
}, },
encodeBase64(str) { encodeBase64(str, urlSafe = false) {
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, const result = btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
(match, p1) => String.fromCharCode(`0x${p1}`))); (match, p1) => String.fromCharCode(`0x${p1}`)));
if (!urlSafe) {
return result;
}
return result
.replace(/\//g, '_') // Replace `/` with `_`
.replace(/\+/g, '-') // Replace `+` with `-`
.replace(/=+$/, ''); // Remove trailing `=`
}, },
decodeBase64(str) { decodeBase64(str) {
return decodeURIComponent(atob(str).split('').map( // In case of URL safe base64
const sanitizedStr = str.replace(/_/g, '/').replace(/-/g, '+');
return decodeURIComponent(atob(sanitizedStr).split('').map(
c => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`).join('')); c => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`).join(''));
}, },
computeProperties(yamlProperties) { computeProperties(yamlProperties) {
@ -214,6 +239,9 @@ export default {
} }
return result; return result;
}, },
concatPaths(...paths) {
return paths.join('/').replace(/\/+/g, '/');
},
getHostname(url) { getHostname(url) {
urlParser.href = url; urlParser.href = url;
return urlParser.hostname; return urlParser.hostname;
@ -221,7 +249,7 @@ export default {
createHiddenIframe(url) { createHiddenIframe(url) {
const iframeElt = document.createElement('iframe'); const iframeElt = document.createElement('iframe');
iframeElt.style.position = 'absolute'; iframeElt.style.position = 'absolute';
iframeElt.style.left = '-9999px'; iframeElt.style.left = '-99px';
iframeElt.style.width = '1px'; iframeElt.style.width = '1px';
iframeElt.style.height = '1px'; iframeElt.style.height = '1px';
iframeElt.src = url; iframeElt.src = url;

View File

@ -96,8 +96,8 @@ export default {
rootGetters['folder/items'].forEach((item) => { rootGetters['folder/items'].forEach((item) => {
nodeMap[item.id] = new Node(item, [], true); nodeMap[item.id] = new Node(item, [], true);
}); });
const syncLocationsByFileId = rootGetters['syncLocation/groupedByFileId']; const syncLocationsByFileId = rootGetters['syncLocation/filteredGroupedByFileId'];
const publishLocationsByFileId = rootGetters['publishLocation/groupedByFileId']; const publishLocationsByFileId = rootGetters['publishLocation/filteredGroupedByFileId'];
rootGetters['file/items'].forEach((item) => { rootGetters['file/items'].forEach((item) => {
const locations = [ const locations = [
...syncLocationsByFileId[item.id] || [], ...syncLocationsByFileId[item.id] || [],

View File

@ -14,12 +14,13 @@ import folder from './folder';
import layout from './layout'; import layout from './layout';
import modal from './modal'; import modal from './modal';
import notification from './notification'; import notification from './notification';
import publishLocation from './publishLocation';
import queue from './queue'; import queue from './queue';
import syncedContent from './syncedContent'; import syncedContent from './syncedContent';
import syncLocation from './syncLocation';
import userInfo from './userInfo'; import userInfo from './userInfo';
import workspace from './workspace'; import workspace from './workspace';
import locationTemplate from './locationTemplate';
import emptyPublishLocation from '../data/emptyPublishLocation';
import emptySyncLocation from '../data/emptySyncLocation';
Vue.use(Vuex); Vue.use(Vuex);
@ -39,10 +40,10 @@ const store = new Vuex.Store({
layout, layout,
modal, modal,
notification, notification,
publishLocation, publishLocation: locationTemplate(emptyPublishLocation),
queue, queue,
syncedContent, syncedContent,
syncLocation, syncLocation: locationTemplate(emptySyncLocation),
userInfo, userInfo,
workspace, workspace,
}, },
@ -59,6 +60,41 @@ const store = new Vuex.Store({
utils.types.forEach(type => Object.assign(result, state[type].itemMap)); utils.types.forEach(type => Object.assign(result, state[type].itemMap));
return result; return result;
}, },
itemPaths: (state) => {
const result = {};
const getPath = (item) => {
let itemPath = result[item.id];
if (!itemPath) {
if (item.parendId === 'trash') {
itemPath = `.stackedit-trash/${item.name}`;
} else {
let name = item.name;
if (item.type === 'folder') {
name += '/';
}
const parent = state.folder.itemMap[item.parentId];
if (!parent) {
itemPath = name;
} else {
itemPath = getPath(parent) + name;
}
}
}
result[item.id] = itemPath;
return itemPath;
};
[...state.folder.items, ...state.file.items].forEach(item => getPath(item));
return result;
},
pathItems: (state, getters) => {
const result = {};
const itemPaths = getters.itemPaths;
const allItemMap = getters.allItemMap;
Object.entries(itemPaths).forEach(([id, path]) => {
result[path] = allItemMap[id];
});
return result;
},
isSponsor: (state, getters) => { isSponsor: (state, getters) => {
const sponsorToken = getters['workspace/sponsorToken']; const sponsorToken = getters['workspace/sponsorToken'];
return state.light || state.monetizeSponsor || (sponsorToken && sponsorToken.isSponsor); return state.light || state.monetizeSponsor || (sponsorToken && sponsorToken.isSponsor);

View File

@ -0,0 +1,43 @@
import moduleTemplate from './moduleTemplate';
import providerRegistry from '../services/providers/common/providerRegistry';
const addToGroup = (groups, item) => {
const list = groups[item.fileId] || [];
list.push(item);
groups[item.fileId] = list;
};
export default (empty) => {
const module = moduleTemplate(empty);
module.getters = {
...module.getters,
groupedByFileId: (state, getters) => {
const groups = {};
getters.items.forEach(item => addToGroup(groups, item));
return groups;
},
filteredGroupedByFileId: (state, getters) => {
const groups = {};
getters.items.filter((item) => {
// Filter items that we can't use
const provider = providerRegistry.providers[item.providerId];
return provider && provider.getToken(item);
}).forEach(item => addToGroup(groups, item));
return groups;
},
current: (state, getters, rootState, rootGetters) => {
const locations = getters.filteredGroupedByFileId[rootGetters['file/current'].id] || [];
return locations.map((location) => {
const provider = providerRegistry.providers[location.providerId];
return {
...location,
description: provider.getDescription(location),
url: provider.getUrl(location),
};
});
},
};
return module;
};

View File

@ -3,10 +3,7 @@ import utils from '../services/utils';
export default (empty, simpleHash = false) => { export default (empty, simpleHash = false) => {
// Use Date.now() as a simple hash function, which is ok for not-synced types // Use Date.now() as a simple hash function, which is ok for not-synced types
const hashFunc = simpleHash ? Date.now : item => utils.hash(utils.serializeObject({ const hashFunc = simpleHash ? Date.now : item => utils.getItemHash(item);
...item,
hash: undefined,
}));
return { return {
namespaced: true, namespaced: true,
@ -19,7 +16,7 @@ export default (empty, simpleHash = false) => {
mutations: { mutations: {
setItem(state, value) { setItem(state, value) {
const item = Object.assign(empty(value.id), value); const item = Object.assign(empty(value.id), value);
if (!item.hash) { if (!item.hash || !simpleHash) {
item.hash = hashFunc(item); item.hash = hashFunc(item);
} }
Vue.set(state.itemMap, item.id, item); Vue.set(state.itemMap, item.id, item);

View File

@ -1,35 +0,0 @@
import moduleTemplate from './moduleTemplate';
import empty from '../data/emptyPublishLocation';
import providerRegistry from '../services/providers/providerRegistry';
const module = moduleTemplate(empty);
module.getters = {
...module.getters,
groupedByFileId: (state, getters) => {
const result = {};
getters.items.forEach((item) => {
// Filter items that we can't use
const provider = providerRegistry.providers[item.providerId];
if (provider && provider.getToken(item)) {
const list = result[item.fileId] || [];
list.push(item);
result[item.fileId] = list;
}
});
return result;
},
current: (state, getters, rootState, rootGetters) => {
const locations = getters.groupedByFileId[rootGetters['file/current'].id] || [];
return locations.map((location) => {
const provider = providerRegistry.providers[location.providerId];
return {
...location,
description: provider.getDescription(location),
url: provider.getUrl(location),
};
});
},
};
export default module;

View File

@ -1,35 +0,0 @@
import moduleTemplate from './moduleTemplate';
import empty from '../data/emptySyncLocation';
import providerRegistry from '../services/providers/providerRegistry';
const module = moduleTemplate(empty);
module.getters = {
...module.getters,
groupedByFileId: (state, getters) => {
const result = {};
getters.items.forEach((item) => {
// Filter items that we can't use
const provider = providerRegistry.providers[item.providerId];
if (provider && provider.getToken(item)) {
const list = result[item.fileId] || [];
list.push(item);
result[item.fileId] = list;
}
});
return result;
},
current: (state, getters, rootState, rootGetters) => {
const locations = getters.groupedByFileId[rootGetters['file/current'].id] || [];
return locations.map((location) => {
const provider = providerRegistry.providers[location.providerId];
return {
...location,
description: provider.getDescription(location),
url: provider.getUrl(location),
};
});
},
};
export default module;

View File

@ -36,6 +36,10 @@ export default {
const googleTokens = rootGetters['data/googleTokens']; const googleTokens = rootGetters['data/googleTokens'];
return googleTokens[workspace.sub]; return googleTokens[workspace.sub];
} }
case 'githubWorkspace': {
const githubTokens = rootGetters['data/githubTokens'];
return githubTokens[workspace.sub];
}
case 'couchdbWorkspace': { case 'couchdbWorkspace': {
const couchdbTokens = rootGetters['data/couchdbTokens']; const couchdbTokens = rootGetters['data/couchdbTokens'];
return couchdbTokens[workspace.id]; return couchdbTokens[workspace.id];