支持Gitee 修复Github Oauth2授权bug

This commit is contained in:
Jacky Chen 2022-05-23 11:29:54 +08:00
parent 46383b5b6a
commit e7e335d958
45 changed files with 1306 additions and 33 deletions

View File

@ -1,4 +1,4 @@
FROM benweet/stackedit-base FROM node:11.15.0
RUN mkdir -p /opt/stackedit RUN mkdir -p /opt/stackedit
WORKDIR /opt/stackedit WORKDIR /opt/stackedit

View File

@ -1,10 +1,22 @@
# StackEdit # StackEdit
从 [StackEdit 官方](https://github.com/benweet/stackedit) fork出来然后加上了 **Gitee** 的支持并且已经重新打了镜像以下官方的部署方式除了Docker镜像地址不同其他均一致。
Fork出来修改的原因Stackedit的作者可能因为什么原因已经很久不维护了Github授权登录很早之前就登录不了了并且还没发支持国内常用的Gitee比较蛋疼所以想到Fork出来改大概花了周末一整天终于改好了。
新的Docker镜像在中央仓库为mafgwo/stackedit当前最新版本为5.15.1(延续原有版本号)
并增加了以下三个环境变量:
- `GITEE_CLIENT_ID` Gitee 的 Client ID
- `GITEE_CLIENT_SECRET` Gitee 的 Client Secret
- `GITEE_CALLBACK` Gitee的回调地址Gitee授权获取token时还需要传入回调地址格式是 http[s]://[hostname]:[port]/oauth2/callback
[![Build Status](https://img.shields.io/travis/benweet/stackedit.svg?style=flat)](https://travis-ci.org/benweet/stackedit) [![NPM version](https://img.shields.io/npm/v/stackedit.svg?style=flat)](https://www.npmjs.org/package/stackedit) [![Build Status](https://img.shields.io/travis/benweet/stackedit.svg?style=flat)](https://travis-ci.org/benweet/stackedit) [![NPM version](https://img.shields.io/npm/v/stackedit.svg?style=flat)](https://www.npmjs.org/package/stackedit)
> Full-featured, open-source Markdown editor based on PageDown, the Markdown library used by Stack Overflow and the other Stack Exchange sites. > Full-featured, open-source Markdown editor based on PageDown, the Markdown library used by Stack Overflow and the other Stack Exchange sites.
https://stackedit.io/ https://edit.qicoder.com/
### Ecosystem ### Ecosystem

View File

@ -8,6 +8,8 @@ googleClientId: ""
googleApiKey: "" googleApiKey: ""
githubClientId: "" githubClientId: ""
githubClientSecret: "" githubClientSecret: ""
giteeClientId: ""
giteeClientSecret: ""
wordpressClientId: "" wordpressClientId: ""
wordpressSecret: "" wordpressSecret: ""
paypalReceiverEmail: "" paypalReceiverEmail: ""

View File

@ -15,10 +15,10 @@
}, },
"app": { "app": {
"urls": [ "urls": [
"https://stackedit.io/" "https://edit.qicoder.com/"
], ],
"launch": { "launch": {
"web_url": "https://stackedit.io/app" "web_url": "https://edit.qicoder.com/app"
} }
}, },
"offline_enabled": true, "offline_enabled": true,

View File

@ -3,4 +3,4 @@ var prodEnv = require('./prod.env')
module.exports = merge(prodEnv, { module.exports = merge(prodEnv, {
NODE_ENV: '"development"' NODE_ENV: '"development"'
}) })

View File

@ -23,7 +23,7 @@ module.exports = {
}, },
dev: { dev: {
env: require('./dev.env'), env: require('./dev.env'),
port: 8080, port: 80,
autoOpenBrowser: false, autoOpenBrowser: false,
assetsSubDirectory: 'static', assetsSubDirectory: 'static',
assetsPublicPath: '/', assetsPublicPath: '/',

View File

@ -3,15 +3,26 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>StackEdit</title> <title>StackEdit</title>
<link rel="canonical" href="https://stackedit.io/app"> <link rel="canonical" href="https://edit.qicoder.com/app">
<meta name="description" content="Free, open-source, full-featured Markdown editor."> <meta name="description" content="Free, open-source, full-featured Markdown editor.">
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"> <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<script>
var _hmt = _hmt || [];
</script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->
<script>
(function() {
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?6e5d2dbd2eeb7bba778f1056fba280d1";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();
</script>
</body> </body>
</html> </html>

View File

@ -1,11 +1,11 @@
{ {
"name": "stackedit", "name": "stackedit",
"version": "5.14.10", "version": "5.15.1",
"description": "Free, open-source, full-featured Markdown editor", "description": "Free, open-source, full-featured Markdown editor",
"author": "Benoit Schweblin", "author": "Benoit Schweblin",
"license": "Apache-2.0", "license": "Apache-2.0",
"bugs": { "bugs": {
"url": "https://github.com/benweet/stackedit/issues" "url": "https://github.com/mafgwo/stackedit/issues"
}, },
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -8,6 +8,9 @@ const dropboxAppKey = process.env.DROPBOX_APP_KEY;
const dropboxAppKeyFull = process.env.DROPBOX_APP_KEY_FULL; const dropboxAppKeyFull = process.env.DROPBOX_APP_KEY_FULL;
const githubClientId = process.env.GITHUB_CLIENT_ID; const githubClientId = process.env.GITHUB_CLIENT_ID;
const githubClientSecret = process.env.GITHUB_CLIENT_SECRET; const githubClientSecret = process.env.GITHUB_CLIENT_SECRET;
const giteeClientId = process.env.GITEE_CLIENT_ID;
const giteeClientSecret = process.env.GITEE_CLIENT_SECRET;
const giteeCallback = process.env.GITEE_CALLBACK;
const googleClientId = process.env.GOOGLE_CLIENT_ID; const googleClientId = process.env.GOOGLE_CLIENT_ID;
const googleApiKey = process.env.GOOGLE_API_KEY; const googleApiKey = process.env.GOOGLE_API_KEY;
const wordpressClientId = process.env.WORDPRESS_CLIENT_ID; const wordpressClientId = process.env.WORDPRESS_CLIENT_ID;
@ -22,6 +25,9 @@ exports.values = {
dropboxAppKeyFull, dropboxAppKeyFull,
githubClientId, githubClientId,
githubClientSecret, githubClientSecret,
giteeClientId,
giteeClientSecret,
giteeCallback,
googleClientId, googleClientId,
googleApiKey, googleApiKey,
wordpressClientId, wordpressClientId,
@ -31,6 +37,7 @@ exports.publicValues = {
dropboxAppKey, dropboxAppKey,
dropboxAppKeyFull, dropboxAppKeyFull,
githubClientId, githubClientId,
giteeClientId,
googleClientId, googleClientId,
googleApiKey, googleApiKey,
wordpressClientId, wordpressClientId,

45
server/gitee.js Normal file
View File

@ -0,0 +1,45 @@
const qs = require('qs'); // eslint-disable-line import/no-extraneous-dependencies
const request = require('request');
const conf = require('./conf');
function giteeToken(clientId, code) {
console.log('clientId: ' + clientId);
console.log('code: ' + code);
console.log('client_secret: ' + conf.values.giteeClientSecret);
console.log('redirect_uri: ' + conf.values.giteeCallback);
return new Promise((resolve, reject) => {
request({
method: 'POST',
url: 'https://gitee.com/oauth/token',
form: {
client_id: clientId,
client_secret: conf.values.giteeClientSecret,
code,
grant_type: 'authorization_code',
scope: 'authorization_code',
redirect_uri: conf.values.giteeCallback,
},
json: true
}, (err, res, body) => {
if (err) {
reject(err);
}
const token = body.access_token;
if (token) {
resolve(token);
} else {
reject(res.statusCode + ',body:' + JSON.stringify(body));
}
});
});
}
exports.giteeToken = (req, res) => {
giteeToken(req.query.clientId, req.query.code)
.then(
token => res.send(token),
err => res
.status(400)
.send(err ? err.message || err.toString() : 'bad_code'),
);
};

View File

@ -4,6 +4,7 @@ const bodyParser = require('body-parser');
const path = require('path'); const path = require('path');
const user = require('./user'); const user = require('./user');
const github = require('./github'); const github = require('./github');
const gitee = require('./gitee');
const pdf = require('./pdf'); const pdf = require('./pdf');
const pandoc = require('./pandoc'); const pandoc = require('./pandoc');
const conf = require('./conf'); const conf = require('./conf');
@ -25,6 +26,7 @@ module.exports = (app) => {
} }
app.get('/oauth2/githubToken', github.githubToken); app.get('/oauth2/githubToken', github.githubToken);
app.get('/oauth2/giteeToken', gitee.giteeToken);
app.get('/conf', (req, res) => res.send(conf.publicValues)); app.get('/conf', (req, res) => res.send(conf.publicValues));
app.get('/userInfo', user.userInfo); app.get('/userInfo', user.userInfo);
app.post('/pdfExport', pdf.generate); app.post('/pdfExport', pdf.generate);
@ -37,6 +39,8 @@ module.exports = (app) => {
app.get('/', (req, res) => res.sendFile(resolvePath('static/landing/index.html'))); app.get('/', (req, res) => res.sendFile(resolvePath('static/landing/index.html')));
// Serve sitemap.xml // Serve sitemap.xml
app.get('/sitemap.xml', (req, res) => res.sendFile(resolvePath('static/sitemap.xml'))); app.get('/sitemap.xml', (req, res) => res.sendFile(resolvePath('static/sitemap.xml')));
// Serve google-api.js
app.get('/google-api.js', (req, res) => res.sendFile(resolvePath('static/google-api.js')));
// Serve callback.html // Serve callback.html
app.get('/oauth2/callback', (req, res) => res.sendFile(resolvePath('static/oauth2/callback.html'))); app.get('/oauth2/callback', (req, res) => res.sendFile(resolvePath('static/oauth2/callback.html')));
// Google Drive action receiver // Google Drive action receiver

5
src/assets/iconGitee.svg Normal file
View File

@ -0,0 +1,5 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg t="1652950823759" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2991" width="32" height="32" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs><style type="text/css"></style></defs>
<path d="M512 1024C229.222 1024 0 794.778 0 512S229.222 0 512 0s512 229.222 512 512-229.222 512-512 512z m259.149-568.883h-290.74a25.293 25.293 0 0 0-25.292 25.293l-0.026 63.206c0 13.952 11.315 25.293 25.267 25.293h177.024c13.978 0 25.293 11.315 25.293 25.267v12.646a75.853 75.853 0 0 1-75.853 75.853h-240.23a25.293 25.293 0 0 1-25.267-25.293V417.203a75.853 75.853 0 0 1 75.827-75.853h353.946a25.293 25.293 0 0 0 25.267-25.292l0.077-63.207a25.293 25.293 0 0 0-25.268-25.293H417.152a189.62 189.62 0 0 0-189.62 189.645V771.15c0 13.977 11.316 25.293 25.294 25.293h372.94a170.65 170.65 0 0 0 170.65-170.65V480.384a25.293 25.293 0 0 0-25.293-25.267z" fill="#C71D23" p-id="2992"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="modal" v-if="config" @keydown.esc.stop="onEscape" @keydown.tab="onTab" @focusin="onFocusInOut" @focusout="onFocusInOut"> <div class="modal" v-if="config" @keydown.esc.stop="onEscape" @keydown.tab="onTab" @focusin="onFocusInOut" @focusout="onFocusInOut">
<div class="modal__sponsor-banner" v-if="!isSponsor"> <div class="modal__sponsor-banner" v-if="!isSponsor">
StackEdit is <a class="not-tabbable" target="_blank" href="https://github.com/benweet/stackedit/">open source</a>, please consider StackEdit is <a class="not-tabbable" target="_blank" href="https://github.com/mafgwo/stackedit/">open source</a>, please consider
<a class="not-tabbable" href="javascript:void(0)" @click="sponsor">sponsoring</a> for just $5. <a class="not-tabbable" href="javascript:void(0)" @click="sponsor">sponsoring</a> for just $5.
</div> </div>
<component v-if="currentModalComponent" :is="currentModalComponent"></component> <component v-if="currentModalComponent" :is="currentModalComponent"></component>
@ -56,6 +56,11 @@ 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';
import GiteeAccountModal from './modals/providers/GiteeAccountModal';
import GiteeOpenModal from './modals/providers/GiteeOpenModal';
import GiteeSaveModal from './modals/providers/GiteeSaveModal';
import GiteeWorkspaceModal from './modals/providers/GiteeWorkspaceModal';
import GiteePublishModal from './modals/providers/GiteePublishModal';
import GitlabAccountModal from './modals/providers/GitlabAccountModal'; import GitlabAccountModal from './modals/providers/GitlabAccountModal';
import GitlabOpenModal from './modals/providers/GitlabOpenModal'; import GitlabOpenModal from './modals/providers/GitlabOpenModal';
import GitlabPublishModal from './modals/providers/GitlabPublishModal'; import GitlabPublishModal from './modals/providers/GitlabPublishModal';
@ -107,6 +112,11 @@ export default {
GithubPublishModal, GithubPublishModal,
GistSyncModal, GistSyncModal,
GistPublishModal, GistPublishModal,
GiteeAccountModal,
GiteeOpenModal,
GiteeSaveModal,
GiteeWorkspaceModal,
GiteePublishModal,
GitlabAccountModal, GitlabAccountModal,
GitlabOpenModal, GitlabOpenModal,
GitlabPublishModal, GitlabPublishModal,

View File

@ -23,6 +23,9 @@
<span v-else-if="currentWorkspace.providerId === 'githubWorkspace'"> <span v-else-if="currentWorkspace.providerId === 'githubWorkspace'">
<b>{{currentWorkspace.name}}</b> synced with a <a :href="workspaceLocationUrl" target="_blank">GitHub repo</a>. <b>{{currentWorkspace.name}}</b> synced with a <a :href="workspaceLocationUrl" target="_blank">GitHub repo</a>.
</span> </span>
<span v-else-if="currentWorkspace.providerId === 'giteeWorkspace'">
<b>{{currentWorkspace.name}}</b> synced with a <a :href="workspaceLocationUrl" target="_blank">Gitee repo</a>.
</span>
<span v-else-if="currentWorkspace.providerId === 'gitlabWorkspace'"> <span v-else-if="currentWorkspace.providerId === 'gitlabWorkspace'">
<b>{{currentWorkspace.name}}</b> synced with a <a :href="workspaceLocationUrl" target="_blank">GitLab project</a>. <b>{{currentWorkspace.name}}</b> synced with a <a :href="workspaceLocationUrl" target="_blank">GitLab project</a>.
</span> </span>

View File

@ -52,6 +52,13 @@
<span>{{token.name}}</span> <span>{{token.name}}</span>
</menu-entry> </menu-entry>
</div> </div>
<div v-for="token in giteeTokens" :key="token.sub">
<menu-entry @click.native="publishGitee(token)">
<icon-provider slot="icon" provider-id="gitee"></icon-provider>
<div>Publish to Gitee</div>
<span>{{token.name}}</span>
</menu-entry>
</div>
<div v-for="token in gitlabTokens" :key="token.sub"> <div v-for="token in gitlabTokens" :key="token.sub">
<menu-entry @click.native="publishGitlab(token)"> <menu-entry @click.native="publishGitlab(token)">
<icon-provider slot="icon" provider-id="gitlab"></icon-provider> <icon-provider slot="icon" provider-id="gitlab"></icon-provider>
@ -93,6 +100,10 @@
<icon-provider slot="icon" provider-id="github"></icon-provider> <icon-provider slot="icon" provider-id="github"></icon-provider>
<span>Add GitHub account</span> <span>Add GitHub account</span>
</menu-entry> </menu-entry>
<menu-entry @click.native="addGiteeAccount">
<icon-provider slot="icon" provider-id="gitee"></icon-provider>
<span>Add Gitee account</span>
</menu-entry>
<menu-entry @click.native="addGitlabAccount"> <menu-entry @click.native="addGitlabAccount">
<icon-provider slot="icon" provider-id="gitlab"></icon-provider> <icon-provider slot="icon" provider-id="gitlab"></icon-provider>
<span>Add GitLab account</span> <span>Add GitLab account</span>
@ -119,6 +130,7 @@ import MenuEntry from './common/MenuEntry';
import googleHelper from '../../services/providers/helpers/googleHelper'; import googleHelper from '../../services/providers/helpers/googleHelper';
import dropboxHelper from '../../services/providers/helpers/dropboxHelper'; import dropboxHelper from '../../services/providers/helpers/dropboxHelper';
import githubHelper from '../../services/providers/helpers/githubHelper'; import githubHelper from '../../services/providers/helpers/githubHelper';
import giteeHelper from '../../services/providers/helpers/giteeHelper';
import gitlabHelper from '../../services/providers/helpers/gitlabHelper'; import gitlabHelper from '../../services/providers/helpers/gitlabHelper';
import wordpressHelper from '../../services/providers/helpers/wordpressHelper'; import wordpressHelper from '../../services/providers/helpers/wordpressHelper';
import zendeskHelper from '../../services/providers/helpers/zendeskHelper'; import zendeskHelper from '../../services/providers/helpers/zendeskHelper';
@ -168,6 +180,9 @@ export default {
githubTokens() { githubTokens() {
return tokensToArray(store.getters['data/githubTokensBySub']); return tokensToArray(store.getters['data/githubTokensBySub']);
}, },
giteeTokens() {
return tokensToArray(store.getters['data/giteeTokensBySub']);
},
gitlabTokens() { gitlabTokens() {
return tokensToArray(store.getters['data/gitlabTokensBySub']); return tokensToArray(store.getters['data/gitlabTokensBySub']);
}, },
@ -218,6 +233,12 @@ export default {
await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess); await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess);
} catch (e) { /* cancel */ } } catch (e) { /* cancel */ }
}, },
async addGiteeAccount() {
try {
await store.dispatch('modal/open', { type: 'giteeAccount' });
await giteeHelper.addAccount();
} catch (e) { /* cancel */ }
},
async addGitlabAccount() { async addGitlabAccount() {
try { try {
const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' }); const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' });
@ -245,6 +266,7 @@ export default {
publishBloggerPage: publishModalOpener('bloggerPagePublish', 'publishToBloggerPage'), publishBloggerPage: publishModalOpener('bloggerPagePublish', 'publishToBloggerPage'),
publishDropbox: publishModalOpener('dropboxPublish', 'publishToDropbox'), publishDropbox: publishModalOpener('dropboxPublish', 'publishToDropbox'),
publishGithub: publishModalOpener('githubPublish', 'publishToGithub'), publishGithub: publishModalOpener('githubPublish', 'publishToGithub'),
publishGitee: publishModalOpener('giteePublish', 'publishToGitee'),
publishGist: publishModalOpener('gistPublish', 'publishToGist'), publishGist: publishModalOpener('gistPublish', 'publishToGist'),
publishGitlab: publishModalOpener('gitlabPublish', 'publishToGitlab'), publishGitlab: publishModalOpener('gitlabPublish', 'publishToGitlab'),
publishGoogleDrive: publishModalOpener('googleDrivePublish', 'publishToGoogleDrive'), publishGoogleDrive: publishModalOpener('googleDrivePublish', 'publishToGoogleDrive'),

View File

@ -83,6 +83,10 @@
<icon-provider slot="icon" provider-id="github"></icon-provider> <icon-provider slot="icon" provider-id="github"></icon-provider>
<span>Add GitHub account</span> <span>Add GitHub account</span>
</menu-entry> </menu-entry>
<menu-entry @click.native="addGiteeAccount">
<icon-provider slot="icon" provider-id="gitee"></icon-provider>
<span>Add Gitee account</span>
</menu-entry>
<menu-entry @click.native="addGitlabAccount"> <menu-entry @click.native="addGitlabAccount">
<icon-provider slot="icon" provider-id="gitlab"></icon-provider> <icon-provider slot="icon" provider-id="gitlab"></icon-provider>
<span>Add GitLab account</span> <span>Add GitLab account</span>
@ -101,6 +105,7 @@ import MenuEntry from './common/MenuEntry';
import googleHelper from '../../services/providers/helpers/googleHelper'; import googleHelper from '../../services/providers/helpers/googleHelper';
import dropboxHelper from '../../services/providers/helpers/dropboxHelper'; import dropboxHelper from '../../services/providers/helpers/dropboxHelper';
import githubHelper from '../../services/providers/helpers/githubHelper'; import githubHelper from '../../services/providers/helpers/githubHelper';
import giteeHelper from '../../services/providers/helpers/giteeHelper';
import gitlabHelper from '../../services/providers/helpers/gitlabHelper'; import gitlabHelper from '../../services/providers/helpers/gitlabHelper';
import googleDriveProvider from '../../services/providers/googleDriveProvider'; import googleDriveProvider from '../../services/providers/googleDriveProvider';
import dropboxProvider from '../../services/providers/dropboxProvider'; import dropboxProvider from '../../services/providers/dropboxProvider';
@ -148,6 +153,9 @@ export default {
githubTokens() { githubTokens() {
return tokensToArray(store.getters['data/githubTokensBySub']); return tokensToArray(store.getters['data/githubTokensBySub']);
}, },
giteeTokens() {
return tokensToArray(store.getters['data/giteeTokensBySub']);
},
gitlabTokens() { gitlabTokens() {
return tokensToArray(store.getters['data/gitlabTokensBySub']); return tokensToArray(store.getters['data/gitlabTokensBySub']);
}, },
@ -157,7 +165,8 @@ export default {
noToken() { noToken() {
return !this.googleDriveTokens.length return !this.googleDriveTokens.length
&& !this.dropboxTokens.length && !this.dropboxTokens.length
&& !this.githubTokens.length; && !this.githubTokens.length
&& !this.giteeTokens.length;
}, },
}, },
methods: { methods: {
@ -183,6 +192,12 @@ export default {
await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess); await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess);
} catch (e) { /* cancel */ } } catch (e) { /* cancel */ }
}, },
async addGiteeAccount() {
try {
await store.dispatch('modal/open', { type: 'giteeAccount' });
await giteeHelper.addAccount();
} catch (e) { /* cancel */ }
},
async addGitlabAccount() { async addGitlabAccount() {
try { try {
const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' }); const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' });

View File

@ -21,6 +21,10 @@
<icon-provider slot="icon" provider-id="githubWorkspace"></icon-provider> <icon-provider slot="icon" provider-id="githubWorkspace"></icon-provider>
<span>Add a <b>GitHub</b> workspace</span> <span>Add a <b>GitHub</b> workspace</span>
</menu-entry> </menu-entry>
<menu-entry @click.native="addGiteeWorkspace">
<icon-provider slot="icon" provider-id="giteeWorkspace"></icon-provider>
<span>Add a <b>Gitee</b> workspace</span>
</menu-entry>
<menu-entry @click.native="addGitlabWorkspace"> <menu-entry @click.native="addGitlabWorkspace">
<icon-provider slot="icon" provider-id="gitlabWorkspace"></icon-provider> <icon-provider slot="icon" provider-id="gitlabWorkspace"></icon-provider>
<span>Add a <b>GitLab</b> workspace</span> <span>Add a <b>GitLab</b> workspace</span>
@ -67,6 +71,13 @@ export default {
}); });
} catch (e) { /* Cancel */ } } catch (e) { /* Cancel */ }
}, },
async addGiteeWorkspace() {
try {
store.dispatch('modal/open', {
type: 'giteeWorkspace',
});
} catch (e) { /* Cancel */ }
},
async addGitlabWorkspace() { async addGitlabWorkspace() {
try { try {
const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' }); const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' });

View File

@ -2,9 +2,9 @@
<modal-inner class="modal__inner-1--about-modal" aria-label="About"> <modal-inner class="modal__inner-1--about-modal" aria-label="About">
<div class="modal__content"> <div class="modal__content">
<div class="logo-background"></div> <div class="logo-background"></div>
StackEdit on <a target="_blank" href="https://github.com/benweet/stackedit/">GitHub</a> StackEdit on <a target="_blank" href="https://github.com/mafgwo/stackedit/">GitHub</a>
<br> <br>
<a target="_blank" href="https://github.com/benweet/stackedit/issues">Issue tracker</a> <a target="_blank" href="https://github.com/benweet/stackedit/releases">Changelog</a> <a target="_blank" href="https://github.com/mafgwo/stackedit/issues">Issue tracker</a> <a target="_blank" href="https://github.com/mafgwo/stackedit/releases">Changelog</a>
<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>
@ -12,7 +12,7 @@
<br> <br>
StackEdit on <a target="_blank" href="https://twitter.com/stackedit/">Twitter</a> StackEdit on <a target="_blank" href="https://twitter.com/stackedit/">Twitter</a>
<hr> <hr>
<small>© 2013-2019 Dock5 Software Ltd.<br>v{{version}}</small> <small>© 2013-2022 Dock5 Software Ltd.<br>v{{version}}</small>
<h3>FAQ</h3> <h3>FAQ</h3>
<div class="faq" v-html="faq"></div> <div class="faq" v-html="faq"></div>
<div class="modal__info"> <div class="modal__info">

View File

@ -49,6 +49,10 @@
<icon-provider slot="icon" provider-id="github"></icon-provider> <icon-provider slot="icon" provider-id="github"></icon-provider>
<span>Add GitHub account</span> <span>Add GitHub account</span>
</menu-entry> </menu-entry>
<menu-entry @click.native="addGiteeAccount">
<icon-provider slot="icon" provider-id="gitee"></icon-provider>
<span>Add Gitee account</span>
</menu-entry>
<menu-entry @click.native="addGitlabAccount"> <menu-entry @click.native="addGitlabAccount">
<icon-provider slot="icon" provider-id="gitlab"></icon-provider> <icon-provider slot="icon" provider-id="gitlab"></icon-provider>
<span>Add GitLab account</span> <span>Add GitLab account</span>
@ -85,6 +89,7 @@ import utils from '../../services/utils';
import googleHelper from '../../services/providers/helpers/googleHelper'; import googleHelper from '../../services/providers/helpers/googleHelper';
import dropboxHelper from '../../services/providers/helpers/dropboxHelper'; import dropboxHelper from '../../services/providers/helpers/dropboxHelper';
import githubHelper from '../../services/providers/helpers/githubHelper'; import githubHelper from '../../services/providers/helpers/githubHelper';
import giteeHelper from '../../services/providers/helpers/giteeHelper';
import gitlabHelper from '../../services/providers/helpers/gitlabHelper'; import gitlabHelper from '../../services/providers/helpers/gitlabHelper';
import wordpressHelper from '../../services/providers/helpers/wordpressHelper'; import wordpressHelper from '../../services/providers/helpers/wordpressHelper';
import zendeskHelper from '../../services/providers/helpers/zendeskHelper'; import zendeskHelper from '../../services/providers/helpers/zendeskHelper';
@ -128,6 +133,13 @@ export default {
name: token.name, name: token.name,
scopes: token.scopes, scopes: token.scopes,
})), })),
...Object.values(store.getters['data/giteeTokensBySub']).map(token => ({
token,
providerId: 'gitee',
userId: token.sub,
name: token.name,
scopes: ['projects', 'pull_requests'],
})),
...Object.values(store.getters['data/gitlabTokensBySub']).map(token => ({ ...Object.values(store.getters['data/gitlabTokensBySub']).map(token => ({
token, token,
providerId: 'gitlab', providerId: 'gitlab',
@ -180,6 +192,12 @@ export default {
await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess); await githubHelper.addAccount(store.getters['data/localSettings'].githubRepoFullAccess);
} catch (e) { /* cancel */ } } catch (e) { /* cancel */ }
}, },
async addGiteeAccount() {
try {
await store.dispatch('modal/open', { type: 'giteeAccount' });
await giteeHelper.addAccount();
} catch (e) { /* cancel */ }
},
async addGitlabAccount() { async addGitlabAccount() {
try { try {
const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' }); const { serverUrl, applicationId } = await store.dispatch('modal/open', { type: 'gitlabAccount' });

View File

@ -72,7 +72,7 @@
<span class="token key atrule">katex</span><span class="token punctuation">:</span> <span class="token key atrule">katex</span><span class="token punctuation">:</span>
<span class="token key atrule">enabled</span><span class="token punctuation">:</span> <span class="token boolean important">true</span> <span class="token key atrule">enabled</span><span class="token punctuation">:</span> <span class="token boolean important">true</span>
</code></pre> </code></pre>
<p>For the full list of options, see <a href="https://github.com/benweet/stackedit/blob/master/src/data/presets.js" target="_blank">here</a>.</p> <p>For the full list of options, see <a href="https://github.com/mafgwo/stackedit/blob/master/src/data/presets.js" target="_blank">here</a>.</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -45,7 +45,7 @@ export default modalTemplate({
}, },
methods: { methods: {
resolve(evt) { resolve(evt) {
evt.preventDefault(); // Fixes https://github.com/benweet/stackedit/issues/1503 evt.preventDefault(); // Fixes https://github.com/mafgwo/stackedit/issues/1503
if (!this.url) { if (!this.url) {
this.setError('url'); this.setError('url');
} else { } else {

View File

@ -22,7 +22,7 @@ export default modalTemplate({
}), }),
methods: { methods: {
resolve(evt) { resolve(evt) {
evt.preventDefault(); // Fixes https://github.com/benweet/stackedit/issues/1503 evt.preventDefault(); // Fixes https://github.com/mafgwo/stackedit/issues/1503
if (!this.url) { if (!this.url) {
this.setError('url'); this.setError('url');
} else { } else {

View File

@ -0,0 +1,31 @@
<template>
<modal-inner aria-label="Link Gitee account">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="gitee"></icon-provider>
</div>
<p>Link your <b>Gitee</b> account to <b>StackEdit</b>.</p>
<div class="form-entry">
<div class="form-entry__checkbox">
<label>
<input type="checkbox" v-model="repoFullAccess"> Grant access to your private repositories
</label>
</div>
</div>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button button--resolve" @click="config.resolve()">Ok</button>
</div>
</modal-inner>
</template>
<script>
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
computedLocalSettings: {
repoFullAccess: 'giteeRepoFullAccess',
},
});
</script>

View File

@ -0,0 +1,70 @@
<template>
<modal-inner aria-label="Synchronize with Gitee">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="gitee"></icon-provider>
</div>
<p>Open a file from your <b>Gitee</b> repository and keep it synced.</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://gitee.com/owner/my-repo
</div>
</form-entry>
<form-entry label="File path" error="path">
<input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()">
<div class="form-entry__info">
<b>Example:</b> path/to/README.md
</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>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
<script>
import giteeProvider from '../../../services/providers/giteeProvider';
import modalTemplate from '../common/modalTemplate';
import utils from '../../../services/utils';
export default modalTemplate({
data: () => ({
branch: '',
path: '',
}),
computedLocalSettings: {
repoUrl: 'giteeRepoUrl',
},
methods: {
resolve() {
const parsedRepo = utils.parseGithubRepoUrl(this.repoUrl);
if (!parsedRepo) {
this.setError('repoUrl');
}
if (!this.path) {
this.setError('path');
}
if (parsedRepo && this.path) {
// Return new location
const location = giteeProvider.makeLocation(
this.config.token,
parsedRepo.owner,
parsedRepo.repo,
this.branch || 'master',
this.path,
);
this.config.resolve(location);
}
},
},
});
</script>

View File

@ -0,0 +1,86 @@
<template>
<modal-inner aria-label="Publish to Gitee">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="gitee"></icon-provider>
</div>
<p>Publish <b>{{currentFileName}}</b> to your <b>Gitee</b> repository.</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://gitee.com/owner/my-repo
</div>
</form-entry>
<form-entry label="File path" error="path">
<input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()">
<div class="form-entry__info">
<b>Example:</b> path/to/README.md<br>
If the file exists, it will be overwritten.
</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="Template">
<select slot="field" class="textfield" v-model="selectedTemplate" @keydown.enter="resolve()">
<option v-for="(template, id) in allTemplatesById" :key="id" :value="id">
{{ template.name }}
</option>
</select>
<div class="form-entry__actions">
<a href="javascript:void(0)" @click="configureTemplates">Configure templates</a>
</div>
</form-entry>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
<script>
import giteeProvider from '../../../services/providers/giteeProvider';
import modalTemplate from '../common/modalTemplate';
import utils from '../../../services/utils';
export default modalTemplate({
data: () => ({
branch: '',
path: '',
}),
computedLocalSettings: {
repoUrl: 'giteeRepoUrl',
selectedTemplate: 'giteePublishTemplate',
},
created() {
this.path = `${this.currentFileName}.md`;
},
methods: {
resolve() {
const parsedRepo = utils.parseGithubRepoUrl(this.repoUrl);
if (!parsedRepo) {
this.setError('repoUrl');
}
if (!this.path) {
this.setError('path');
}
if (parsedRepo && this.path) {
// Return new location
const location = giteeProvider.makeLocation(
this.config.token,
parsedRepo.owner,
parsedRepo.repo,
this.branch || 'master',
this.path,
);
location.templateId = this.selectedTemplate;
this.config.resolve(location);
}
},
},
});
</script>

View File

@ -0,0 +1,73 @@
<template>
<modal-inner aria-label="Synchronize with Gitee">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="gitee"></icon-provider>
</div>
<p>Save <b>{{currentFileName}}</b> to your <b>Gitee</b> repository and keep it synced.</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://gitee.com/owner/my-repo
</div>
</form-entry>
<form-entry label="File path" error="path">
<input slot="field" class="textfield" type="text" v-model.trim="path" @keydown.enter="resolve()">
<div class="form-entry__info">
<b>Example:</b> path/to/README.md<br>
If the file exists, it will be overwritten.
</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>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
<script>
import giteeProvider from '../../../services/providers/giteeProvider';
import modalTemplate from '../common/modalTemplate';
import utils from '../../../services/utils';
export default modalTemplate({
data: () => ({
branch: '',
path: '',
}),
computedLocalSettings: {
repoUrl: 'giteeRepoUrl',
},
created() {
this.path = `${this.currentFileName}.md`;
},
methods: {
resolve() {
const parsedRepo = utils.parseGithubRepoUrl(this.repoUrl);
if (!parsedRepo) {
this.setError('repoUrl');
}
if (!this.path) {
this.setError('path');
}
if (parsedRepo && this.path) {
const location = giteeProvider.makeLocation(
this.config.token,
parsedRepo.owner,
parsedRepo.repo,
this.branch || 'master',
this.path,
);
this.config.resolve(location);
}
},
},
});
</script>

View File

@ -0,0 +1,65 @@
<template>
<modal-inner aria-label="Synchronize with Gitee">
<div class="modal__content">
<div class="modal__image">
<icon-provider provider-id="gitee"></icon-provider>
</div>
<p>Create a workspace synced with a <b>Gitee</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://gitee.com/owner/my-repo
</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>
<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>
</div>
<div class="modal__button-bar">
<button class="button" @click="config.reject()">Cancel</button>
<button class="button button--resolve" @click="resolve()">Ok</button>
</div>
</modal-inner>
</template>
<script>
import utils from '../../../services/utils';
import modalTemplate from '../common/modalTemplate';
export default modalTemplate({
data: () => ({
branch: '',
path: '',
}),
computedLocalSettings: {
repoUrl: 'giteeWorkspaceRepoUrl',
},
methods: {
resolve() {
const parsedRepo = utils.parseGithubRepoUrl(this.repoUrl);
if (!parsedRepo) {
this.setError('repoUrl');
} else {
const path = this.path && this.path.replace(/^\//, '');
const url = utils.addQueryParams('app', {
...parsedRepo,
providerId: 'giteeWorkspace',
branch: this.branch || 'master',
path: path || undefined,
}, true);
this.config.resolve();
window.open(url);
}
},
},
});
</script>

View File

@ -19,6 +19,8 @@ export default () => ({
githubPublishTemplate: 'jekyllSite', githubPublishTemplate: 'jekyllSite',
gistIsPublic: false, gistIsPublic: false,
gistPublishTemplate: 'plainText', gistPublishTemplate: 'plainText',
giteeRepoUrl: '',
giteeWorkspaceRepoUrl: '',
gitlabServerUrl: '', gitlabServerUrl: '',
gitlabApplicationId: '', gitlabApplicationId: '',
gitlabProjectUrl: '', gitlabProjectUrl: '',

View File

@ -79,16 +79,16 @@ turndown:
# GitHub/GitLab commit messages # GitHub/GitLab commit messages
git: git:
createFileMessage: '{{path}} created from https://stackedit.io/' createFileMessage: '{{path}} created from https://edit.qicoder.com/'
updateFileMessage: '{{path}} updated from https://stackedit.io/' updateFileMessage: '{{path}} updated from https://edit.qicoder.com/'
deleteFileMessage: '{{path}} deleted from https://stackedit.io/' deleteFileMessage: '{{path}} deleted from https://edit.qicoder.com/'
# Default content for new files # Default content for new files
newFileContent: | newFileContent: |
> Written with [StackEdit](https://stackedit.io/). > Written with [StackEdit](https://edit.qicoder.com/).
# Default properties for new files # Default properties for new files
newFileProperties: | newFileProperties: |

View File

@ -6,4 +6,4 @@ We recommend syncing your workspace to make sure files won't be lost in case you
**Can StackEdit access my data without telling me?** **Can StackEdit access my data without telling me?**
StackEdit is a browser-based application. The access tokens issued by Google, Dropbox, GitHub... are stored in your browser and are not sent to any kind of backend or 3^rd^ party so your data won't be accessed by anyone. StackEdit is a browser-based application. The access tokens issued by Google, Dropbox, GitHub, Gitee... are stored in your browser and are not sent to any kind of backend or 3^rd^ party so your data won't be accessed by anyone.

View File

@ -172,6 +172,11 @@ export default [
'GitHub workspace creator', 'GitHub workspace creator',
'Use the workspace menu to create a GitHub workspace.', 'Use the workspace menu to create a GitHub workspace.',
), ),
new Feature(
'addGiteeWorkspace',
'GitHub workspace creator',
'Use the workspace menu to create a Gitee workspace.',
),
new Feature( new Feature(
'addGitlabWorkspace', 'addGitlabWorkspace',
'GitLab workspace creator', 'GitLab workspace creator',

View File

@ -5,7 +5,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{files.0.name}}</title> <title>{{files.0.name}}</title>
<link rel="stylesheet" href="https://stackedit.io/style.css" /> <link rel="stylesheet" href="https://edit.qicoder.com/style.css" />
</head> </head>
{{#if pdf}} {{#if pdf}}

View File

@ -5,7 +5,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{files.0.name}}</title> <title>{{files.0.name}}</title>
<link rel="stylesheet" href="https://stackedit.io/style.css" /> <link rel="stylesheet" href="https://edit.qicoder.com/style.css" />
</head> </head>
{{#if pdf}} {{#if pdf}}

View File

@ -26,6 +26,8 @@ export default {
return 'blogger'; return 'blogger';
case 'couchdbWorkspace': case 'couchdbWorkspace':
return 'couchdb'; return 'couchdb';
case 'giteeWorkspace':
return 'gitee';
default: default:
return this.providerId; return this.providerId;
} }
@ -86,4 +88,8 @@ export default {
.icon-provider--couchdb { .icon-provider--couchdb {
background-image: url(../assets/iconCouchdb.svg); background-image: url(../assets/iconCouchdb.svg);
} }
.icon-provider--gitee {
background-image: url(../assets/iconGitee.svg);
}
</style> </style>

View File

@ -69,7 +69,7 @@ export default {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
script.onload = resolve; script.onload = resolve;
script.onerror = reject; script.onerror = reject;
script.src = `https://apis.google.com/js/api.js?${Date.now()}`; script.src = `https://www.gstatic.cn/charts/loader.js?${Date.now()}`;
try { try {
document.head.appendChild(script); // This can fail with bad network document.head.appendChild(script); // This can fail with bad network
timeout = setTimeout(reject, networkTimeout); timeout = setTimeout(reject, networkTimeout);

View File

@ -0,0 +1,165 @@
import store from '../../store';
import giteeHelper from './helpers/giteeHelper';
import Provider from './common/Provider';
import utils from '../utils';
import workspaceSvc from '../workspaceSvc';
import userSvc from '../userSvc';
const savedSha = {};
export default new Provider({
id: 'gitee',
name: 'Gitee',
getToken({ sub }) {
return store.getters['data/giteeTokensBySub'][sub];
},
getLocationUrl({
owner,
repo,
branch,
path,
}) {
return `https://gitee.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`;
},
getLocationDescription({ path }) {
return path;
},
async downloadContent(token, syncLocation) {
const { sha, data } = await giteeHelper.downloadFile({
...syncLocation,
token,
});
savedSha[syncLocation.id] = sha;
return Provider.parseContent(data, `${syncLocation.fileId}/content`);
},
async uploadContent(token, content, syncLocation) {
if (!savedSha[syncLocation.id]) {
try {
// Get the last sha
await this.downloadContent(token, syncLocation);
} catch (e) {
// Ignore error
}
}
const sha = savedSha[syncLocation.id];
delete savedSha[syncLocation.id];
await giteeHelper.uploadFile({
...syncLocation,
token,
content: Provider.serializeContent(content),
sha,
});
return syncLocation;
},
async publish(token, html, metadata, publishLocation) {
try {
// Get the last sha
await this.downloadContent(token, publishLocation);
} catch (e) {
// Ignore error
}
const sha = savedSha[publishLocation.id];
delete savedSha[publishLocation.id];
await giteeHelper.uploadFile({
...publishLocation,
token,
content: html,
sha,
});
return publishLocation;
},
async openFile(token, syncLocation) {
// Check if the file exists and open it
if (!Provider.openFileWithLocation(syncLocation)) {
// Download content from Gitee
let content;
try {
content = await this.downloadContent(token, syncLocation);
} catch (e) {
store.dispatch('notification/error', `Could not open file ${syncLocation.path}.`);
return;
}
// Create the file
let name = syncLocation.path;
const slashPos = name.lastIndexOf('/');
if (slashPos > -1 && slashPos < name.length - 1) {
name = name.slice(slashPos + 1);
}
const dotPos = name.lastIndexOf('.');
if (dotPos > 0 && slashPos < name.length) {
name = name.slice(0, dotPos);
}
const item = await workspaceSvc.createFile({
name,
parentId: store.getters['file/current'].parentId,
text: content.text,
properties: content.properties,
discussions: content.discussions,
comments: content.comments,
}, true);
store.commit('file/setCurrentId', item.id);
workspaceSvc.addSyncLocation({
...syncLocation,
fileId: item.id,
});
store.dispatch('notification/info', `${store.getters['file/current'].name} was imported from Gitee.`);
}
},
makeLocation(token, owner, repo, branch, path) {
return {
providerId: this.id,
sub: token.sub,
owner,
repo,
branch,
path,
};
},
async listFileRevisions({ token, syncLocation }) {
const entries = await giteeHelper.getCommits({
...syncLocation,
token,
});
return entries.map(({
author,
committer,
commit,
sha,
}) => {
let user;
if (author && author.login) {
user = author;
} else if (committer && committer.login) {
user = committer;
}
const sub = `${giteeHelper.subPrefix}:${user.login}`;
userSvc.addUserInfo({ id: sub, name: user.login, imageUrl: user.avatar_url });
const date = (commit.author && commit.author.date)
|| (commit.committer && commit.committer.date);
return {
id: sha,
sub,
created: date ? new Date(date).getTime() : 1,
};
});
},
async loadFileRevision() {
// Revision are already loaded
return false;
},
async getFileRevisionContent({
token,
contentId,
syncLocation,
revisionId,
}) {
const { data } = await giteeHelper.downloadFile({
...syncLocation,
token,
branch: revisionId,
});
return Provider.parseContent(data, contentId);
},
});

View File

@ -0,0 +1,281 @@
import store from '../../store';
import giteeHelper from './helpers/giteeHelper';
import Provider from './common/Provider';
import utils from '../utils';
import userSvc from '../userSvc';
import gitWorkspaceSvc from '../gitWorkspaceSvc';
import badgeSvc from '../badgeSvc';
const getAbsolutePath = ({ id }) =>
`${store.getters['workspace/currentWorkspace'].path || ''}${id}`;
export default new Provider({
id: 'giteeWorkspace',
name: 'Gitee',
getToken() {
return store.getters['workspace/syncToken'];
},
getWorkspaceParams({
owner,
repo,
branch,
path,
}) {
return {
providerId: this.id,
owner,
repo,
branch,
path,
};
},
getWorkspaceLocationUrl({
owner,
repo,
branch,
path,
}) {
return `https://gitee.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(path)}`;
},
getSyncDataUrl({ id }) {
const { owner, repo, branch } = store.getters['workspace/currentWorkspace'];
return `https://gitee.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/tree/${encodeURIComponent(branch)}/${utils.encodeUrlPath(getAbsolutePath({ id }))}`;
},
getSyncDataDescription({ id }) {
return getAbsolutePath({ id });
},
async initWorkspace() {
const { owner, repo, branch } = utils.queryParams;
const workspaceParams = this.getWorkspaceParams({ owner, repo, branch });
if (!branch) {
workspaceParams.branch = 'master';
}
// Extract path param
const path = (utils.queryParams.path || '')
.trim()
.replace(/^\/*/, '') // Remove leading `/`
.replace(/\/*$/, '/'); // Add trailing `/`
if (path !== '/') {
workspaceParams.path = path;
}
const workspaceId = utils.makeWorkspaceId(workspaceParams);
const workspace = store.getters['workspace/workspacesById'][workspaceId];
// See if we already have a token
let token;
if (workspace) {
// Token sub is in the workspace
token = store.getters['data/giteeTokensBySub'][workspace.sub];
}
if (!token) {
await store.dispatch('modal/open', { type: 'giteeAccount' });
token = await giteeHelper.addAccount();
}
if (!workspace) {
const pathEntries = (path || '').split('/');
const name = pathEntries[pathEntries.length - 2] || repo; // path ends with `/`
store.dispatch('workspace/patchWorkspacesById', {
[workspaceId]: {
...workspaceParams,
id: workspaceId,
sub: token.sub,
name,
},
});
}
badgeSvc.addBadge('addGithubWorkspace');
return store.getters['workspace/workspacesById'][workspaceId];
},
getChanges() {
return giteeHelper.getTree({
...store.getters['workspace/currentWorkspace'],
token: this.getToken(),
});
},
prepareChanges(tree) {
return gitWorkspaceSvc.makeChanges(tree);
},
async saveWorkspaceItem({ item }) {
const syncData = {
id: store.getters.gitPathsByItemId[item.id],
type: item.type,
hash: item.hash,
};
// Files and folders are not in git, only contents
if (item.type === 'file' || item.type === 'folder') {
return { syncData };
}
// locations are stored as paths, so we upload an empty file
const syncToken = store.getters['workspace/syncToken'];
await giteeHelper.uploadFile({
...store.getters['workspace/currentWorkspace'],
token: syncToken,
path: getAbsolutePath(syncData),
content: '',
sha: gitWorkspaceSvc.shaByPath[syncData.id],
});
// Return sync data to save
return { syncData };
},
async removeWorkspaceItem({ syncData }) {
if (gitWorkspaceSvc.shaByPath[syncData.id]) {
const syncToken = store.getters['workspace/syncToken'];
await giteeHelper.removeFile({
...store.getters['workspace/currentWorkspace'],
token: syncToken,
path: getAbsolutePath(syncData),
sha: gitWorkspaceSvc.shaByPath[syncData.id],
});
}
},
async downloadWorkspaceContent({
token,
contentId,
contentSyncData,
fileSyncData,
}) {
const { sha, data } = await giteeHelper.downloadFile({
...store.getters['workspace/currentWorkspace'],
token,
path: getAbsolutePath(fileSyncData),
});
gitWorkspaceSvc.shaByPath[fileSyncData.id] = sha;
const content = Provider.parseContent(data, contentId);
return {
content,
contentSyncData: {
...contentSyncData,
hash: content.hash,
sha,
},
};
},
async downloadWorkspaceData({ token, syncData }) {
if (!syncData) {
return {};
}
const { sha, data } = await giteeHelper.downloadFile({
...store.getters['workspace/currentWorkspace'],
token,
path: getAbsolutePath(syncData),
});
gitWorkspaceSvc.shaByPath[syncData.id] = sha;
const item = JSON.parse(data);
return {
item,
syncData: {
...syncData,
hash: item.hash,
sha,
},
};
},
async uploadWorkspaceContent({ token, content, file }) {
const path = store.getters.gitPathsByItemId[file.id];
const absolutePath = `${store.getters['workspace/currentWorkspace'].path || ''}${path}`;
const res = await giteeHelper.uploadFile({
...store.getters['workspace/currentWorkspace'],
token,
path: absolutePath,
content: Provider.serializeContent(content),
sha: gitWorkspaceSvc.shaByPath[path],
});
// Return new sync data
return {
contentSyncData: {
id: store.getters.gitPathsByItemId[content.id],
type: content.type,
hash: content.hash,
sha: res.content.sha,
},
fileSyncData: {
id: path,
type: 'file',
hash: file.hash,
},
};
},
async uploadWorkspaceData({ token, item }) {
const path = store.getters.gitPathsByItemId[item.id];
const syncData = {
id: path,
type: item.type,
hash: item.hash,
};
const res = await giteeHelper.uploadFile({
...store.getters['workspace/currentWorkspace'],
token,
path: getAbsolutePath(syncData),
content: JSON.stringify(item),
sha: gitWorkspaceSvc.shaByPath[path],
});
return {
syncData: {
...syncData,
sha: res.content.sha,
},
};
},
async listFileRevisions({ token, fileSyncDataId }) {
const { owner, repo, branch } = store.getters['workspace/currentWorkspace'];
const entries = await giteeHelper.getCommits({
token,
owner,
repo,
sha: branch,
path: getAbsolutePath({ id: fileSyncDataId }),
});
return entries.map(({
author,
committer,
commit,
sha,
}) => {
let user;
if (author && author.login) {
user = author;
} else if (committer && committer.login) {
user = committer;
}
const sub = `${giteeHelper.subPrefix}:${user.login}`;
userSvc.addUserInfo({ id: sub, name: user.login, imageUrl: user.avatar_url });
const date = (commit.author && commit.author.date)
|| (commit.committer && commit.committer.date)
|| 1;
return {
id: sha,
sub,
created: new Date(date).getTime(),
};
});
},
async loadFileRevision() {
// Revisions are already loaded
return false;
},
async getFileRevisionContent({
token,
contentId,
fileSyncDataId,
revisionId,
}) {
const { data } = await giteeHelper.downloadFile({
...store.getters['workspace/currentWorkspace'],
token,
branch: revisionId,
path: getAbsolutePath({ id: fileSyncDataId }),
});
return Provider.parseContent(data, contentId);
},
});

View File

@ -0,0 +1,315 @@
import utils from '../../utils';
import networkSvc from '../../networkSvc';
import store from '../../../store';
import userSvc from '../../userSvc';
import badgeSvc from '../../badgeSvc';
const request = (token, options) => networkSvc.request({
...options,
headers: {
...options.headers || {},
Authorization: `token ${token.accessToken}`,
},
params: {
...options.params || {},
t: Date.now(), // Prevent from caching
},
});
const repoRequest = (token, owner, repo, options) => request(token, {
...options,
url: `https://gitee.com/api/v5/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${options.url}`,
})
.then(res => res.body);
const getCommitMessage = (name, path) => {
const message = store.getters['data/computedSettings'].git[name];
return message.replace(/{{path}}/g, path);
};
/**
* Getting a user from its userId is not feasible with API v3.
* Using an undocumented endpoint...
*/
const subPrefix = 'ge';
userSvc.setInfoResolver('gitee', subPrefix, async (sub) => {
try {
const user = (await networkSvc.request({
url: `https://gitee.com/api/v5/users/${sub}`,
params: {
t: Date.now(), // Prevent from caching
},
})).body;
return {
id: `${subPrefix}:${user.login}`,
name: user.login,
imageUrl: user.avatar_url || '',
};
} catch (err) {
if (err.status !== 404) {
throw new Error('RETRY');
}
throw err;
}
});
export default {
subPrefix,
/**
* https://developer.gitee.com/apps/building-oauth-apps/authorization-options-for-oauth-apps/
*/
async startOauth2(scopes, sub = null, silent = false) {
const clientId = store.getters['data/serverConf'].giteeClientId;
// Get an OAuth2 code
const { code } = await networkSvc.startOauth2(
'https://gitee.com/oauth/authorize',
{
client_id: clientId,
scope: 'projects pull_requests',
response_type: 'code',
},
silent,
);
// Exchange code with token
const accessToken = (await networkSvc.request({
method: 'GET',
url: 'oauth2/giteeToken',
params: {
clientId,
code,
},
})).body;
// Call the user info endpoint
const user = (await networkSvc.request({
method: 'GET',
url: 'https://gitee.com/api/v5/user',
params: {
access_token: accessToken,
},
})).body;
userSvc.addUserInfo({
id: `${subPrefix}:${user.login}`,
name: user.login,
imageUrl: user.avatar_url || '',
});
// Check the returned sub consistency
if (sub && `${user.login}` !== sub) {
throw new Error('Gitee account ID not expected.');
}
// Build token object including scopes and sub
const token = {
scopes,
accessToken,
name: user.login,
sub: `${user.login}`,
};
// Add token to gitee tokens
store.dispatch('data/addGiteeToken', token);
return token;
},
async addAccount() {
const token = await this.startOauth2();
badgeSvc.addBadge('addGiteeAccount');
return token;
},
/**
* https://developer.gitee.com/v3/repos/commits/#get-a-single-commit
* https://developer.gitee.com/v3/git/trees/#get-a-tree
*/
async getTree({
token,
owner,
repo,
branch,
}) {
const { commit } = await repoRequest(token, owner, repo, {
url: `commits/${encodeURIComponent(branch)}`,
});
const { tree, truncated } = await repoRequest(token, owner, repo, {
url: `git/trees/${encodeURIComponent(commit.tree.sha)}?recursive=1`,
});
if (truncated) {
throw new Error('Git tree too big. Please remove some files in the repository.');
}
return tree;
},
/**
* https://developer.gitee.com/v3/repos/commits/#list-commits-on-a-repository
*/
async getCommits({
token,
owner,
repo,
sha,
path,
}) {
return repoRequest(token, owner, repo, {
url: 'commits',
params: { sha, path },
});
},
/**
* https://developer.gitee.com/v3/repos/contents/#create-a-file
* https://developer.gitee.com/v3/repos/contents/#update-a-file
*/
async uploadFile({
token,
owner,
repo,
branch,
path,
content,
sha,
}) {
return repoRequest(token, owner, repo, {
method: sha ? 'PUT' : 'POST',
url: `contents/${encodeURIComponent(path)}`,
body: {
message: getCommitMessage(sha ? 'updateFileMessage' : 'createFileMessage', path),
content: utils.encodeBase64(content),
sha,
branch,
},
});
},
/**
* https://developer.gitee.com/v3/repos/contents/#delete-a-file
*/
async 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,
},
});
},
/**
* https://developer.gitee.com/v3/repos/contents/#get-contents
*/
async downloadFile({
token,
owner,
repo,
branch,
path,
}) {
const { sha, content } = await repoRequest(token, owner, repo, {
url: `contents/${encodeURIComponent(path)}`,
params: { ref: branch },
});
return {
sha,
data: utils.decodeBase64(content),
};
},
/**
* https://developer.gitee.com/v3/gists/#create-a-gist
* https://developer.gitee.com/v3/gists/#edit-a-gist
*/
async uploadGist({
token,
description,
filename,
content,
isPublic,
gistId,
}) {
const { body } = await request(token, gistId ? {
method: 'PATCH',
url: `https://gitee.com/api/v5/gists/${gistId}`,
body: {
description,
files: {
[filename]: {
content,
},
},
},
} : {
method: 'POST',
url: 'https://gitee.com/api/v5/gists',
body: {
description,
files: {
[filename]: {
content,
},
},
public: isPublic,
},
});
return body;
},
/**
* https://developer.gitee.com/v3/gists/#get-a-single-gist
*/
async downloadGist({
token,
gistId,
filename,
}) {
const result = (await request(token, {
url: `https://gitee.com/api/v5/gists/${gistId}`,
})).body.files[filename];
if (!result) {
throw new Error('Gist file not found.');
}
return result.content;
},
/**
* https://developer.gitee.com/v3/gists/#list-gist-commits
*/
async getGistCommits({
token,
gistId,
}) {
const { body } = await request(token, {
url: `https://gitee.com/api/v5/gists/${gistId}/commits`,
});
return body;
},
/**
* https://developer.gitee.com/v3/gists/#get-a-specific-revision-of-a-gist
*/
async downloadGistRevision({
token,
gistId,
filename,
sha,
}) {
const result = (await request(token, {
url: `https://gitee.com/api/v5/gists/${gistId}/${sha}`,
})).body.files[filename];
if (!result) {
throw new Error('Gist file not found.');
}
return result.content;
},
};

View File

@ -89,8 +89,8 @@ export default {
const user = (await networkSvc.request({ const user = (await networkSvc.request({
method: 'GET', method: 'GET',
url: 'https://api.github.com/user', url: 'https://api.github.com/user',
params: { headers: {
access_token: accessToken, Authorization: `Bearer ${accessToken}`,
}, },
})).body; })).body;
userSvc.addUserInfo({ userSvc.addUserInfo({

View File

@ -625,7 +625,7 @@ export default {
async openPicker(token, type = 'doc') { async openPicker(token, type = 'doc') {
const scopes = type === 'img' ? photosScopes : getDriveScopes(token); const scopes = type === 'img' ? photosScopes : getDriveScopes(token);
if (!window.google) { if (!window.google) {
await networkSvc.loadScript('https://apis.google.com/js/api.js'); await networkSvc.loadScript('https://www.gstatic.cn/charts/loader.js');
await new Promise((resolve, reject) => window.gapi.load('picker', { await new Promise((resolve, reject) => window.gapi.load('picker', {
callback: resolve, callback: resolve,
onerror: reject, onerror: reject,

View File

@ -7,6 +7,7 @@ import providerRegistry from './providers/common/providerRegistry';
import googleDriveAppDataProvider from './providers/googleDriveAppDataProvider'; import googleDriveAppDataProvider from './providers/googleDriveAppDataProvider';
import './providers/couchdbWorkspaceProvider'; import './providers/couchdbWorkspaceProvider';
import './providers/githubWorkspaceProvider'; import './providers/githubWorkspaceProvider';
import './providers/giteeWorkspaceProvider';
import './providers/gitlabWorkspaceProvider'; import './providers/gitlabWorkspaceProvider';
import './providers/googleDriveWorkspaceProvider'; import './providers/googleDriveWorkspaceProvider';
import tempFileSvc from './tempFileSvc'; import tempFileSvc from './tempFileSvc';

View File

@ -211,6 +211,7 @@ export default {
couchdbTokensBySub: (state, { tokensByType }) => tokensByType.couchdb || {}, couchdbTokensBySub: (state, { tokensByType }) => tokensByType.couchdb || {},
dropboxTokensBySub: (state, { tokensByType }) => tokensByType.dropbox || {}, dropboxTokensBySub: (state, { tokensByType }) => tokensByType.dropbox || {},
githubTokensBySub: (state, { tokensByType }) => tokensByType.github || {}, githubTokensBySub: (state, { tokensByType }) => tokensByType.github || {},
giteeTokensBySub: (state, { tokensByType }) => tokensByType.gitee || {},
gitlabTokensBySub: (state, { tokensByType }) => tokensByType.gitlab || {}, gitlabTokensBySub: (state, { tokensByType }) => tokensByType.gitlab || {},
wordpressTokensBySub: (state, { tokensByType }) => tokensByType.wordpress || {}, wordpressTokensBySub: (state, { tokensByType }) => tokensByType.wordpress || {},
zendeskTokensBySub: (state, { tokensByType }) => tokensByType.zendesk || {}, zendeskTokensBySub: (state, { tokensByType }) => tokensByType.zendesk || {},
@ -303,6 +304,7 @@ export default {
addCouchdbToken: tokenAdder('couchdb'), addCouchdbToken: tokenAdder('couchdb'),
addDropboxToken: tokenAdder('dropbox'), addDropboxToken: tokenAdder('dropbox'),
addGithubToken: tokenAdder('github'), addGithubToken: tokenAdder('github'),
addGiteeToken: tokenAdder('gitee'),
addGitlabToken: tokenAdder('gitlab'), addGitlabToken: tokenAdder('gitlab'),
addWordpressToken: tokenAdder('wordpress'), addWordpressToken: tokenAdder('wordpress'),
addZendeskToken: tokenAdder('zendesk'), addZendeskToken: tokenAdder('zendesk'),

View File

@ -44,9 +44,11 @@ export default {
workspacesById[currentWorkspaceId] || mainWorkspace, workspacesById[currentWorkspaceId] || mainWorkspace,
currentWorkspaceIsGit: (state, { currentWorkspace }) => currentWorkspaceIsGit: (state, { currentWorkspace }) =>
currentWorkspace.providerId === 'githubWorkspace' currentWorkspace.providerId === 'githubWorkspace'
|| currentWorkspace.providerId === 'giteeWorkspace'
|| currentWorkspace.providerId === 'gitlabWorkspace', || currentWorkspace.providerId === 'gitlabWorkspace',
currentWorkspaceHasUniquePaths: (state, { currentWorkspace }) => currentWorkspaceHasUniquePaths: (state, { currentWorkspace }) =>
currentWorkspace.providerId === 'githubWorkspace' currentWorkspace.providerId === 'githubWorkspace'
|| currentWorkspace.providerId === 'giteeWorkspace'
|| currentWorkspace.providerId === 'gitlabWorkspace', || currentWorkspace.providerId === 'gitlabWorkspace',
lastSyncActivityKey: (state, { currentWorkspace }) => `${currentWorkspace.id}/lastSyncActivity`, lastSyncActivityKey: (state, { currentWorkspace }) => `${currentWorkspace.id}/lastSyncActivity`,
lastFocusKey: (state, { currentWorkspace }) => `${currentWorkspace.id}/lastWindowFocus`, lastFocusKey: (state, { currentWorkspace }) => `${currentWorkspace.id}/lastWindowFocus`,
@ -63,6 +65,8 @@ export default {
return rootGetters['data/googleTokensBySub'][currentWorkspace.sub]; return rootGetters['data/googleTokensBySub'][currentWorkspace.sub];
case 'githubWorkspace': case 'githubWorkspace':
return rootGetters['data/githubTokensBySub'][currentWorkspace.sub]; return rootGetters['data/githubTokensBySub'][currentWorkspace.sub];
case 'giteeWorkspace':
return rootGetters['data/giteeTokensBySub'][currentWorkspace.sub];
case 'gitlabWorkspace': case 'gitlabWorkspace':
return rootGetters['data/gitlabTokensBySub'][currentWorkspace.sub]; return rootGetters['data/gitlabTokensBySub'][currentWorkspace.sub];
case 'couchdbWorkspace': case 'couchdbWorkspace':
@ -78,6 +82,8 @@ export default {
return 'google'; return 'google';
case 'githubWorkspace': case 'githubWorkspace':
return 'github'; return 'github';
case 'giteeWorkspace':
return 'gitee';
case 'gitlabWorkspace': case 'gitlabWorkspace':
return 'gitlab'; return 'gitlab';
} }

View File

@ -3,7 +3,7 @@
<head> <head>
<title>StackEdit In-browser Markdown editor</title> <title>StackEdit In-browser Markdown editor</title>
<link rel="canonical" href="https://stackedit.io/"> <link rel="canonical" href="https://edit.qicoder.com/">
<link rel="icon" href="static/landing/favicon.ico" type="image/x-icon"> <link rel="icon" href="static/landing/favicon.ico" type="image/x-icon">
<link rel="shortcut icon" href="static/landing/favicon.ico" type="image/x-icon"> <link rel="shortcut icon" href="static/landing/favicon.ico" type="image/x-icon">
<meta charset="UTF-8"> <meta charset="UTF-8">
@ -13,7 +13,7 @@
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"> <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
<meta name="msvalidate.01" content="5E47EE6F67B069C17E3CDD418351A612"> <meta name="msvalidate.01" content="5E47EE6F67B069C17E3CDD418351A612">
<meta name="google-site-verification" content="iWDn0T2r2_bDQWp_nW23MGePbO9X0M8wQSzbOU70pFQ" /> <meta name="google-site-verification" content="iWDn0T2r2_bDQWp_nW23MGePbO9X0M8wQSzbOU70pFQ" />
<link rel="stylesheet" href="https://stackedit.io/style.css"> <link rel="stylesheet" href="https://edit.qicoder.com/style.css">
<style> <style>
body { body {
background-color: #fbfbfb; background-color: #fbfbfb;

View File

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url> <url>
<loc>https://stackedit.io/</loc> <loc>https://edit.qicoder.com/</loc>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>1.0</priority> <priority>1.0</priority>
</url> </url>
<url> <url>
<loc>https://stackedit.io/app</loc> <loc>https://edit.qicoder.com/app</loc>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>1.0</priority> <priority>1.0</priority>
</url> </url>
@ -16,7 +16,7 @@
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://stackedit.io/privacy_policy.html</loc> <loc>https://edit.qicoder.com/privacy_policy.html</loc>
<changefreq>monthly</changefreq> <changefreq>monthly</changefreq>
<priority>0.6</priority> <priority>0.6</priority>
</url> </url>