Repository: webclipper/web-clipper
Branch: master
Commit: c8be915332f2
Files: 346
Total size: 707.6 KB
Directory structure:
gitextract_obtgc54t/
├── .dockerignore
├── .eslintignore
├── .eslintrc.js
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report_en-US.md
│ │ ├── bug_report_zh-CN.md
│ │ ├── feature_request_en-US.md
│ │ └── feature_request_zh-CN.md
│ └── workflows/
│ └── ci.yml
├── .gitignore
├── .prettierrc
├── .yarnrc
├── LICENSE
├── README.md
├── bin/
│ ├── index.js
│ ├── main.ts
│ └── scripts/
│ ├── format.ts
│ └── index.ts
├── chrome/
│ ├── html/
│ │ └── error.html
│ └── js/
│ └── icon.js
├── config.json
├── global.d.ts
├── package.json
├── script/
│ ├── build.js
│ ├── release.ts
│ └── utils/
│ └── pack.ts
├── src/
│ ├── __test__/
│ │ └── utils.ts
│ ├── actions/
│ │ ├── account.ts
│ │ ├── clipper.ts
│ │ └── userPreference.ts
│ ├── common/
│ │ ├── backend/
│ │ │ ├── clients/
│ │ │ │ ├── github/
│ │ │ │ │ ├── client.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── joplin/
│ │ │ │ │ ├── LegacyJoplinClient.ts
│ │ │ │ │ ├── basic.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── leanote/
│ │ │ │ │ ├── client.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ └── interface.ts
│ │ │ │ └── siyuan/
│ │ │ │ ├── client.ts
│ │ │ │ └── types.ts
│ │ │ ├── imageHosting/
│ │ │ │ ├── baklib/
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── service.ts
│ │ │ │ ├── github/
│ │ │ │ │ ├── form.tsx
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── service.ts
│ │ │ │ │ └── type.ts
│ │ │ │ ├── imgur/
│ │ │ │ │ ├── form.tsx
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── service.ts
│ │ │ │ ├── interface.ts
│ │ │ │ ├── joplin/
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── service.ts
│ │ │ │ ├── leanote/
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── service.ts
│ │ │ │ ├── piclist/
│ │ │ │ │ ├── form.tsx
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── service.ts
│ │ │ │ ├── qcloud/
│ │ │ │ │ ├── form.tsx
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── service.ts
│ │ │ │ ├── siyuan/
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── service.ts
│ │ │ │ ├── sm.ms/
│ │ │ │ │ ├── form.tsx
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── service.ts
│ │ │ │ ├── wiznote/
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── service.ts
│ │ │ │ └── yuque_oauth/
│ │ │ │ ├── index.ts
│ │ │ │ └── service.ts
│ │ │ ├── index.ts
│ │ │ ├── interface.ts
│ │ │ └── services/
│ │ │ ├── baklib/
│ │ │ │ ├── complete.tsx
│ │ │ │ ├── form.tsx
│ │ │ │ ├── headerForm.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── interface.ts
│ │ │ │ └── service.ts
│ │ │ ├── bear/
│ │ │ │ ├── form.tsx
│ │ │ │ ├── index.ts
│ │ │ │ └── service.ts
│ │ │ ├── buildin/
│ │ │ │ ├── index.ts
│ │ │ │ ├── service.ts
│ │ │ │ └── type.ts
│ │ │ ├── confluence/
│ │ │ │ ├── form.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── interface.ts
│ │ │ │ └── service.ts
│ │ │ ├── dida365/
│ │ │ │ ├── headerForm.tsx
│ │ │ │ ├── index.ts
│ │ │ │ └── service.ts
│ │ │ ├── flomo/
│ │ │ │ ├── index.ts
│ │ │ │ └── service.ts
│ │ │ ├── flowus/
│ │ │ │ ├── index.ts
│ │ │ │ ├── service.ts
│ │ │ │ └── type.ts
│ │ │ ├── github/
│ │ │ │ ├── form.tsx
│ │ │ │ ├── headerForm.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── interface.ts
│ │ │ │ └── service.ts
│ │ │ ├── github_repository/
│ │ │ │ ├── form.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── interface.ts
│ │ │ │ └── service.ts
│ │ │ ├── interface.ts
│ │ │ ├── joplin/
│ │ │ │ ├── form.tsx
│ │ │ │ ├── headerForm.tsx
│ │ │ │ ├── index.ts
│ │ │ │ └── service.ts
│ │ │ ├── leanote/
│ │ │ │ ├── form.tsx
│ │ │ │ ├── index.ts
│ │ │ │ └── service.ts
│ │ │ ├── memos/
│ │ │ │ ├── form.tsx
│ │ │ │ ├── headerForm.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── interface.ts
│ │ │ │ └── service.ts
│ │ │ ├── notion/
│ │ │ │ ├── index.ts
│ │ │ │ ├── service.ts
│ │ │ │ └── types.ts
│ │ │ ├── obsidian/
│ │ │ │ ├── form.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── interface.ts
│ │ │ │ └── service.ts
│ │ │ ├── onenote_oauth/
│ │ │ │ ├── form.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── interface.ts
│ │ │ │ └── service.ts
│ │ │ ├── server_chan/
│ │ │ │ ├── form.tsx
│ │ │ │ ├── index.ts
│ │ │ │ └── service.ts
│ │ │ ├── siyuan/
│ │ │ │ ├── form.tsx
│ │ │ │ ├── index.ts
│ │ │ │ └── service.ts
│ │ │ ├── ticktick/
│ │ │ │ ├── headerForm.tsx
│ │ │ │ ├── index.ts
│ │ │ │ └── service.ts
│ │ │ ├── ulysses/
│ │ │ │ ├── form.tsx
│ │ │ │ ├── index.ts
│ │ │ │ └── service.ts
│ │ │ ├── webdav/
│ │ │ │ ├── form.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── interface.ts
│ │ │ │ └── service.ts
│ │ │ ├── wiznote/
│ │ │ │ ├── form.tsx
│ │ │ │ ├── headerForm.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── interface.ts
│ │ │ │ └── service.ts
│ │ │ ├── wolai/
│ │ │ │ ├── index.ts
│ │ │ │ ├── service.ts
│ │ │ │ └── type.ts
│ │ │ ├── youdao/
│ │ │ │ ├── index.ts
│ │ │ │ └── service.ts
│ │ │ ├── yuque/
│ │ │ │ ├── form.tsx
│ │ │ │ ├── headerForm.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── interface.ts
│ │ │ │ └── service.ts
│ │ │ └── yuque_oauth/
│ │ │ ├── form.tsx
│ │ │ ├── headerForm.tsx
│ │ │ ├── index.ts
│ │ │ ├── interface.ts
│ │ │ └── service.ts
│ │ ├── blob.ts
│ │ ├── buffer.ts
│ │ ├── chrome/
│ │ │ └── storage.ts
│ │ ├── error.ts
│ │ ├── getResource.ts
│ │ ├── hooks/
│ │ │ ├── useFilterExtensions.ts
│ │ │ ├── useFilterImageHostingServices.ts
│ │ │ ├── useOriginPermission.ts
│ │ │ └── useVerifiedAccount.tsx
│ │ ├── loading.test.ts
│ │ ├── loading.ts
│ │ ├── locales/
│ │ │ ├── antd.ts
│ │ │ ├── data/
│ │ │ │ ├── de-DE.json
│ │ │ │ ├── de-DE.ts
│ │ │ │ ├── en-US.json
│ │ │ │ ├── en-US.ts
│ │ │ │ ├── ja-JP.json
│ │ │ │ ├── ja-JP.ts
│ │ │ │ ├── ko-KR.json
│ │ │ │ ├── ko-KR.ts
│ │ │ │ ├── ru-RU.json
│ │ │ │ ├── ru-RU.ts
│ │ │ │ ├── zh-CN.json
│ │ │ │ ├── zh-CN.ts
│ │ │ │ ├── zh-TW.json
│ │ │ │ └── zh-TW.ts
│ │ │ ├── index.test.ts
│ │ │ ├── index.ts
│ │ │ └── interface.ts
│ │ ├── matchUrl.test.ts
│ │ ├── matchUrl.ts
│ │ ├── modelTypes/
│ │ │ ├── account.ts
│ │ │ ├── clipper.ts
│ │ │ ├── extensions.ts
│ │ │ └── userPreference.ts
│ │ ├── object.ts
│ │ ├── storage/
│ │ │ ├── __test__/
│ │ │ │ └── index.spec.ts
│ │ │ ├── index.ts
│ │ │ ├── interface.ts
│ │ │ └── typedCommonStorage.ts
│ │ ├── strings.ts
│ │ ├── types.ts
│ │ └── version/
│ │ ├── index.test.ts
│ │ └── index.ts
│ ├── components/
│ │ ├── ExtensionCard/
│ │ │ ├── index.less
│ │ │ └── index.tsx
│ │ ├── IconFont.tsx
│ │ ├── ImageHostingSelect.less
│ │ ├── ImageHostingSelect.tsx
│ │ ├── LinkRender/
│ │ │ └── index.tsx
│ │ ├── RepositorySelect.tsx
│ │ ├── accountItem/
│ │ │ ├── index.less
│ │ │ └── index.tsx
│ │ ├── avatar/
│ │ │ └── index.tsx
│ │ ├── container/
│ │ │ ├── index.less
│ │ │ └── index.tsx
│ │ ├── imageHostingSelectOption/
│ │ │ ├── index.less
│ │ │ └── index.tsx
│ │ ├── imagehostingListItem/
│ │ │ ├── index.less
│ │ │ └── index.tsx
│ │ ├── section/
│ │ │ ├── __snapshots__/
│ │ │ │ └── index.test.tsx.snap
│ │ │ ├── index.less
│ │ │ └── index.tsx
│ │ ├── share/
│ │ │ └── index.tsx
│ │ └── userItem/
│ │ ├── index.less
│ │ └── index.tsx
│ ├── config.ts
│ ├── extensions/
│ │ ├── common.ts
│ │ ├── contextMenus/
│ │ │ └── saveSelection/
│ │ │ └── saveSelection.ts
│ │ ├── contextMenus.ts
│ │ ├── extensions/
│ │ │ ├── bookmark.ts
│ │ │ ├── extensions/
│ │ │ │ ├── remove.ts
│ │ │ │ ├── selectTool.ts
│ │ │ │ └── uploadImage.ts
│ │ │ ├── fullPage.ts
│ │ │ ├── qrcode.ts
│ │ │ ├── readability.ts
│ │ │ ├── screenshot.ts
│ │ │ ├── select.ts
│ │ │ └── web-clipper/
│ │ │ ├── clear.ts
│ │ │ ├── copyToClipboard.ts
│ │ │ ├── download.ts
│ │ │ ├── link.tsx
│ │ │ └── pangu.ts
│ │ └── index.ts
│ ├── hooks/
│ │ └── useOriginForm.tsx
│ ├── index.html
│ ├── main/
│ │ ├── background.worker.ts
│ │ ├── contentScript.main.ts
│ │ └── tool.main.chrome.ts
│ ├── models/
│ │ ├── account.ts
│ │ ├── clipper.tsx
│ │ └── userPreference.ts
│ ├── pages/
│ │ ├── app.less
│ │ ├── app.tsx
│ │ ├── auth.tsx
│ │ ├── complete/
│ │ │ ├── complete.less
│ │ │ └── complete.tsx
│ │ ├── locale.tsx
│ │ ├── plugin/
│ │ │ ├── Page.tsx
│ │ │ ├── TextEditor.tsx
│ │ │ └── index.less
│ │ ├── preference/
│ │ │ ├── account/
│ │ │ │ ├── index.less
│ │ │ │ ├── index.tsx
│ │ │ │ └── modal/
│ │ │ │ ├── createAccountModal.tsx
│ │ │ │ ├── editAccountModal.tsx
│ │ │ │ └── index.less
│ │ │ ├── base.tsx
│ │ │ ├── changelog/
│ │ │ │ └── index.tsx
│ │ │ ├── extensions/
│ │ │ │ ├── index.less
│ │ │ │ └── index.tsx
│ │ │ ├── imageHosting/
│ │ │ │ ├── form/
│ │ │ │ │ └── addImageHosting.tsx
│ │ │ │ ├── index.less
│ │ │ │ └── index.tsx
│ │ │ ├── index.less
│ │ │ ├── index.tsx
│ │ │ └── privacy/
│ │ │ └── index.tsx
│ │ └── tool/
│ │ ├── ClipExtension.tsx
│ │ ├── Header.tsx
│ │ ├── index.less
│ │ ├── index.tsx
│ │ └── toolExtensions.tsx
│ ├── service/
│ │ ├── common/
│ │ │ ├── config.ts
│ │ │ ├── configuration.ts
│ │ │ ├── contentScript.ts
│ │ │ ├── cookie.ts
│ │ │ ├── extension.ts
│ │ │ ├── ipc.ts
│ │ │ ├── locale.ts
│ │ │ ├── permissions.ts
│ │ │ ├── preference.ts
│ │ │ ├── request.ts
│ │ │ ├── storage.ts
│ │ │ ├── tab.ts
│ │ │ └── webRequest.ts
│ │ ├── config/
│ │ │ └── browser/
│ │ │ └── configService.ts
│ │ ├── configuration/
│ │ │ ├── common/
│ │ │ │ └── generate-local-config.ts
│ │ │ └── configuration.ts
│ │ ├── contentScript/
│ │ │ ├── browser/
│ │ │ │ └── contentScript/
│ │ │ │ ├── contentScript.less
│ │ │ │ └── contentScript.ts
│ │ │ └── common/
│ │ │ └── contentScriptIPC.ts
│ │ ├── cookie/
│ │ │ ├── background/
│ │ │ │ └── cookieService.ts
│ │ │ └── common/
│ │ │ └── cookieIpc.ts
│ │ ├── extension/
│ │ │ └── browser/
│ │ │ ├── extensionContainer.ts
│ │ │ └── extensionService.ts
│ │ ├── ipc/
│ │ │ └── browser/
│ │ │ ├── background-main/
│ │ │ │ └── ipcService.ts
│ │ │ ├── contentScript/
│ │ │ │ └── contentScriptIPCServer.ts
│ │ │ └── popup/
│ │ │ └── ipcClient.ts
│ │ ├── permissions/
│ │ │ ├── chrome/
│ │ │ │ └── permissionsService.ts
│ │ │ └── common/
│ │ │ └── permissionsIpc.ts
│ │ ├── preference/
│ │ │ └── browser/
│ │ │ └── preferenceService.ts
│ │ ├── request/
│ │ │ ├── common/
│ │ │ │ ├── request.test.ts
│ │ │ │ └── request.ts
│ │ │ └── tool/
│ │ │ └── basic.ts
│ │ ├── tab/
│ │ │ ├── browser/
│ │ │ │ └── background/
│ │ │ │ └── tabService.ts
│ │ │ └── common/
│ │ │ └── tabIpc.ts
│ │ ├── webRequest/
│ │ │ ├── browser/
│ │ │ │ └── background/
│ │ │ │ └── tabService.ts
│ │ │ ├── chrome/
│ │ │ │ └── background/
│ │ │ │ └── tabService.ts
│ │ │ └── common/
│ │ │ └── webRequestIPC.ts
│ │ └── worker/
│ │ ├── common/
│ │ │ ├── index.ts
│ │ │ └── workserServiceIPC.ts
│ │ └── worker/
│ │ └── workerService.ts
│ ├── services/
│ │ ├── account/
│ │ │ └── common.ts
│ │ ├── configuration/
│ │ │ └── common/
│ │ │ ├── configuration.ts
│ │ │ └── configurationService.ts
│ │ ├── environment/
│ │ │ └── common/
│ │ │ ├── changelog/
│ │ │ │ ├── CHANGELOG.en-US.md
│ │ │ │ └── CHANGELOG.zh-CN.md
│ │ │ ├── environment.ts
│ │ │ ├── environmentService.ts
│ │ │ └── privacy/
│ │ │ ├── PRIVACY.en-US.md
│ │ │ └── PRIVACY.zh-CN.md
│ │ └── log/
│ │ └── common/
│ │ └── index.ts
│ ├── setupTests.ts
│ └── vendor/
│ └── global.d.ts
├── tsconfig.json
├── vitest.config.ts
└── webpack/
├── plugin/
│ └── webpack-create-extension-manifest-plugin.js
├── webpack.common.js
├── webpack.dev.js
└── webpack.prod.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
node_modules
release
lib
================================================
FILE: .eslintignore
================================================
lib/
dist/
coverage/
node_modules/
chrome/js/icon.js
releases/
================================================
FILE: .eslintrc.js
================================================
module.exports = {
extends: ['@diamondyuan/react-typescript', 'prettier'],
plugins: ['eslint-plugin-prettier'],
rules: {
'no-use-before-define': 'off',
'arrow-body-style': 'off',
'no-redeclare': 'off',
'prettier/prettier': 'error',
'@typescript-eslint/no-unused-vars': 'off',
},
settings: {
'import/resolver': {
webpack: {
config: './webpack/webpack.common.js',
},
},
},
};
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report_en-US.md
================================================
---
name: Bug report
about: Create a report to help us improve
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**please complete the following information**
- Notebook: [e.g. notion,yuque]
- Browser [e.g. chrome, safari]
- Version [e.g. 1.23.0]
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report_zh-CN.md
================================================
---
name: Bug 报告
about: 创建报告以帮助我们改进
---
**Bug 描述**
清楚简明地描述错误是什么。
**复现步骤**
重现的步骤:
1. 打开 '...'
2. 点击按钮 '....'
3. 滚动到 '....'
4. 看到错误
**预期行为**
对您期望发生的事情的简洁明了的描述。
**截图**
如果适用,请添加屏幕截图以帮助解释您的问题。
**请填写以下信息**
- 笔记平台: [e.g. notion,yuque]
- 浏览器 [e.g. chrome, safari]
- 版本 [e.g. 1.23.0]
**其他背景**
在此处添加有关该问题的任何其他上下文。
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request_en-US.md
================================================
---
name: Feature request
about: Suggest an idea for this project
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request_zh-CN.md
================================================
---
name: 功能请求
about: 为这个项目提出一个想法
---
**您的功能要求与问题有关吗? 请描述。**
清楚,简洁地说明问题所在。 例如 当[...]时,我总是感到沮丧
**描述您想要的解决方案**
对您想要发生的事情的简洁明了的描述。
**描述您考虑过的替代方案**
对您考虑过的所有替代解决方案或功能的简洁明了的描述。
**其他内容**
在此处添加有关功能请求的其他任何上下文或屏幕截图。
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI Test
on:
pull_request_target:
types: [opened, edited, reopened]
push:
branches:
- '**'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
with:
fetch-depth: 0
- uses: actions/setup-node@v1
with:
node-version: '16.x'
- name: Install Dependencies
run: npm install --force
- run: npm run cov
env:
GITHUB_BRANCH: ${{ github.ref }}
================================================
FILE: .gitignore
================================================
npm-debug.log
node_modules/
dist/*
!dist/.gitkeep
lib/
coverage/
tmp/
.DS_Store
.idea
.vscode
yarn-error.log
dll/
webclipper.zip
.now
release
================================================
FILE: .prettierrc
================================================
{
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"proseWrap": "never"
}
================================================
FILE: .yarnrc
================================================
version-git-message "chore(release): %s :tada:"
================================================
FILE: LICENSE
================================================
Software License Agreement
Copyright (c) 2020-2020, DiamondYuan. All rights reserved.
Licensed under the terms of GNU General Public License Version 2 or later.
================================================
FILE: README.md
================================================
Web Clipper
You can use Web Clipper to save anything on the web to anywhere.
### Support Site
- [FlowUs](https://flowus.cn/)
- [Obsidian](https://obsidian.md/)
- [Github](https://github.com)
- [Yuque](https://www.yuque.com)
- [Buildin.AI](https://buildin.ai/product)
- [Notion](https://www.notion.so/)
- [Youdao](https://note.youdao.com/)
- [OneNote](https://www.onenote.com/)
- [Bear](https://bear.app)
- [Joplin](https://joplinapp.org/)
- [Server Chan](http://sc.ftqq.com/3.version)
- [dida365](https://dida365.com/)
- [baklib](https://www.baklib-free.com/)
- [wolai](https://www.wolai.com/)
- [Leanote](https://github.com/leanote/leanote)
- [Flomo](https://flomoapp.com/)
- [Siyuan](https://b3log.org/siyuan)
- [Ulysses](https://ulysses.app/)
- [Confluence](https://www.atlassian.com/software/confluence)
### Install
- [Chrome](https://chrome.google.com/webstore/detail/web-clipper/mhfbofiokmppgdliakminbgdgcmbhbac)
- [Edge](https://microsoftedge.microsoft.com/addons/detail/opejamnnohhbjflpbhnmdlknhjkfhfdp)
ps: Because the review takes a week, the version will fall behind.
#### From Github
1. Download the webclipper.zip from [release page](https://github.com/webclipper/web-clipper/releases)
2. Go to **chrome://extensions/** and check the box for **Developer mode** in the top right.
3. Locate the ZIP file on your computer and unzip it.
4. Go back to the chrome://extensions/ page and click the **Load unpacked extension** button and select the unzipped folder for your extension to install it.
### Develop
```bash
$ git clone https://github.com/webclipper/web-clipper.git
$ cd web-clipper
$ npm i
$ npm run dev
```
- You should load the 'dist/chrome' folder in Chrome.
- You should load the 'dist/manifest.json' folder in Firefox.
### Test
```bash
$ npm run test
```
### Feedback
| Type | Link |
| -------- | ---------------------------------------------------- |
| Telegram | [Link](https://t.me/joinchat/HoVttRRUIA6aXASixzoqAw) |
================================================
FILE: bin/index.js
================================================
#!/usr/bin/env node
require('ts-node').register({
transpileOnly: true,
});
require('./main');
================================================
FILE: bin/main.ts
================================================
import { hideBin } from 'yargs/helpers';
import { format } from './scripts';
const [command] = hideBin(process.argv);
switch (command) {
case 'format': {
format();
break;
}
default: {
throw new Error('unknown command');
}
}
================================================
FILE: bin/scripts/format.ts
================================================
import * as fs from 'fs';
import * as path from 'path';
export function format() {
const localsPath = path.resolve(__dirname, '../../src/common/locales/data');
const files = fs.readdirSync(localsPath);
const sortedKeys = Object.keys(
JSON.parse(fs.readFileSync(path.resolve(localsPath, 'en-US.json'), { encoding: 'utf-8' }))
).sort((a, b) => a.localeCompare(b));
files
.filter(file => path.extname(file) === '.json')
.map(file => path.resolve(localsPath, file))
.forEach(file => {
const messages = JSON.parse(fs.readFileSync(file, 'utf-8'));
const result = {};
sortedKeys.forEach(key => {
result[key] = messages[key] || '';
});
fs.writeFileSync(file, JSON.stringify(result, null, 2));
});
}
================================================
FILE: bin/scripts/index.ts
================================================
export { format } from './format';
================================================
FILE: chrome/html/error.html
================================================
Plugin Installation Notice
Plugin Installation Notice
After installing the plugin, if you want to use it on pages that were already open before installation, please
refresh the page.
================================================
FILE: chrome/js/icon.js
================================================
window._iconfont_svg_string_1402208=' ',function(a){var c=(c=document.getElementsByTagName("script"))[c.length-1],l=c.getAttribute("data-injectcss"),c=c.getAttribute("data-disable-injectsvg");if(!c){var h,t,i,o,v,s=function(c,l){l.parentNode.insertBefore(c,l)};if(l&&!a.__iconfont__svg__cssinject__){a.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(c){console&&console.log(c)}}h=function(){var c,l=document.createElement("div");l.innerHTML=a._iconfont_svg_string_1402208,(l=l.getElementsByTagName("svg")[0])&&(l.setAttribute("aria-hidden","true"),l.style.position="absolute",l.style.width=0,l.style.height=0,l.style.overflow="hidden",l=l,(c=document.body).firstChild?s(l,c.firstChild):c.appendChild(l))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(h,0):(t=function(){document.removeEventListener("DOMContentLoaded",t,!1),h()},document.addEventListener("DOMContentLoaded",t,!1)):document.attachEvent&&(i=h,o=a.document,v=!1,z(),o.onreadystatechange=function(){"complete"==o.readyState&&(o.onreadystatechange=null,p())})}function p(){v||(v=!0,i())}function z(){try{o.documentElement.doScroll("left")}catch(c){return void setTimeout(z,50)}p()}}(window);
================================================
FILE: config.json
================================================
{
"iconfont": "https://at.alicdn.com/t/font_1402208_ghcp6tuu13c.js",
"chromeWebStoreVersion": "1.28.4",
"edgeWebStoreVersion": "1.28.4",
"firefoxWebStoreVersion": "1.28.4",
"privacyLocale": ["en-US", "zh-CN"],
"changelogLocale": ["en-US", "zh-CN"]
}
================================================
FILE: global.d.ts
================================================
declare module '*.md' {
const src: string;
export default src;
}
================================================
FILE: package.json
================================================
{
"name": "web-clipper",
"version": "1.42.0",
"description": "Universal open source web clipper.",
"bin": {
"web-clipper": "bin/index.js"
},
"scripts": {
"test": "vitest",
"cov": "vitest --coverage",
"dev": "webpack --config webpack/webpack.dev.js --watch",
"release": "ts-node script/release.ts",
"format": "web-clipper format"
},
"author": "DiamondYuan",
"license": "GPL-2.0-or-later",
"dependencies": {
"@ant-design/compatible": "^1.0.8",
"@ant-design/icons": "^4.2.2",
"@formily/antd": "^2.0.0-beta.47",
"@formily/core": "^2.0.0-beta.47",
"@formily/react": "^2.0.0-beta.47",
"@shihengtech/hooks": "^0.0.16",
"@web-clipper/area-selector": "^0.1.3",
"@web-clipper/chrome-promise": "^0.1.2",
"@web-clipper/highlight": "^0.1.3",
"@web-clipper/readability": "^0.3.0",
"@web-clipper/remark-pangu": "^1.0.2",
"@web-clipper/shared": "^0.0.20",
"@web-clipper/turndown": "^0.4.8",
"antd": "4.16.3",
"classnames": "^2.2.6",
"codemirror": "^5.47.0",
"copy-to-clipboard": "^3.2.0",
"cos-js-sdk-v5": "^1.8.1",
"dayjs": "^1.10.4",
"dva": "^2.6.0-beta.19",
"dva-loading": "^3.0.19",
"dva-model-creator": "^0.4.3",
"filenamify": "^4.1.0",
"form-data": "^2.3.3",
"history": "4.10.1",
"hypermd": "^0.3.11",
"immutability-helper": "^3.0.1",
"jquery": "^3.4.0",
"lodash": "^4.17.20",
"mobx": "^5.15.1",
"mobx-react": "^6.1.4",
"qrcode": "^1.4.1",
"qs": "^6.7.0",
"query-string": "7",
"raw-loader": "^4.0.2",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-intl": "^3.9.2",
"react-markdown": "^6.0.2",
"redux": "4.2.1",
"redux-saga": "^0.16.2",
"reflect-metadata": "^0.1.13",
"regenerator-runtime": "^0.13.3",
"remark": "^11.0.2",
"short-uuid": "^3.1.1",
"showdown": "^1.9.0",
"tldjs": "^2.3.1",
"turndown": "5.0.3",
"typedi": "^0.8.0",
"umi-request": "^1.2.15",
"webdav": "^5.2.2"
},
"devDependencies": {
"@types/chrome": "^0.0.268",
"@types/classnames": "^2.2.9",
"@types/codemirror": "^0.0.76",
"@types/history": "^4.7.2",
"@types/jquery": "^3.3.6",
"@types/lodash": "^4.14.161",
"@types/qrcode": "^1.3.3",
"@types/qs": "^6.9.0",
"@types/react": "^17.0.6",
"@types/react-dom": "^16.9.9",
"@types/react-redux": "^7.0.8",
"@types/react-router": "^5.1.3",
"@types/showdown": "^1.9.3",
"@types/tldjs": "^2.3.0",
"@types/yargs": "^17.0.2",
"@vitest/coverage-v8": "^0.32.2",
"axios": "^0.21.1",
"clean-webpack-plugin": "^0.1.19",
"compressing": "^1.4.0",
"copy-webpack-plugin": "^5.1.1",
"css-loader": "^1.0.0",
"html-webpack-plugin": "^3.2.0",
"less": "^3.8.1",
"less-loader": "^7.0.2",
"prettier": "^3.3.2",
"pump": "^3.0.0",
"style-loader": "^0.23.1",
"terser-webpack-plugin": "^2.3.1",
"ts-loader": "^6.2.1",
"ts-node": "^10.9.2",
"typescript": "^5.1.6",
"url-loader": "^3.0.0",
"vitest": "^0.32.2",
"webpack": "^4.41.5",
"webpack-cli": "^3.3.2",
"webpack-merge": "^4.2.2",
"yargs": "^17.1.1"
},
"packageManager": "pnpm@8.14.0+sha512.5d4bf97b349faf1a51318aa1ba887e99d9c36e203dbcb55938a91fddd2454246cb00723d6642f54d463a0f52a2701dadf8de002a37fc613c9cdc94ed5675ddce"
}
================================================
FILE: script/build.js
================================================
const webpack = require('webpack');
const prodConfig = require('../webpack/webpack.prod');
const compiler = webpack(prodConfig);
function send(data) {
if (!process.send) {
return;
}
return new Promise((r) => {
process.send(data, null, {}, r);
});
}
compiler.run((err) => {
if (err) {
console.log(err);
}
send({
type: 'Success',
});
});
================================================
FILE: script/release.ts
================================================
import { fork } from 'child_process';
import fs from 'fs';
import path from 'path';
import { pack } from './utils/pack';
(async () => {
const releaseDir = path.join(__dirname, '../release');
if (!fs.existsSync(releaseDir)) {
fs.mkdirSync(releaseDir);
}
await build();
await pack({
releaseDir,
distDir: path.join(__dirname, '../dist/chrome'),
fileName: 'web-clipper-chrome.zip',
});
await pack({
releaseDir,
distDir: path.join(__dirname, '../dist'),
fileName: 'web-clipper-firefox.zip',
});
const manifestConfig = path.join(__dirname, '../dist/manifest.json');
const content = fs.readFileSync(manifestConfig, 'utf-8');
const manifest = JSON.parse(content);
manifest.browser_specific_settings = {
gecko: {
id: '{3fbb1f97-0acf-49a0-8348-36e91bef22ea}',
},
};
manifest.name = 'Universal Web Clipper';
fs.writeFileSync(manifestConfig, JSON.stringify(manifest, null, 2));
await pack({
releaseDir,
distDir: path.join(__dirname, '../dist'),
fileName: 'web-clipper-firefox-store.zip',
});
})();
function build() {
const buildScript = require.resolve('./build');
const buildEnv = Object.create(process.env);
buildEnv.NODE_ENV = 'production';
const cp = fork(buildScript, [], {
env: buildEnv as unknown as typeof process.env,
stdio: 'inherit',
});
return new Promise((r) => {
cp.on('message', r);
});
}
================================================
FILE: script/utils/pack.ts
================================================
import compressing from 'compressing';
import fs from 'fs';
import path from 'path';
const pump = require('pump');
interface IPackOptions {
distDir: string;
releaseDir: string;
fileName: string;
}
export function pack(options: IPackOptions) {
const zipStream = new compressing.zip.Stream();
const files = fs.readdirSync(options.distDir).filter((p) => !p.match(/^\./));
for (const file of files) {
zipStream.addEntry(path.join(options.distDir, file));
}
const dest = path.join(options.releaseDir, options.fileName);
const destStream = fs.createWriteStream(dest);
return new Promise((r) => {
pump(zipStream, destStream, r);
});
}
================================================
FILE: src/__test__/utils.ts
================================================
import { IRequestService, TRequestOption } from '@/service/common/request';
import { vi, Mock } from 'vitest';
type TMockRequestServiceHandler = (url: string, options?: TRequestOption) => any;
export class MockRequestService implements IRequestService {
public mock: {
request: Mock;
};
private handler: TMockRequestServiceHandler;
constructor(handler: TMockRequestServiceHandler) {
this.mock = {
request: vi.fn(),
};
this.handler = handler;
}
request(url: string, options: TRequestOption) {
this.mock.request(url, options);
return this.handler(url, options);
}
download(url: string): Promise {
return Promise.resolve(new Blob([url]));
}
}
================================================
FILE: src/actions/account.ts
================================================
import { UserInfo } from '@/common/backend/services/interface';
import { AccountPreference } from '@/common/types';
import { actionCreatorFactory } from 'dva-model-creator';
const actionCreator = actionCreatorFactory('account');
export const asyncAddAccount = actionCreator.async<
{
id: string;
info: any;
imageHosting?: string;
defaultRepositoryId?: string;
userInfo: UserInfo;
type: string;
callback(): void;
},
{
accounts: AccountPreference[];
defaultAccountId: string;
},
void
>('asyncAddAccount');
export const initAccounts = actionCreator.async<
void,
{ accounts: AccountPreference[]; defaultAccountId: string }
>('initAccounts');
export const asyncDeleteAccount = actionCreator.async<
{ id: string },
{
accounts: AccountPreference[];
defaultAccountId: string;
},
void
>('asyncDeleteAccount');
export const asyncUpdateDefaultAccountId = actionCreator.async<{ id?: string }, void>(
'asyncUpdateDefaultAccountId'
);
export const asyncUpdateAccount = actionCreator<{
id: string;
account: {
info: any;
imageHosting?: string;
defaultRepositoryId?: string;
type: string;
};
newId: string;
userInfo: UserInfo;
callback(): void;
}>('asyncUpdateAccount');
================================================
FILE: src/actions/clipper.ts
================================================
import { ClipperHeaderForm } from 'common/modelTypes/clipper';
import { Repository, CompleteStatus, CreateDocumentRequest } from 'common/backend/index';
import { actionCreatorFactory } from 'dva-model-creator';
const actionCreator = actionCreatorFactory('clipper');
export const asyncCreateDocument = actionCreator.async<
{ pathname: string },
{
result: CompleteStatus;
request: CreateDocumentRequest;
},
null
>('ASYNC_CREATE_DOCUMENT');
export const asyncUploadImage = actionCreator.async('ASYNC_UPLOAD_IMAGE');
export const selectRepository = actionCreator<{ repositoryId: string }>('SELECT_REPOSITORY');
export const initTabInfo = actionCreator<{ title: string; url: string }>('INIT_TAB_INFO');
export const asyncChangeAccount = actionCreator.async<
{
id: string;
},
{
repositories: Repository[];
currentImageHostingService?: { type: string };
},
any
>('ASYNC_CHANGE_ACCOUNT');
export const changeData = actionCreator<{ data: any; pathName: string }>('CHANGE_DATA');
export const watchActionChannel = actionCreator('watchActionChannel');
export const updateClipperHeader = actionCreator('updateClipperHeader');
================================================
FILE: src/actions/userPreference.ts
================================================
import { IExtensionWithId } from './../extensions/common';
import { ImageHosting, GlobalStore } from '@/common/types';
import { PreferenceStorage } from 'common/storage/interface';
import { actionCreatorFactory } from 'dva-model-creator';
const actionCreator = actionCreatorFactory('userPreference');
export const initUserPreference = actionCreator('INIT_USER_PREFERENCE');
export const asyncChangeDefaultRepository = actionCreator.async<
{
defaultRepositoryId: string;
},
void
>('ASYNC_CHANGE_DEFAULT_REPOSITORY');
export const asyncSetEditorLiveRendering = actionCreator.async<
{
value: boolean;
},
{
value: boolean;
},
void
>('ASYNC_SET_EDITOR_LIVE_RENDERING');
export const asyncSetIconColor = actionCreator.async<
{
value: 'dark' | 'light' | 'auto';
},
{
value: 'dark' | 'light' | 'auto';
},
void
>('ASYNC_SET_ICON_COLOR');
export const asyncRunExtension = actionCreator.async<
{
pathname: string;
extension: IExtensionWithId;
},
{
result: unknown;
pathname: string;
},
void
>('ASYNC_RUN_EXTENSION');
export const asyncRunScript = actionCreator.async('ASYNC_RUN_SCRIPT');
export const asyncAddImageHosting = actionCreator.async<
{ closeModal: () => void } & Omit,
ImageHosting[],
void
>('ASYNC_ADD_IMAGE_HOSTING');
export const asyncDeleteImageHosting = actionCreator.async<{ id: string }, ImageHosting[], void>(
'ASYNC_DELETE_IMAGE_HOSTING'
);
export const asyncEditImageHosting = actionCreator.async<
{ id: string; value: Omit; closeModal: () => void },
ImageHosting[],
void
>('ASYNC_EDIT_IMAGE_HOSTING');
export const setLocale = actionCreator('setLocale');
export const asyncSetLocaleToStorage = actionCreator('asyncSetLocaleToStorage');
export const initServices = actionCreator<
Pick
>('initServices');
================================================
FILE: src/common/backend/clients/github/client.test.ts
================================================
import { MockRequestService } from '@/__test__/utils';
import { GithubClient } from './client';
import { IRepository } from './types';
describe('test GithubClient', () => {
test('test generateNewTokenUrl', () => {
expect(GithubClient.generateNewTokenUrl).toEqual(
'https://github.com/settings/tokens/new?scopes=repo&description=Web%20Clipper'
);
});
function getTestFixtures(response?: any) {
let handler = () => response;
if (typeof response === 'function') {
handler = response;
}
const mockRequestService = new MockRequestService(handler);
const githubClient = new GithubClient({
token: 'DiamondYuan',
request: mockRequestService,
});
return {
mockRequestService: mockRequestService.mock.request,
githubClient,
};
}
test('test getUserInfo', async () => {
const expectResult = { login: 'DiamondYuan' };
const testFixtures = getTestFixtures(expectResult);
const result = await testFixtures.githubClient.getUserInfo();
expect(testFixtures.mockRequestService.mock.calls[0]).toEqual([
'https://api.github.com/user',
{
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: 'token DiamondYuan',
},
method: 'get',
},
]);
expect(result).toEqual(expectResult);
});
test('test createIssue', async () => {
const expectResult = { html_url: 'html_url', id: 'id' };
const testFixtures = getTestFixtures(expectResult);
const result = await testFixtures.githubClient.createIssue({
title: 'title',
body: 'body',
labels: ['label1', 'label2'],
namespace: 'webclipper/web-clipper',
});
expect(testFixtures.mockRequestService.mock.calls[0]).toEqual([
'https://api.github.com/repos/webclipper/web-clipper/issues',
{
data: { title: 'title', body: 'body', labels: ['label1', 'label2'] },
method: 'post',
requestType: 'json',
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: 'token DiamondYuan',
},
},
]);
expect(result).toEqual(expectResult);
});
test('test createIssue', async () => {
const expectResult = { html_url: 'html_url', id: 'id' };
const testFixtures = getTestFixtures(expectResult);
const result = await testFixtures.githubClient.uploadFile({
owner: 'owner',
repo: 'repo',
path: 'path',
message: 'message',
content: 'content',
branch: 'branch',
});
expect(testFixtures.mockRequestService.mock.calls[0]).toEqual([
'https://api.github.com/repos/owner/repo/contents/path',
{
data: { message: 'message', content: 'content', branch: 'branch' },
method: 'put',
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: 'token DiamondYuan',
},
},
]);
expect(result).toEqual(expectResult);
});
test('test list branches', async () => {
const testFixtures = getTestFixtures([]);
await testFixtures.githubClient.listBranch({
owner: 'owner',
repo: 'repo',
protected: false,
page: 1,
per_page: 100,
});
expect(testFixtures.mockRequestService.mock.calls[0]).toEqual([
'https://api.github.com/repos/owner/repo/branches?protected=false&per_page=100&page=1',
{
method: 'get',
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: 'token DiamondYuan',
},
},
]);
});
test('test getRepos', async () => {
const testFixtures = getTestFixtures([]);
await testFixtures.githubClient.getRepos({
visibility: 'all',
affiliation: 'owner',
page: 1,
per_page: 100,
});
expect(testFixtures.mockRequestService.mock.calls[0]).toEqual([
'https://api.github.com/user/repos?affiliation=owner&per_page=100&page=1',
{
method: 'get',
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: 'token DiamondYuan',
},
},
]);
});
test('test git all', async () => {
const result: IRepository[] = [];
for (let i = 0; i < 670; i++) {
result.push({
name: `${i}`,
full_name: `webclipper/${i}`,
default_branch: 'main',
});
}
const testFixtures = getTestFixtures((...args: [string, Object]) => {
const url = new URL(args[0]);
const page = parseInt(url.searchParams.get('page')!, 10);
const per_page = parseInt(url.searchParams.get('per_page')!, 10);
return result.slice(per_page * (page - 1), per_page * page);
});
const res = await testFixtures.githubClient.queryAll(
{
visibility: 'all',
affiliation: 'owner',
},
testFixtures.githubClient.getRepos
);
expect(res).toEqual(result);
});
});
================================================
FILE: src/common/backend/clients/github/client.ts
================================================
import { IExtendRequestHelper } from '@/service/common/request';
import { RequestHelper } from '@/service/request/common/request';
import { stringify } from 'qs';
import {
ICreateIssueOptions,
ICreateIssueResponse,
IGithubClientOptions,
IGithubUserInfoResponse,
IUploadFileOptions,
IUploadFileResponse,
IListBranchesOptions,
IBranch,
TPageRequest,
IPageQuery,
TOmitPage,
IGetGithubRepositoryOptions,
IRepository,
} from './types';
export class GithubClient {
private options: IGithubClientOptions;
private request: IExtendRequestHelper;
constructor(options: IGithubClientOptions) {
this.options = options;
this.request = new RequestHelper({
baseURL: 'https://api.github.com/',
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `token ${this.options.token}`,
},
request: this.options.request,
});
}
createIssue = async (options: ICreateIssueOptions) => {
const data = { title: options.title, body: options.body, labels: options.labels };
const response = await this.request.post(
`repos/${options.namespace}/issues`,
{ data }
);
return response;
};
getUserInfo = () => {
return this.request.get('user');
};
queryAll = async (
args: TOmitPage,
pageRequest: TPageRequest
): Promise => {
const startPage: number = 1;
const pageSize: number = 50;
const baseArgs = { ...args, page: startPage, per_page: pageSize } as O;
let result: T[] = [];
while (true) {
const response = await pageRequest(baseArgs);
result = result.concat(response);
if (response.length === pageSize) {
baseArgs.page = baseArgs.page + 1;
continue;
}
return result;
}
};
/**
* Create or update file contents
*
* @see https://docs.github.com/en/free-pro-team@latest/rest/reference/repos#create-or-update-file-contents
*/
uploadFile = (options: IUploadFileOptions) => {
return this.request.put(
`repos/${options.owner}/${options.repo}/contents/${options.path}`,
{
data: {
message: options.message,
content: options.content,
branch: options.branch,
},
}
);
};
listBranch = (options: IListBranchesOptions) => {
return this.request.get(
`repos/${options.owner}/${options.repo}/branches?${stringify({
protected: options.protected,
per_page: options.per_page,
page: options.page,
})}`
);
};
/**
*
* @see https://docs.github.com/en/free-pro-team@latest/rest/reference/repos#list-repositories-for-the-authenticated-user
* @param options IGetGithubRepositoryOptions
*/
getRepos = (options: IGetGithubRepositoryOptions) => {
return this.request.get(
`user/repos?${stringify({
affiliation: options.affiliation,
per_page: options.per_page,
type: options.type,
page: options.page,
})}`
);
};
static get generateNewTokenUrl() {
return `https://github.com/settings/tokens/new?${stringify({
scopes: 'repo',
description: 'Web Clipper',
})}`;
}
}
================================================
FILE: src/common/backend/clients/github/types.ts
================================================
import { IRequestService } from '@/service/common/request';
export interface IGithubClientOptions {
token: string;
request: IRequestService;
}
export interface ICreateIssueOptions {
title: string;
body: string;
labels: string[];
namespace: string;
}
export interface ICreateIssueResponse {
html_url: string;
id: number;
}
export interface IGithubUserInfoResponse {
login: string;
avatar_url: string;
name: string;
bio: string;
html_url: string;
}
export interface IUploadFileOptions {
owner: string;
repo: string;
path: string;
message: string;
content: string;
branch?: string;
}
export interface IUploadFileResponse {
content: {
html_url: string;
};
}
export interface IListBranchesOptions extends IPageQuery {
owner: string;
repo: string;
protected: boolean;
}
export interface IBranch {
name: string;
protected: boolean;
}
export interface IPageQuery {
per_page: number;
page: number;
}
export type TOmitPage = Omit;
export type TPageRequest = (option: O) => Promise;
export interface IGetGithubRepositoryOptions extends IPageQuery {
visibility?: 'all' | 'public' | 'private';
affiliation?: 'owner' | 'collaborator' | 'organization_member';
type?: 'all' | 'owner' | 'public' | 'private' | 'member';
}
export interface IRepository {
name: string;
/**
*like webclipper/web-clipper
*/
full_name: string;
default_branch: string;
}
================================================
FILE: src/common/backend/clients/joplin/LegacyJoplinClient.ts
================================================
import { Repository } from './../../services/interface';
import { JoplinFolderItem, JoplinTag, JoplinCreateDocumentRequest } from './types';
import { AbstractJoplinClient } from './basic';
export class LegacyJoplinClient extends AbstractJoplinClient {
getRepositories = async () => {
const repositories: Repository[] = [];
const folders = await this.request.get('folders');
folders.forEach(folder => {
repositories.push({
id: folder.id,
name: folder.title,
groupId: folder.id,
groupName: folder.title,
});
if (Array.isArray(folder.children)) {
folder.children.forEach(subFolder => {
repositories.push({
id: subFolder.id,
name: subFolder.title,
groupId: folder.id,
groupName: folder.title,
});
});
}
});
return repositories;
};
getTags = async (filterTags: boolean) => {
let tags = await this.request.get('tags');
if (filterTags) {
tags = (
await Promise.all(
tags.map(async tag => {
console.log(this);
const notes = await this.request.get(`tags/${tag.id}/notes`);
if (notes.length === 0) {
return null;
}
return tag;
})
)
).filter((tag): tag is JoplinTag => !!tag);
}
return tags;
};
createDocument = async (data: JoplinCreateDocumentRequest) => {
await this.request.post('notes', {
data: {
parent_id: data.repositoryId,
title: data.title,
body: data.content,
tags: data.tags.join(','),
source_url: data.url,
},
});
};
}
================================================
FILE: src/common/backend/clients/joplin/basic.ts
================================================
import { generateUuid } from '@web-clipper/shared/lib/uuid';
import { IExtendRequestHelper } from '@/service/common/request';
import { Repository, IJoplinClient, JoplinTag, JoplinCreateDocumentRequest } from './types';
export abstract class AbstractJoplinClient implements IJoplinClient {
constructor(protected request: IExtendRequestHelper) {}
public uploadBlob = async (blob: Blob): Promise => {
let formData = new FormData();
formData.append('data', blob);
formData.append(
'props',
JSON.stringify({
title: generateUuid(),
})
);
const result = await this.request.postForm<{ id: string }>(`resources`, {
data: formData,
});
return `:/${result.id}`;
};
abstract getTags(filterTags: boolean): Promise;
abstract getRepositories(): Promise;
abstract createDocument(data: JoplinCreateDocumentRequest): Promise;
}
================================================
FILE: src/common/backend/clients/joplin/client.ts
================================================
import { AbstractJoplinClient } from './basic';
import {
Repository,
JoplinFolderItem,
JoplinTag,
JoplinCreateDocumentRequest,
IPageRes,
} from './types';
export class JoplinClient extends AbstractJoplinClient {
support = async (): Promise => {
let tags = await this.request.get>('tags');
return typeof tags.has_more === 'boolean';
};
getRepositories = async () => {
const repositories: Repository[] = [];
const folders = await this.pageToAllList(this.getFolderByPageNumber);
folders.forEach(folder => {
repositories.push({
id: folder.id,
name: folder.title,
groupId: folder.id,
groupName: folder.title,
});
if (Array.isArray(folder.children)) {
folder.children.forEach(subFolder => {
repositories.push({
id: subFolder.id,
name: subFolder.title,
groupId: folder.id,
groupName: folder.title,
});
});
}
});
return repositories;
};
getTags = async (filterTags: boolean) => {
let tags = await this.pageToAllList(this.getTagsByPageNumber);
if (filterTags) {
tags = (
await Promise.all(
tags.map(async tag => {
const notes = await this.request.get(`tags/${tag.id}/notes`);
console.log('notes', notes);
if (notes.length === 0) {
return null;
}
return tag;
})
)
).filter((tag): tag is JoplinTag => !!tag);
}
return tags;
};
createDocument = async (data: JoplinCreateDocumentRequest) => {
await this.request.post('notes', {
data: {
parent_id: data.repositoryId,
title: data.title,
body: data.content,
tags: data.tags.join(','),
source_url: data.url,
},
});
};
private getTagsByPageNumber = async (page: number) => {
return this.request.get>(`tags?page=${page}`);
};
private getFolderByPageNumber = async (page: number) => {
return this.request.get>(`folders?page=${page}`);
};
private pageToAllList = async (
getOnePage: (page: number) => Promise>
): Promise => {
let hasMore = true;
let startPageNumber = 1;
let result: T[] = [];
while (hasMore) {
const response = await getOnePage(startPageNumber);
result = result.concat(response.items);
hasMore = response.has_more;
startPageNumber++;
}
return result;
};
}
================================================
FILE: src/common/backend/clients/joplin/index.ts
================================================
export { JoplinClient } from './client';
export { LegacyJoplinClient } from './LegacyJoplinClient';
export * from './types';
================================================
FILE: src/common/backend/clients/joplin/types.ts
================================================
import type { Repository, CreateDocumentRequest } from '../../services/interface';
export type { Repository } from '../../services/interface';
export type { CreateDocumentRequest } from '../../services/interface';
export interface IJoplinClient {
getTags(filterTags: boolean): Promise;
getRepositories(): Promise;
createDocument(data: JoplinCreateDocumentRequest): Promise;
uploadBlob(blob: Blob): Promise;
}
export interface JoplinTag {
id: string;
title: string;
}
export interface JoplinCreateDocumentRequest extends CreateDocumentRequest {
tags: string[];
}
export interface JoplinBackendServiceConfig {
token: string;
filterTags: boolean;
}
export interface JoplinFolderItem {
id: string;
title: string;
children: JoplinFolderItem[];
}
export interface JoplinTag {
id: string;
title: string;
}
export interface IJoplinClient {
getTags(filterTags: boolean): Promise;
getRepositories(): Promise;
createDocument(data: JoplinCreateDocumentRequest): Promise;
}
export interface JoplinCreateDocumentRequest extends CreateDocumentRequest {
tags: string[];
}
export interface IPageRes {
has_more: boolean;
items: T[];
}
================================================
FILE: src/common/backend/clients/leanote/client.test.ts
================================================
import { MockRequestService } from '@/__test__/utils';
import LeanoteClient from './client';
const FormData = require('form-data');
describe('test LeanoteClient', () => {
test('test login', async () => {
const expectedResponseToken = { Token: 'SomeToken' };
const mockRequestService = new MockRequestService(() => expectedResponseToken);
const inputStub = {
leanote_host: 'https://localhost',
email: 'email',
pwd: 'pwd',
token_cached: 'OldToken',
};
const client = new LeanoteClient(inputStub, mockRequestService);
const result = await client.login();
const expectUrlGenerated = `${inputStub.leanote_host}/api/auth/login`;
expect(mockRequestService.mock.request.mock.calls[0][0]).toEqual(expectUrlGenerated);
//TODO add test
expect(result).toEqual(expectedResponseToken.Token);
});
test('test repository', async () => {
const notebookListStubResponse = [{ NotebookId: 1, Title: 'some_notebook_title' }];
const inputStub = {
leanote_host: 'https://localhost',
email: '',
pwd: '',
token_cached: 'some_token_when_already_logged',
};
const mockRequestService = new MockRequestService(() => notebookListStubResponse);
const client = new LeanoteClient(inputStub, mockRequestService);
const result = await client.getSyncNotebooks();
const expectUrlGenerated = `${inputStub.leanote_host}/api/notebook/getSyncNotebooks?token=${inputStub.token_cached}`;
expect(mockRequestService.mock.request.mock.calls[0][0]).toEqual(expectUrlGenerated);
expect(notebookListStubResponse[0].Title).toEqual(result[0].Title);
});
test('test create document', async () => {
const inputStub = {
leanote_host: 'https://localhost',
email: '',
pwd: '',
token_cached: 'some_token_when_already_logged',
};
const mockRequestService = new MockRequestService(function() {
// No return expected
});
const client = new LeanoteClient(inputStub, mockRequestService);
await client.createDocument({
title: 'some_title',
content: 'some_content',
repositoryId: 'some_repo',
});
const expectUrlGenerated = `${inputStub.leanote_host}/api/note/addNote?token=${inputStub.token_cached}`;
const calledUrl = mockRequestService.mock.request.mock.calls[0][0];
const callRequest = mockRequestService.mock.request.mock.calls[0][1];
expect(calledUrl).toEqual(expectUrlGenerated);
expect(Object.keys(callRequest.data).length).toBeGreaterThan(0);
expect(callRequest.data.constructor).toEqual(FormData);
expect(callRequest.requestType).toEqual('form');
expect(callRequest.method).toEqual('post');
});
});
================================================
FILE: src/common/backend/clients/leanote/client.ts
================================================
import { IExtendRequestHelper, IRequestService } from '@/service/common/request';
import { CreateDocumentRequest } from '../../index';
import { RequestHelper } from '@/service/request/common/request';
import showdown from 'showdown';
import {
LeanoteBackendServiceConfig,
LeanoteCreateDocumentResponse,
LeanoteNotebook,
LeanoteResponse,
} from './interface';
const FormData = require('form-data');
const converter = new showdown.Converter();
/**
* Client for self hosted leanote or leanote.com
*/
export default class LeanoteClient {
private config: LeanoteBackendServiceConfig;
private request: IExtendRequestHelper;
private formData: FormData;
private imagesCount: number;
/**
* This class wrap a IExtendRequestHelper to perform HTTP request
*/
constructor(
{ leanote_host, email, pwd, token_cached }: LeanoteBackendServiceConfig,
request: IRequestService
) {
this.config = { leanote_host, email, pwd, token_cached };
this.formData = new FormData();
this.imagesCount = 0;
this.request = new RequestHelper({
baseURL: this.config.leanote_host,
request: request,
});
}
/**
* @TODO: Support markdown
*
* Perform a POST with document request as formData to leanote server to create a note
*
* @see documentation https://github.com/leanote/leanote/wiki/leanote-api
*/
createDocument = async (info: CreateDocumentRequest): Promise => {
this.formData.append('NotebookId', info.repositoryId);
this.formData.append('Title', info.title);
this.formData.append('Content', converter.makeHtml(info.content));
const formData = this.formData;
this.formData = new FormData();
this.imagesCount = 0;
return this.request.postForm(
`/api/note/addNote?token=${this.config.token_cached}`,
{
data: formData,
}
);
};
/**
* Append blob accordingly in the formData and guess computed image url
*
* @see documentation https://github.com/leanote/leanote/wiki/leanote-api
*/
uploadBlob = async (blob: Blob): Promise => {
const ext = blob.type.split('/').pop();
const filename = `${this.imagesCount}.${ext}`;
const localFileId = `${this.imagesCount}`;
this.formData.append(`Files[${localFileId}][LocalFileId]`, localFileId);
this.formData.append(`Files[${localFileId}][Title]`, filename);
this.formData.append(`Files[${localFileId}][Type]`, blob.type);
this.formData.append(`Files[${localFileId}][HasBody]`, 'true');
this.imagesCount++;
this.formData.append(`FileDatas[${localFileId}]`, blob, filename);
return `${this.config.leanote_host}/api/file/getImage?fileId=${localFileId}`;
};
/**
* Perform a GET in login api
*
* @see documentation https://github.com/leanote/leanote/wiki/leanote-api
*/
login = async () => {
if (!this.config.email || !this.config.pwd || this.config.email === '') {
throw new Error('Cannot login');
}
/**
* change: get method=>postForm method
* Remark :
* The username and password fields need to be placed in the request body
* 用户名和密码的字段需要放在请求体
*/
let formData = new FormData();
formData.append('email', this.config.email);
formData.append('pwd', this.config.pwd);
const data = await this.request.postForm(`/api/auth/login`, {
data: formData,
});
this.config.token_cached = data.Token;
return data.Token;
};
/**
* Perform a GET in getSyncNotebooks api to find notebooks
* Get notebooks which need be synced
* need Params: afterUsn(int, the usn bigger than it is need be synced)
* maxEntry(int) Number returned by getSyncNotebooks api
* leanote:
* afterUsn : Default value = 0
* maxEntry : if maxEntry==0;maxEntry=100
* @see documentation https://github.com/leanote/leanote/wiki/leanote-api
*/
getSyncNotebooks = async () => {
return this.request.get(
`/api/notebook/getSyncNotebooks?token=${this.config.token_cached}`
);
};
/**
* Perform a GET in getNotebooks api to find all notebooks
*
* @see documentation https://github.com/leanote/leanote/wiki/leanote-api
*/
getNotebooks = async () => {
return this.request.get(
`/api/notebook/getNotebooks?token=${this.config.token_cached}`
);
};
}
================================================
FILE: src/common/backend/clients/leanote/interface.ts
================================================
export interface LeanoteBackendServiceConfig {
leanote_host: string;
email: string;
pwd: string;
token_cached: string;
}
export interface LeanoteResponse {
Ok: string;
Msg: string;
Token: string;
}
export interface LeanoteCreateDocumentResponse {
NoteId: string;
}
export interface LeanoteNotebook {
NotebookId: string;
Title: string;
}
export interface LeanoteNote {
NotebookId: string;
Title: string;
Content: string;
}
================================================
FILE: src/common/backend/clients/siyuan/client.ts
================================================
import { CreateDocumentRequest } from './../../services/interface';
import { IExtendRequestHelper } from '@/service/common/request';
import { RequestHelper } from '@/service/request/common/request';
import {
ISiyuanClientOptions,
ISiyuanUploadImageResponse,
ISiyuanFetchNotesResponse,
} from './types';
const SIYUAN_BASE_URL = 'http://127.0.0.1:6806/';
export class SiYuanClient {
private options: ISiyuanClientOptions;
private request: IExtendRequestHelper;
constructor(options: ISiyuanClientOptions) {
this.options = options;
const headers: Record = {};
if (options.accessToken) {
headers.Authorization = `Token ${options.accessToken}`;
}
this.request = new RequestHelper({
baseURL: SIYUAN_BASE_URL,
headers: headers,
request: this.options.request,
});
}
listNotebooks = async (): Promise<{ id: string; name: string }[]> => {
const res = await this.request.post(`api/notebook/lsNotebooks`, {
data: {},
});
return (res.data.notebooks ?? res.data.files ?? [])
.map(p => {
if (typeof p === 'object') {
return p;
}
return {
name: p.split('/')[p.split('/').length - 1],
id: p.split('/')[p.split('/').length - 1],
closed: false,
};
})
.filter(e => {
return !e.closed;
});
};
createNote = async (data: CreateDocumentRequest) => {
const response = await this.request.post<{ code: number; msg: string; data: string }>(
`api/filetree/createDocWithMd`,
{
data: {
notebook: data.repositoryId,
path: `${data.title}.sy`,
markdown: data.content.replaceAll(SIYUAN_BASE_URL, ''),
},
}
);
if (response.code !== 0) {
throw new Error(response.msg);
}
return response.data;
};
uploadImage = async (blob: Blob) => {
let formData = new FormData();
formData.append('assetsDirPath', '/assets/');
const fileName = `${Date.now()}.png`;
formData.append('file[]', new File([blob], fileName));
const response = await this.request.postForm(`api/asset/upload`, {
data: formData,
});
return `${SIYUAN_BASE_URL}${response.data.succMap[fileName]}`;
};
}
================================================
FILE: src/common/backend/clients/siyuan/types.ts
================================================
import { IRequestService } from '@/service/common/request';
export interface ISiyuanClientOptions {
request: IRequestService;
accessToken?: string;
}
export interface ISiyuanUploadImageResponse {
data: {
succMap: {
[key: string]: string;
};
};
}
export interface ISiyuanFetchNotesResponse {
data: {
files?: string[] | { name: string; id: string; closed?: boolean }[];
notebooks?: string[] | { name: string; id: string; closed?: boolean }[];
};
}
================================================
FILE: src/common/backend/imageHosting/baklib/index.ts
================================================
import localeService from '@/common/locales';
import { ImageHostingServiceMeta } from '../interface';
import Service from './service';
export default (): ImageHostingServiceMeta => {
return {
name: localeService.format({
id: 'backend.imageHosting.baklib.name',
defaultMessage: 'Baklib',
}),
icon: 'baklib',
type: 'baklib',
service: Service,
builtIn: true,
builtInRemark: localeService.format({
id: 'backend.imageHosting.baklib.builtInRemark',
defaultMessage: 'Baklib built in image hosting service.',
}),
};
};
================================================
FILE: src/common/backend/imageHosting/baklib/service.ts
================================================
import { Base64ImageToBlob, BlobToBase64 } from '@/common/blob';
import { UploadImageRequest, ImageHostingService } from '../interface';
import md5 from '@web-clipper/shared/lib/md5';
import { extend, RequestMethod } from 'umi-request';
import { BaklibBackendServiceConfig } from '../../services/baklib/interface';
import { Repository } from '../../services/interface';
export interface YuqueImageHostingOption {
access_token: string;
}
export default class YuqueImageHostingService implements ImageHostingService {
private accessToken: string;
private request: RequestMethod;
private context?: { currentRepository: Repository };
constructor({ accessToken }: BaklibBackendServiceConfig) {
this.accessToken = accessToken;
this.request = extend({
prefix: 'https://www.baklib-free.com/api/',
headers: { Authorization: `Bearer ${accessToken}` },
timeout: 5000,
});
this.request.interceptors.response.use(
async response => {
const json = await response.clone().json();
if (json.code !== 0) {
throw new Error(json.message || json.error);
}
return response;
},
{ global: false }
);
}
getId() {
return md5(this.accessToken);
}
uploadImage = async ({ data }: UploadImageRequest) => {
const blob = Base64ImageToBlob(data);
return this.uploadBlob(blob);
};
uploadImageUrl = async (url: string) => {
const res = await extend({}).get(url, {
responseType: 'blob',
});
let blob: Blob = res;
if (blob.type === 'image/webp') {
blob = blob.slice(0, blob.size, 'image/jpeg');
}
return this.uploadBlob(blob);
};
updateContext = (context: { currentRepository: Repository }) => {
this.context = context;
};
private uploadBlob = async (blob: Blob): Promise => {
if (!this.context?.currentRepository.id) {
throw new Error('请选择站点');
}
console.log('this.context?.currentRepository.id', this.context?.currentRepository.id);
let formData = new FormData();
formData.append('base64', await BlobToBase64(blob));
formData.append('tenant_id', this.context?.currentRepository.id);
const result = await this.request.post(`v1/image/upload`, {
data: formData,
requestType: 'form',
});
return result.message.url;
};
}
================================================
FILE: src/common/backend/imageHosting/github/form.tsx
================================================
import React, { Fragment } from 'react';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import { Input, Select, Tooltip } from 'antd';
import { Form } from '@ant-design/compatible';
import { FormattedMessage } from 'react-intl';
import locale from '@/common/locales';
import IconFont from '@/components/IconFont';
import { GithubClient } from '../../clients/github/client';
import { IBasicRequestService } from '@/service/common/request';
import Container from 'typedi';
import {
IBranch,
IGetGithubRepositoryOptions,
IRepository,
IListBranchesOptions,
} from '../../clients/github/types';
import { useFetch } from '@shihengtech/hooks';
import { GithubImageHostingOption } from './type';
interface Props extends FormComponentProps {
info: GithubImageHostingOption;
}
interface IRepositoryState {
init: boolean;
repos: IRepository[];
}
async function fetchAllRepos(accessToken?: string): Promise {
if (!accessToken) {
return {
init: false,
repos: [],
};
}
const githubClient = new GithubClient({
token: accessToken,
request: Container.get(IBasicRequestService),
});
const repos = await githubClient.queryAll(
{ visibility: 'all' },
githubClient.getRepos
);
return {
init: true,
repos: repos,
};
}
interface IBranchState {
init: boolean;
branches: IBranch[];
default_branch?: string;
}
interface IFetchBranchesOptions {
accessToken?: string;
currentRepo?: string;
repositoryState: IRepositoryState;
}
async function fetchBranches(options: IFetchBranchesOptions): Promise {
if (!options.accessToken || !options.repositoryState.init || !options.currentRepo) {
return {
init: false,
branches: [],
};
}
const currentRepository = options.repositoryState.repos?.filter(
o => o.full_name === options.currentRepo
)[0];
const githubClient = new GithubClient({
token: options.accessToken,
request: Container.get(IBasicRequestService),
});
const branches = await githubClient.queryAll(
{
owner: options.currentRepo.split('/')[0],
repo: options.currentRepo.split('/')[1],
protected: false,
},
githubClient.listBranch
);
return {
init: true,
branches,
default_branch: currentRepository.default_branch,
};
}
export default ({ form: { getFieldDecorator }, info, form }: Props) => {
const initInfo: Partial = info || {};
const accessToken = form.getFieldValue('accessToken');
const { data: reposResult, loading } = useFetch(() => fetchAllRepos(accessToken), [accessToken], {
auto: true,
initialState: {
data: { init: false, repos: [] },
},
onError: () => {
return {
data: { init: false, repos: [] },
};
},
});
const currentRepo = form.getFieldValue('repo');
const { data: branchResponse, loading: branchLoading } = useFetch(
() =>
fetchBranches({
accessToken,
currentRepo,
repositoryState: reposResult!,
}),
[accessToken, currentRepo, reposResult],
{
onError: () => {
return {
data: {
init: false,
branches: [],
},
};
},
auto: true,
initialState: {
data: {
init: false,
branches: [],
},
},
}
);
return (
}>
{getFieldDecorator('accessToken', {
initialValue: initInfo.accessToken,
rules: [
{
required: true,
message: (
),
},
],
})(
form.setFields({ repo: null, branch: null })}
suffix={
{locale.format({
id: 'backend.imageHosting.github.form.generateNewToken',
})}
}
>
}
/>
)}
}>
{getFieldDecorator('repo', {
initialValue: initInfo.repo,
rules: [
{
required: true,
message: ,
},
],
})(
form.setFields({ branch: null })}
disabled={loading || !reposResult?.init}
loading={loading}
options={reposResult?.repos?.map(o => {
return {
value: o.full_name,
key: o.full_name,
label: o.full_name,
};
})}
/>
)}
}
>
{getFieldDecorator('branch', {
initialValue: initInfo.branch,
})(
{
return {
value: o.name,
key: o.name,
};
})}
/>
)}
}
>
{getFieldDecorator('savePath', {
initialValue: initInfo.savePath,
rules: [
{
required: false,
},
],
})( )}
);
};
================================================
FILE: src/common/backend/imageHosting/github/index.ts
================================================
import { ImageHostingServiceMeta } from '../interface';
import Service from './service';
import Form from './form';
export default (): ImageHostingServiceMeta => {
return {
name: 'Github',
icon: 'github',
type: 'github',
form: Form,
service: Service,
permission: {
origins: ['https://api.github.com/*'],
},
};
};
================================================
FILE: src/common/backend/imageHosting/github/service.ts
================================================
import { generateUuid } from '@web-clipper/shared/lib/uuid';
import { BlobToBase64 } from '@/common/blob';
import { UploadImageRequest, ImageHostingService } from '../interface';
import { isUndefined } from 'lodash';
import { GithubClient } from '../../clients/github/client';
import Container from 'typedi';
import { IBasicRequestService } from '@/service/common/request';
import { GithubImageHostingOption } from './type';
import localeService from '@/common/locales';
export default class GithubImageHostingService implements ImageHostingService {
private config: GithubImageHostingOption;
private date: Date;
private githubClient: GithubClient;
constructor(config: GithubImageHostingOption) {
this.config = config;
this.date = new Date();
this.githubClient = new GithubClient({
token: this.config.accessToken,
request: Container.get(IBasicRequestService),
});
}
getId() {
return this.config.accessToken;
}
uploadImage = async ({ data }: UploadImageRequest) => {
return this.uploadAsBase64(data);
};
uploadImageUrl = async (url: string) => {
const imageBlob = await Container.get(IBasicRequestService).download(url);
const imageBase64 = await BlobToBase64(imageBlob);
return this.uploadAsBase64(imageBase64);
};
private generateFilename = (data: string): string => {
const matchedSuffix: any = data.match(/^data:image\/(.*);base64,/);
const suffix: string = matchedSuffix[1];
return `${generateUuid()}\.${suffix}`;
};
private uploadAsBase64 = async (data: string): Promise => {
if (!this.config.repo) {
throw new Error(
localeService.format({
id: 'backend.imageHosting.github.repo.errorMessage',
defaultMessage: 'Please config the github imageHosting again.',
})
);
}
const [username, repo] = this.config.repo.split('/');
if (isUndefined(this.config.savePath)) this.config.savePath = '';
if (this.config.savePath.startsWith('/')) this.config.savePath.substr(1);
if (!this.config.savePath.endsWith('/') && this.config.savePath.length > 0)
this.config.savePath += '/';
const fileName = this.generateFilename(data);
const folderName = this.date
.toLocaleString('chinese', { hour12: false })
.replace(new RegExp('/', 'g'), '-')
.replace(new RegExp(':', 'g'), '-');
const filteredImage = data.replace(/^data:image\/.*;base64,/, '');
const response = await this.githubClient.uploadFile({
owner: username,
repo,
branch: this.config.branch,
path: `${this.config.savePath}${folderName}/${fileName}`,
message: `Upload image "${fileName}"`,
content: filteredImage,
});
return `${response.content.html_url}?raw=true`;
};
}
================================================
FILE: src/common/backend/imageHosting/github/type.ts
================================================
export interface GithubImageHostingOption {
accessToken: string;
repo: string;
branch?: string;
savePath: string;
}
================================================
FILE: src/common/backend/imageHosting/imgur/form.tsx
================================================
import React from 'react';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input } from 'antd';
interface Props extends FormComponentProps {
info: {
clientId: string;
};
}
export default ({ form: { getFieldDecorator }, info }: Props) => {
const initInfo: Partial = info || {};
return (
{getFieldDecorator('clientId', {
initialValue: initInfo.clientId,
rules: [
{
required: true,
},
],
})( )}
);
};
================================================
FILE: src/common/backend/imageHosting/imgur/index.ts
================================================
import Form from './form';
import { ImageHostingServiceMeta } from '../interface';
import Service from './service';
export default (): ImageHostingServiceMeta => {
return {
name: 'Imgur',
icon: 'imgur',
type: 'imgur',
service: Service,
form: Form,
permission: {
origins: ['https://api.imgur.com/*'],
},
};
};
================================================
FILE: src/common/backend/imageHosting/imgur/service.ts
================================================
import { IBasicRequestService } from '@/service/common/request';
import { RequestHelper } from '@/service/request/common/request';
import { UploadImageRequest, ImageHostingService } from '../interface';
import { Base64ImageToBlob } from '@/common/blob';
import Container from 'typedi';
export interface ImgurImageHostingOption {
clientId: string;
}
export default class ImgurImageHostingService implements ImageHostingService {
private config: ImgurImageHostingOption;
constructor(config: ImgurImageHostingOption) {
this.config = config;
}
getId = () => {
return this.config.clientId;
};
uploadImage = async ({ data }: UploadImageRequest) => {
const blob = Base64ImageToBlob(data);
return this.uploadBlob(blob);
};
uploadImageUrl = async (url: string) => {
return this.uploadBlob(url);
};
private uploadBlob = async (blob: Blob | string): Promise => {
let formData = new FormData();
formData.append('image', blob);
const request = new RequestHelper({ request: Container.get(IBasicRequestService) });
const result = await request.postForm<{ data: { link: string } }>(
`https://api.imgur.com/3/image`,
{
data: formData,
headers: {
Authorization: `Client-ID ${this.config.clientId}`,
},
}
);
return result.data.link;
};
}
================================================
FILE: src/common/backend/imageHosting/interface.ts
================================================
import { Repository } from '../services/interface';
export interface ImageHostingServiceConstructAble {
new (info: any): ImageHostingService;
}
export interface ImageHostingService {
getId(): string;
uploadImage(request: UploadImageRequest): Promise;
uploadImageUrl(url: string): Promise;
updateContext?({ currentRepository }: { currentRepository: Repository }): void;
}
export interface UploadImageRequest {
data: string;
}
export interface ImageHostingServiceMeta {
name: string;
icon: string;
type: string;
service: ImageHostingServiceConstructAble;
form?: any;
support?: (type: string) => boolean;
builtIn?: boolean;
builtInRemark?: string;
permission?: chrome.permissions.Permissions;
}
export const BUILT_IN_IMAGE_HOSTING_ID = 'BUILT_IN_IMAGE_HOSTING_ID';
================================================
FILE: src/common/backend/imageHosting/joplin/index.ts
================================================
import localeService from '@/common/locales';
import { ImageHostingServiceMeta } from '../interface';
import Service from './service';
export default (): ImageHostingServiceMeta => {
return {
name: localeService.format({
id: 'backend.imageHosting.joplin.name',
}),
icon: 'joplin',
type: 'joplin',
service: Service,
builtIn: true,
builtInRemark: localeService.format({
id: 'backend.imageHosting.joplin.builtInRemark',
defaultMessage: 'Joplin built in image hosting service.',
}),
};
};
================================================
FILE: src/common/backend/imageHosting/joplin/service.ts
================================================
import { RequestHelper } from '@/service/request/common/request';
import { JoplinClient } from './../../clients/joplin/index';
import { IJoplinClient } from './../../clients/joplin/types';
import { IBasicRequestService } from '@/service/common/request';
import { Base64ImageToBlob } from '@/common/blob';
import { UploadImageRequest, ImageHostingService } from '../interface';
import Container from 'typedi';
export interface JoplinImageHostingOption {
token: string;
}
export default class JoplinImageHostingService implements ImageHostingService {
private client: IJoplinClient;
private token: string;
constructor({ token }: JoplinImageHostingOption) {
this.token = token;
const request = new RequestHelper({
baseURL: 'http://localhost:41184/',
request: Container.get(IBasicRequestService),
params: {
token: token,
},
});
this.client = new JoplinClient(request);
}
getId() {
return this.token;
}
uploadImage = async ({ data }: UploadImageRequest) => {
const blob = Base64ImageToBlob(data);
return this.client.uploadBlob(blob);
};
uploadImageUrl = async (url: string) => {
let blob: Blob = await Container.get(IBasicRequestService).download(url);
if (blob.type === 'image/webp') {
blob = blob.slice(0, blob.size, 'image/jpeg');
}
return this.client.uploadBlob(blob);
};
}
================================================
FILE: src/common/backend/imageHosting/leanote/index.ts
================================================
import localeService from '@/common/locales';
import { ImageHostingServiceMeta } from '../interface';
import Service from './service';
export default (): ImageHostingServiceMeta => {
return {
name: localeService.format({
id: 'backend.imageHosting.leanote.name',
}),
icon: 'leanote',
type: 'leanote',
service: Service,
builtIn: true,
builtInRemark: localeService.format({
id: 'backend.imageHosting.leanote.builtInRemark',
}),
};
};
================================================
FILE: src/common/backend/imageHosting/leanote/service.ts
================================================
import { IBasicRequestService } from '@/service/common/request';
import { Base64ImageToBlob } from '@/common/blob';
import { UploadImageRequest, ImageHostingService } from '../interface';
import backend from '../..';
import { message } from 'antd';
import localeService from '@/common/locales';
import Container from 'typedi';
/**
* Use leanote as image hosting service by embbeding images to note body
*/
export default class LeanoteImageHostingService implements ImageHostingService {
getId = () => {
return 'leanote';
};
uploadImage = async ({ data }: UploadImageRequest) => {
const blob = Base64ImageToBlob(data);
return this.uploadBlob(blob);
};
uploadImageUrl = async (url: string) => {
let blob: Blob = await Container.get(IBasicRequestService).download(url);
if (blob.type === 'image/webp') {
blob = blob.slice(0, blob.size, 'image/jpeg');
}
return this.uploadBlob(blob);
};
/**
* Delegate image saving to document service
*
* @param blob
*
* @return string image url once hosted
*/
private uploadBlob = async (blob: Blob): Promise => {
message.destroy();
message.warning(
localeService.format({
id: 'backend.services.leanote.warning.image.host.saving.delayed',
defaultMessage: 'Image will be attached only if the current clipping is saved',
})
);
return (backend.getDocumentService()! as any).uploadBlob(blob);
};
}
================================================
FILE: src/common/backend/imageHosting/piclist/form.tsx
================================================
import React, { Fragment } from 'react';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input } from 'antd';
interface Props extends FormComponentProps {
info: {
uploadUrl: string;
key: string;
};
}
export default ({ form: { getFieldDecorator }, info }: Props) => {
const initInfo: Partial = info || {};
return (
{getFieldDecorator('uploadUrl', {
initialValue: initInfo.uploadUrl,
rules: [
{
required: true,
},
],
})( )}
{getFieldDecorator('key', {
initialValue: initInfo.key,
})( )}
);
};
================================================
FILE: src/common/backend/imageHosting/piclist/index.ts
================================================
import Form from './form';
import { ImageHostingServiceMeta } from '../interface';
import Service from './service';
export default (): ImageHostingServiceMeta => {
return {
name: 'piclist',
icon: 'icons/piclist.png',
type: 'piclist',
service: Service,
form: Form,
permission: {
origins: [''], // often to be self-hosted
},
};
};
================================================
FILE: src/common/backend/imageHosting/piclist/service.ts
================================================
import { IBasicRequestService } from '@/service/common/request';
import { RequestHelper } from '@/service/request/common/request';
import { UploadImageRequest, ImageHostingService } from '../interface';
import { Base64ImageToBlob } from '@/common/blob';
import Container from 'typedi';
import md5 from '@web-clipper/shared/lib/md5';
export interface PiclistImageHostingOption {
uploadUrl: string;
key: string;
}
export default class PiclistImageHostingService implements ImageHostingService {
private config: PiclistImageHostingOption;
constructor(config: PiclistImageHostingOption) {
this.config = config;
}
getId = () => {
let uploadUrl = this.config.uploadUrl;
if (this.config.key) uploadUrl += `?key=${this.config.key}`;
return md5(uploadUrl); // as id
};
uploadImage = async ({ data }: UploadImageRequest) => {
const blob = Base64ImageToBlob(data);
return this.uploadBlob(blob, `web_cliper_image.png`);
};
uploadImageUrl = async (url: string) => {
const imageBlob = await Container.get(IBasicRequestService).download(url);
return this.uploadBlob(imageBlob, this._getImageFileName(url));
};
private uploadBlob = async (blob: Blob, fileName?: string): Promise => {
const request = new RequestHelper({ request: Container.get(IBasicRequestService) });
let uploadUrl = this.config.uploadUrl;
if (this.config.key) uploadUrl += `?key=${this.config.key}`;
let formData = new FormData();
formData.append('image', blob, fileName);
let result = await request.postForm<{ success: boolean; result: string[] }>(uploadUrl, {
data: formData,
});
if (!result.success) throw new Error('Upload failed');
return result.result[0];
};
private _getImageFileName(url: string) {
// 分割路径和查询参数
const queryIndex = url.indexOf('?');
const pathPart = queryIndex === -1 ? url : url.slice(0, queryIndex);
const queryPart = queryIndex === -1 ? '' : url.slice(queryIndex + 1);
// 处理路径部分
const segments = pathPart.split('/');
let lastSegment = segments.pop() || '';
// 移除可能的哈希片段
const hashIndex = lastSegment.indexOf('#');
if (hashIndex !== -1) {
lastSegment = lastSegment.slice(0, hashIndex);
}
// 检查最后一段是否为文件名
if (lastSegment.includes('.')) {
return lastSegment;
}
let fileName = "web_cliper_image"
let fileExt: string = "png";
// 解析查询参数中的后缀
const queryParams = new URLSearchParams(queryPart);
const formatKeys = ['wx_fmt', 'format', 'fm', 'type'];
for (const key of formatKeys) {
if (queryParams.has(key)) {
fileExt = queryParams.get(key) as string;
if (fileExt) {
break;
}
}
}
// 检查路径中的其他段是否有已知图片后缀
const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'];
for (const seg of segments) {
const dotIndex = seg.lastIndexOf('.');
if (dotIndex !== -1) {
const ext = seg.slice(dotIndex + 1).toLowerCase();
if (imageExts.includes(ext)) {
fileExt = ext;
break;
}
}
}
// 默认返回空字符串
return `${fileName}.${fileExt}`;
}
}
================================================
FILE: src/common/backend/imageHosting/qcloud/form.tsx
================================================
import React, { Fragment } from 'react';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input, InputNumber, Checkbox } from 'antd';
import { QcloudCosImageHostingOption } from './service';
interface Props extends FormComponentProps {
info: QcloudCosImageHostingOption;
}
export default ({ form: { getFieldDecorator }, info }: Props) => {
const initInfo: Partial = info || {};
return (
{getFieldDecorator('bucket', {
initialValue: initInfo.bucket,
rules: [
{
required: true,
},
],
})( )}
{getFieldDecorator('region', {
initialValue: initInfo.region,
rules: [
{
required: true,
},
],
})( )}
{getFieldDecorator('folder', {
initialValue: initInfo.folder,
rules: [
{
required: true,
},
],
})( )}
{getFieldDecorator('secretId', {
initialValue: initInfo.secretId,
rules: [
{
required: true,
},
],
})( )}
{getFieldDecorator('secretKey', {
initialValue: initInfo.secretKey,
rules: [
{
required: true,
},
],
})( )}
{getFieldDecorator('privateRead', {
initialValue: initInfo.privateRead,
valuePropName: 'checked',
rules: [
{
required: false,
},
],
})( )}
{getFieldDecorator('expires', {
initialValue: initInfo.expires,
rules: [
{
required: true,
},
],
})( )}
);
};
================================================
FILE: src/common/backend/imageHosting/qcloud/index.ts
================================================
import localeService from '@/common/locales';
import { ImageHostingServiceMeta } from '../interface';
import Service from './service';
import form from './form';
export default (): ImageHostingServiceMeta => {
return {
name: localeService.format({
id: 'backend.services.qcloud.name',
}),
icon: 'qcloud',
type: 'qcloud',
form,
service: Service,
permission: {
origins: ['https://*.myqcloud.com/*'],
},
};
};
================================================
FILE: src/common/backend/imageHosting/qcloud/service.ts
================================================
import { IBasicRequestService } from '@/service/common/request';
import { Base64ImageToBlob } from '@/common/blob';
import { UploadImageRequest, ImageHostingService } from '../interface';
import { generateUuid } from '@web-clipper/shared/lib/uuid';
import Container from 'typedi';
import { isUndefined } from 'lodash';
import COS from 'cos-js-sdk-v5';
export interface QcloudCosImageHostingOption {
bucket: string;
region: string;
folder: string;
secretId: string;
secretKey: string;
privateRead: boolean;
expires: number;
}
export default class QcloudCosImageHostingService implements ImageHostingService {
private config: QcloudCosImageHostingOption;
constructor(config: QcloudCosImageHostingOption) {
this.config = config;
}
getId = () => {
return this.config.bucket;
};
uploadImage = async ({ data }: UploadImageRequest) => {
console.log('uploading...');
const blob = Base64ImageToBlob(data);
return this.uploadAsBlob(blob);
};
uploadImageUrl = async (url: string) => {
const imageBlob = await Container.get(IBasicRequestService).download(url);
return this.uploadAsBlob(imageBlob);
};
private generateFilename = (blob: Blob): string => {
const matchedSuffix: any = blob.type.match(/^image\/(.*)/);
const suffix: string = matchedSuffix[1];
return `${generateUuid()}.${suffix}`;
};
private uploadAsBlob = async (blob: Blob): Promise => {
if (isUndefined(this.config.folder)) this.config.folder = '';
if (this.config.folder.startsWith('/')) this.config.folder.substr(1);
if (!this.config.folder.endsWith('/') && this.config.folder.length > 0)
this.config.folder += '/';
const fileName = this.generateFilename(blob);
const date = new Date();
const folderName = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(
2,
'0'
)}${String(date.getDate()).padStart(2, '0')}`;
let cos = new COS({
SecretId: this.config.secretId,
SecretKey: this.config.secretKey,
});
await cos.putObject({
Bucket: this.config.bucket,
Region: this.config.region,
Key: `${this.config.folder}${folderName}/${fileName}`,
Body: blob,
});
return cos.getObjectUrl(
{
Bucket: this.config.bucket,
Region: this.config.region,
Key: `${this.config.folder}${folderName}/${fileName}`,
Sign: this.config.privateRead,
Expires: this.config.expires,
},
(err, data) => {
if (err) throw err;
return data.Url;
}
);
};
}
================================================
FILE: src/common/backend/imageHosting/siyuan/index.ts
================================================
import localeService from '@/common/locales';
import { ImageHostingServiceMeta } from '../interface';
import Service from './service';
export default (): ImageHostingServiceMeta => {
return {
name: localeService.format({
id: 'backend.imageHosting.siyuan.name',
}),
icon: 'siyuan',
type: 'siyuan',
service: Service,
builtIn: true,
builtInRemark: localeService.format({
id: 'backend.imageHosting.siyuan.builtInRemark',
defaultMessage: 'Siyuan Note built in image hosting service.',
}),
};
};
================================================
FILE: src/common/backend/imageHosting/siyuan/service.ts
================================================
import { Base64ImageToBlob } from '@/common/blob';
import { Container } from 'typedi';
import { IBasicRequestService } from './../../../../service/common/request';
import { SiYuanClient } from './../../clients/siyuan/client';
import { UploadImageRequest, ImageHostingService } from '../interface';
import { Repository } from '../../services/interface';
export interface YuqueImageHostingOption {
access_token: string;
}
export default class SiYuanImageHostingService implements ImageHostingService {
private context: { currentRepository: Repository } | null = null;
private siyuan: SiYuanClient;
constructor(config: { accessToken?: string }) {
this.siyuan = new SiYuanClient({
request: Container.get(IBasicRequestService),
accessToken: config.accessToken,
});
}
getId() {
return 'siyuan';
}
uploadImage = async ({ data }: UploadImageRequest) => {
const blob = Base64ImageToBlob(data);
return this.siyuan.uploadImage(blob);
};
uploadImageUrl = async (url: string) => {
let blob: Blob = await Container.get(IBasicRequestService).download(url);
if (blob.type === 'image/webp') {
blob = blob.slice(0, blob.size, 'image/jpeg');
}
return this.siyuan.uploadImage(blob);
};
updateContext = (context: { currentRepository: Repository }) => {
this.context = context;
};
}
================================================
FILE: src/common/backend/imageHosting/sm.ms/form.tsx
================================================
import React from 'react';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input } from 'antd';
interface Props extends FormComponentProps {
info: {
secretToken: string;
};
}
export default ({ form: { getFieldDecorator }, info }: Props) => {
const initInfo: Partial = info || {};
return (
{getFieldDecorator('secretToken', {
initialValue: initInfo.secretToken,
})( )}
);
};
================================================
FILE: src/common/backend/imageHosting/sm.ms/index.ts
================================================
import { ImageHostingServiceMeta } from '../interface';
import Service from './service';
import form from './form';
export default (): ImageHostingServiceMeta => {
return {
name: 'sm.ms',
icon: 'smms',
type: 'sm.ms',
form,
service: Service,
permission: {
origins: ['https://sm.ms/*'],
},
};
};
================================================
FILE: src/common/backend/imageHosting/sm.ms/service.ts
================================================
import { IBasicRequestService } from '@/service/common/request';
import { Base64ImageToBlob } from '@/common/blob';
import { UploadImageRequest, ImageHostingService } from '../interface';
import md5 from '@web-clipper/shared/lib/md5';
import Container from 'typedi';
import { RequestHelper } from '@/service/request/common/request';
export interface YuqueImageHostingOption {
host: string;
}
export default class YuqueImageHostingService implements ImageHostingService {
private secretToken?: string;
constructor(info: { secretToken?: string }) {
this.secretToken = info.secretToken;
}
getId = () => {
return md5(this.secretToken ?? 'sm.ms');
};
uploadImage = async ({ data }: UploadImageRequest) => {
const blob = Base64ImageToBlob(data);
return this.uploadBlob(blob);
};
uploadImageUrl = async (url: string) => {
let blob: Blob = await Container.get(IBasicRequestService).download(url);
if (blob.type === 'image/webp') {
blob = blob.slice(0, blob.size, 'image/jpeg');
}
return this.uploadBlob(blob);
};
private uploadBlob = async (blob: Blob): Promise => {
let formData = new FormData();
formData.append('smfile', blob);
formData.append('ssl', 'true');
let headers: { Authorization?: string } = {};
if (this.secretToken) {
headers.Authorization = this.secretToken;
}
const request = new RequestHelper({
request: Container.get(IBasicRequestService),
headers: headers,
});
const result = await request.postForm<
{ data: { url: string } } | { code: string; success: false; images: string; message: string }
>(`https://sm.ms/api/v2/upload`, { data: formData });
if (isFail(result)) {
if (result.code !== 'image_repeated') {
throw new Error(result.message);
}
return result.images;
}
return result.data.url;
};
}
function isFail(
rs: { data: { url: string } } | { code: string; success: false; images: string }
): rs is { code: string; success: false; images: string } {
if (!(rs as { code: string; success: false; images: string }).success) {
return true;
}
return false;
}
================================================
FILE: src/common/backend/imageHosting/wiznote/index.ts
================================================
import localeService from '@/common/locales';
import { ImageHostingServiceMeta } from '../interface';
import Service from './service';
export default (): ImageHostingServiceMeta => {
return {
name: localeService.format({
id: 'backend.imageHosting.wiznote.name',
}),
icon: 'wiznote',
type: 'WizNote',
service: Service,
builtIn: true,
builtInRemark: localeService.format({
id: 'backend.imageHosting.wiznote.builtInRemark',
}),
};
};
================================================
FILE: src/common/backend/imageHosting/wiznote/service.ts
================================================
import { UploadImageRequest, ImageHostingService } from '../interface';
import { Base64ImageToBlob } from 'common/blob';
import Container from 'typedi';
import { IBasicRequestService } from '@/service/common/request';
import backend from 'common/backend';
import WizNoteDocumentService from 'common/backend/services/wiznote/service';
export interface WizImageHostingOption {
token: string;
}
export default class WizNoteImageHostingService implements ImageHostingService {
getId() {
return 'wiznote';
}
uploadImage = async ({ data }: UploadImageRequest) => {
const blob = Base64ImageToBlob(data);
return this.uploadBlob(blob);
};
uploadImageUrl = async (url: string) => {
let blob: Blob = await Container.get(IBasicRequestService).download(url);
if (blob.type === 'image/webp') {
blob = blob.slice(0, blob.size, 'image/jpeg');
}
return this.uploadBlob(blob);
};
private uploadBlob = async (blob: Blob): Promise => {
return (backend.getDocumentService()! as WizNoteDocumentService).uploadBlob(blob);
};
}
================================================
FILE: src/common/backend/imageHosting/yuque_oauth/index.ts
================================================
import localeService from '@/common/locales';
import { ImageHostingServiceMeta } from '../interface';
import Service from './service';
export default (): ImageHostingServiceMeta => {
return {
name: localeService.format({
id: 'backend.imageHosting.yuque_oauth.name',
}),
icon: 'yuque',
type: 'yuque_oauth',
service: Service,
builtIn: true,
builtInRemark: localeService.format({
id: 'backend.imageHosting.yuque_oauth.builtInRemark',
}),
};
};
================================================
FILE: src/common/backend/imageHosting/yuque_oauth/service.ts
================================================
import { IBasicRequestService } from '@/service/common/request';
import { Base64ImageToBlob } from '@/common/blob';
import { UploadImageRequest, ImageHostingService } from '../interface';
import md5 from '@web-clipper/shared/lib/md5';
import { extend } from 'umi-request';
import localeService from '@/common/locales';
import Container from 'typedi';
const request = extend({});
request.interceptors.response.use(
response => {
const codeMaps: {
[code: number]: string;
} = {
429: localeService.format({
id: 'backend.imageHosting.yuque_oauth.error_429',
}),
401: localeService.format({
id: 'backend.imageHosting.yuque_oauth.error_401',
}),
403: localeService.format({
id: 'backend.imageHosting.yuque_oauth.error_403',
}),
};
if (codeMaps[response.status]) {
throw new Error(codeMaps[response.status]);
}
return response;
},
{ global: false }
);
export interface YuqueImageHostingOption {
access_token: string;
}
const HOST = 'https://www.yuque.com';
const BASE_URL = `${HOST}/api/v2/`;
export default class YuqueImageHostingService implements ImageHostingService {
private accessToken: string;
constructor({ access_token }: YuqueImageHostingOption) {
this.accessToken = access_token;
}
getId() {
return md5(this.accessToken);
}
uploadImage = async ({ data }: UploadImageRequest) => {
const blob = Base64ImageToBlob(data);
return this.uploadBlob(blob);
};
uploadImageUrl = async (url: string) => {
let blob: Blob = await Container.get(IBasicRequestService).download(url);
if (blob.type === 'image/webp') {
blob = blob.slice(0, blob.size, 'image/jpeg');
}
return this.uploadBlob(blob);
};
private uploadBlob = async (blob: Blob): Promise => {
let formData = new FormData();
formData.append('file', blob, 'file.png');
const result = await request.post(`${BASE_URL}upload/attach`, {
data: formData,
requestType: 'form',
headers: {
'X-Auth-Token': this.accessToken,
},
});
return result.data.url;
};
}
================================================
FILE: src/common/backend/index.ts
================================================
import {
ServiceMeta,
ImageHostingServiceMeta,
ImageHostingService,
DocumentService,
} from './interface';
export * from './interface';
const serviceContext = require.context('./services', true, /index.ts$/);
const getServices = (): ServiceMeta[] => {
return serviceContext.keys().map(key => {
return serviceContext(key).default() as ServiceMeta;
});
};
const imageHostingContext = require.context('./imageHosting', true, /index.ts$/);
const getImageHostingServices = (): ImageHostingServiceMeta[] => {
return imageHostingContext.keys().map(key => {
return imageHostingContext(key).default() as ImageHostingServiceMeta;
});
};
export function documentServiceFactory(type: string, info?: any) {
const service = getServices().find(o => o.type === type);
if (service) {
const Service = service.service;
return new Service(info);
}
throw new Error('unSupport type');
}
export function imageHostingServiceFactory(type: string, info?: any) {
const service = getImageHostingServices().find(o => o.type === type);
if (service) {
const Service = service.service;
return new Service(info);
}
throw new Error('un support image hosting type');
}
export { getServices, getImageHostingServices };
export class BackendContext {
private documentService?: DocumentService;
private imageHostingService?: ImageHostingService;
setDocumentService(documentService: DocumentService) {
this.documentService = documentService;
}
getDocumentService() {
return this.documentService;
}
setImageHostingService(imageHostingService: ImageHostingService) {
this.imageHostingService = imageHostingService;
}
getImageHostingService() {
return this.imageHostingService;
}
}
export default new BackendContext();
================================================
FILE: src/common/backend/interface.ts
================================================
export * from './imageHosting/interface';
export * from './services/interface';
================================================
FILE: src/common/backend/services/baklib/complete.tsx
================================================
import React from 'react';
export default ({ status: { edit_url } }: any) => {
return (
);
};
================================================
FILE: src/common/backend/services/baklib/form.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/es/form';
import React, { Fragment } from 'react';
import { BaklibBackendServiceConfig } from './interface';
import useOriginForm from '@/hooks/useOriginForm';
import { FormattedMessage } from 'react-intl';
interface BaklibFormProps {
verified?: boolean;
info?: BaklibBackendServiceConfig;
}
const FormItem: React.FC = props => {
const {
form,
form: { getFieldDecorator },
info,
verified,
} = props;
const { verified: formVerified, handleAuthentication, formRules } = useOriginForm({
form,
initStatus: !!info,
});
let initData: Partial = {};
if (info) {
initData = info;
}
let editMode = info ? true : false;
return (
{getFieldDecorator('origin', {
initialValue: initData.origin || 'https://www.baklib.com',
rules: [
{
required: true,
message: 'Host is required!',
},
...formRules,
],
})(
}
disabled={editMode || formVerified}
onSearch={handleAuthentication}
/>
)}
{getFieldDecorator('accessToken', {
initialValue: initData.accessToken,
rules: [
{
required: true,
message: 'AccessToken is required!',
},
],
})( )}
);
};
export default FormItem;
================================================
FILE: src/common/backend/services/baklib/headerForm.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { TreeSelect } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment, useEffect } from 'react';
import locale from '@/common/locales';
import { Repository } from '../interface';
import { useFetch } from '@shihengtech/hooks';
import backend from '../..';
import BaklibDocumentService from './service';
const HeaderForm: React.FC = ({
form: { getFieldDecorator, setFieldsValue, getFieldValue },
currentRepository,
}) => {
const service = backend.getDocumentService() as BaklibDocumentService;
const channals = useFetch(() => {
if (currentRepository) {
return service.getTentChannel(currentRepository.id);
}
return [];
}, [currentRepository]);
useEffect(() => {
setFieldsValue({
channel: null,
});
}, [currentRepository, setFieldsValue]);
useEffect(() => {
if (Array.isArray(channals.data) && channals.data.length > 0 && !getFieldValue('channel')) {
setFieldsValue({
channel: channals.data[0].value,
});
}
}, [channals.data, getFieldValue, setFieldsValue]);
return (
{getFieldDecorator('channel', {
rules: [],
})(
)}
);
};
export default HeaderForm;
================================================
FILE: src/common/backend/services/baklib/index.ts
================================================
import { ServiceMeta } from './../interface';
import Service from './service';
import Form from './form';
import localeService from '@/common/locales';
import headerForm from './headerForm';
import complete from './complete';
export default (): ServiceMeta => {
return {
name: localeService.format({
id: 'backend.services.baklib.name',
}),
complete,
icon: 'baklib',
type: 'baklib',
service: Service,
form: Form,
headerForm,
homePage: 'https://www.baklib.com/',
};
};
================================================
FILE: src/common/backend/services/baklib/interface.ts
================================================
export interface BaklibBackendServiceConfig {
accessToken: string;
origin: string;
}
export interface BaklibTenantsResponse {
current_tenants: { id: string; name: string; member_role: string[] }[];
share_tenants: { id: string; name: string; member_role: string[] }[];
}
================================================
FILE: src/common/backend/services/baklib/service.ts
================================================
import { DocumentService, CreateDocumentRequest } from './../../index';
import { extend, RequestMethod } from 'umi-request';
import md5 from '@web-clipper/shared/lib/md5';
import { BaklibBackendServiceConfig, BaklibTenantsResponse } from './interface';
import { CompleteStatus, Repository } from '../interface';
interface Channel {
id: string;
name: string;
ordinal: number;
child_channels: Channel[];
}
export default class BaklibDocumentService implements DocumentService {
private request: RequestMethod;
private token: string;
private cache: Map;
private origin: string;
constructor({ accessToken, origin }: BaklibBackendServiceConfig) {
const realHost = origin || 'https://www.baklib.com';
this.request = extend({
prefix: `${realHost}/api/`,
headers: { Authorization: `Bearer ${accessToken}` },
timeout: 5000,
});
this.request.interceptors.response.use(
async response => {
const json = await response.clone().json();
if (json.code !== 0) {
throw new Error(json.message);
}
return response;
},
{ global: false }
);
this.token = accessToken;
this.cache = new Map();
this.origin = origin;
}
getId = () => md5(this.token);
getUserInfo = async () => {
return {
name: 'Baklib',
avatar: '',
homePage: `${this.origin}/-/groups`,
description: 'Baklib',
};
};
getRepositories = async () => {
const {
message: { current_tenants, share_tenants },
} = await this.request.get<{
message: BaklibTenantsResponse;
}>('v1/tenants');
function tenantToRepo(tenants: any, groupName: string) {
return tenants.map(
({ id, name, member_role }: any): Repository => {
const readOnly = Array.isArray(member_role) && member_role[0] === '只能阅读';
return {
id,
name: readOnly ? `${name} (只读)` : name,
disabled: readOnly,
groupId: groupName,
groupName,
};
}
);
}
return tenantToRepo(current_tenants, '我的站点').concat(
tenantToRepo(share_tenants, '共享站点')
);
};
async getTentChannel(tenant_id: string) {
if (this.cache.has(tenant_id)) {
return this.cache.get(tenant_id);
}
const response = await this.request.get<{
message: Channel[];
}>(`v1/channels?tenant_id=${tenant_id}`);
const { message } = response;
function channelToTree(tree: Channel[], parent: string): any {
tree.sort((a, b) => a.ordinal - b.ordinal);
return tree.map((o, index) => ({
title: o.name,
value: o.id,
key: `${parent}-${index}`,
children: channelToTree(o.child_channels, `${parent}-${index}`),
}));
}
this.cache.set(tenant_id, channelToTree(message, '0'));
return channelToTree(message, '0');
}
createDocument = async (
info: CreateDocumentRequest & {
channel: string;
status: number;
}
): Promise => {
const response = await this.request.post<{
message: {
id: string;
frontend_url: string;
edit_url: string;
};
}>('v1/articles', {
data: {
content_type: 'markdown',
tenant_id: info.repositoryId,
name: info.title,
channel_id: info.channel,
content: info.content,
status: 1,
},
});
return {
href: response.message.frontend_url,
edit_url: response.message.edit_url,
};
};
}
================================================
FILE: src/common/backend/services/bear/form.tsx
================================================
import React from 'react';
import { FormattedMessage } from 'react-intl';
export default () => (
);
================================================
FILE: src/common/backend/services/bear/index.ts
================================================
import Service from './service';
import Form from './form';
export default () => {
return {
name: 'Bear',
icon: 'bear',
type: 'bear',
service: Service,
form: Form,
homePage: 'https://bear.app/',
};
};
================================================
FILE: src/common/backend/services/bear/service.ts
================================================
import { CompleteStatus } from 'common/backend/interface';
import { DocumentService, CreateDocumentRequest } from '../../index';
export default class GithubDocumentService implements DocumentService {
getId = () => {
return 'bear';
};
getUserInfo = async () => {
return {
name: 'BEAR',
avatar: '',
homePage: 'bear://x-callback-url/search',
description: 'Bear app',
};
};
getRepositories = async () => {
return [
{
id: 'bear',
name: 'Bear',
groupId: 'bear',
groupName: 'Bear',
},
];
};
createDocument = async (info: CreateDocumentRequest): Promise => {
const url = `bear://x-callback-url/create?title=${encodeURIComponent(
info.title
)}&text=${encodeURIComponent(info.content)}&open_note=no`;
window.location.href = url;
return {
href: `bear://x-callback-url/open-note?title=${info.title}`,
};
};
}
================================================
FILE: src/common/backend/services/buildin/index.ts
================================================
import { ServiceMeta } from '@/common/backend';
import Service from './service';
export const buildinOrigin = 'https://buildin.ai';
export default (): ServiceMeta => {
return {
name: 'Buildin.AI',
icon: 'https://cdn.buildin.ai/s3-public/8ebf3bb6-08c9-40b1-93d5-6d5c5c2fe49c/logo.svg',
type: 'buildin',
homePage: 'https://buildin.ai/',
service: Service,
permission: {
origins: [`${buildinOrigin}/*`, ''],
permissions: ['cookies'],
},
};
};
================================================
FILE: src/common/backend/services/buildin/service.ts
================================================
import { CompleteStatus, UnauthorizedError } from '../interface';
import { DocumentService, CreateDocumentRequest } from '../../index';
import localeService from '@/common/locales';
import { extend, RequestMethod } from 'umi-request';
import { IWebRequestService, WebBlockHeader } from '@/service/common/webRequest';
import Container from 'typedi';
import { ICookieService } from '@/service/common/cookie';
import {
BuildinToc,
BuildinRepository,
BuildinSpace,
BuildinUserInfo,
Block,
OSSInfo,
TaskResult,
BuildinResponse,
ROLE_WEIGHT,
Share,
} from './type';
import { generateUuid } from '@web-clipper/shared/lib/uuid';
import { buildinOrigin } from '.';
import showdown from 'showdown';
const converter = new showdown.Converter({});
export default class BuildinDocumentService implements DocumentService {
private request: RequestMethod;
private repositories: BuildinRepository[];
private userSpaces?: BuildinSpace;
private tocPageBlocks?: Record;
private userInfo?: BuildinUserInfo;
private webRequestService: IWebRequestService;
private cookieService: ICookieService;
constructor() {
const request = extend({
prefix: `${buildinOrigin}/api/`,
timeout: 10000,
credentials: 'include',
});
this.request = request;
this.repositories = [];
this.webRequestService = Container.get(IWebRequestService);
this.cookieService = Container.get(ICookieService);
request.interceptors.response.use(
response => {
if (response.status === 401) {
throw new UnauthorizedError(
localeService.format({
id: 'backend.services.buildin.unauthorizedErrorMessage',
defaultMessage: 'Unauthorized! Please Login Buildin.ai Web.',
})
);
}
return response;
},
{ global: false }
);
}
getId = () => {
return 'Buildin.AI';
};
getUserInfo = async () => {
if (!this.userInfo) {
const res = await this.fetchUserInfo();
this.userInfo = res.data;
}
const { nickname, avatar, ext } = this.userInfo;
return {
name: nickname,
avatar: avatar?.startsWith('http') ? avatar : getImageCdnUrl(avatar),
homePage: 'https://buildin.ai',
description: ext?.email?.email,
};
};
getRepositories = async () => {
if (!this.userInfo) {
const res = await this.fetchUserInfo();
this.userInfo = res.data;
}
if (!this.userSpaces) {
const res = await this.getUserSpaces();
this.userSpaces = res.data;
}
const { spaceViews, spaces } = this.userSpaces;
if (!spaceViews || !spaces) {
this.repositories = [];
return [];
}
const result: BuildinRepository[] = [];
//fetch spaces
const userSpaces = Object.values(spaceViews)
.filter(spaceView => spaces[spaceView.spaceId])
.map(spaceView => spaces[spaceView.spaceId]);
if (!this.tocPageBlocks) {
const allPromise = userSpaces.map(space => {
return this.getSpaceRoot(space.uuid);
});
const allToc = await Promise.all(allPromise);
this.tocPageBlocks = allToc.reduce((pre, cur) => {
if (!cur.data.blocks) return pre;
Object.values(cur.data.blocks).forEach(b => {
//the pages which can be saved
if ([0, 18, 19].includes(b.type)) {
pre[b.uuid] = b;
}
});
return pre;
}, {} as Record);
userSpaces.forEach(sp => {
sp.subNodes.forEach(id => {
const block = this.tocPageBlocks?.[id];
if (!block) return;
if (block.permissions.some(o => o.type === 'illegal')) return;
if (block.permissions.length === 0) return;
const { role } = getPermission(block, this.userInfo?.uuid!, sp.permissionGroups ?? []);
if (role === 'editor' || role === 'writer') {
const spaceId = block.spaceId ?? sp.uuid;
let groupName = sp.title;
result.push({
id: block.uuid,
name: block.title || 'Untitled',
groupId: spaceId,
groupName,
});
}
});
});
}
this.repositories = result;
return result;
};
createDocument = async ({
repositoryId,
title,
content,
}: CreateDocumentRequest): Promise => {
const repository = this.repositories.find(o => o.id === repositoryId);
if (!repository) {
throw new Error('Illegal repository');
}
const documentId = await this.createEmptyPage(repository, title);
const html = `
${title}
${converter.makeHtml(`${content}`)}
`;
const ossInfo = await this.requestWithCookie>(header => {
return this.request.post(`import_temp_file?source=web-clipper`, {
headers: {
[header.name]: header.value,
},
data: {
content: html,
extName: 'html',
},
});
});
if (ossInfo.code !== 200) {
throw new Error('upload md content failed');
}
//call import api
const res = await this.requestWithCookie>(header => {
return this.request.post('enqueueTask', {
headers: {
[header.name]: header.value,
},
data: {
eventName: 'import',
request: {
blockId: documentId,
spaceId: repository.groupId,
importOptions: {
type: 'html',
ossName: ossInfo.data.ossName,
},
},
},
});
});
if (!res.data.taskId) {
throw new Error('enqueueTask failed');
}
const taskId = res.data.taskId;
const waitResult = async () => {
await sleep(2000);
const res = await this.requestWithCookie>(header => {
return this.request.post('getTasks', {
headers: {
[header.name]: header.value,
},
data: {
taskIds: [taskId],
},
});
});
if (res.code !== 200) {
throw new Error('getTasks failed');
}
const result = res.data.results[taskId];
if (result && result.status === 'success') {
if (result.result?.status === 'success') {
// do nothing
} else if (result.result?.msg) {
throw new Error(result.result?.msg);
}
} else {
await waitResult();
}
};
await waitResult();
this.changeTitle(documentId, repository.groupId, title);
return {
href: `${buildinOrigin}/${documentId}`,
};
};
createEmptyPage = async (repository: BuildinRepository, title: string) => {
if (!this.tocPageBlocks) {
throw new Error('Illegal tocBlocks');
}
const documentId = generateUuid();
const parentId = repository.id;
const spaceId = repository.groupId;
const blocks = this.tocPageBlocks;
if (!blocks) {
throw new Error('Illegal blocks');
}
const subNodes = blocks[parentId].subNodes;
const after = subNodes[subNodes.length - 1];
const operations = {
requestId: generateUuid(),
transactions: [
{
id: generateUuid(),
spaceId,
operations: [
{
id: documentId,
path: [],
command: 'set',
table: 'block',
args: {
uuid: documentId,
spaceId,
parentId,
textColor: '',
backgroundColor: '',
type: 0,
status: 1,
permissions: [],
updateBy: this.userInfo?.uuid,
updateAt: Date.now(),
data: {
segments: [{ type: 0, text: title, enhancer: {} }],
},
},
},
{
id: parentId,
command: 'listAfter',
path: ['subNodes'],
table: 'block',
args: {
uuid: documentId,
after,
},
},
],
},
],
};
await this.requestWithCookie(header => {
return this.request.post('blocks/transactions', {
data: operations,
headers: {
[header.name]: header.value,
},
});
});
return documentId;
};
private changeTitle = async (documentId: string, spaceId: string, title: string) => {
const operations = {
requestId: generateUuid(),
transactions: [
{
id: generateUuid(),
spaceId,
operations: [
{
id: documentId,
path: ['data'],
command: 'update',
table: 'block',
args: {
segments: [{ type: 0, text: title, enhancer: {} }],
},
},
],
},
],
};
await this.requestWithCookie(header => {
return this.request.post('blocks/transactions', {
data: operations,
headers: {
[header.name]: header.value,
},
});
});
};
private getUserSpaces = async () => {
return this.requestWithCookie>(header => {
return this.request.get(`users/${this.userInfo?.uuid}/root`, {
headers: {
[header.name]: header.value,
},
});
});
};
private getSpaceRoot = async (spaceId: string) => {
return this.requestWithCookie(header => {
return this.request.get(`spaces/${spaceId}/root`, {
headers: {
[header.name]: header.value,
},
});
});
};
private fetchUserInfo = async () => {
return this.requestWithCookie>(header => {
return this.request.get('users/me', {
headers: {
[header.name]: header.value,
},
});
});
};
/**
* Modify the cookie when request
*/
private requestWithCookie = async (
requestFunction: (header: WebBlockHeader) => Promise
) => {
const cookies = await this.cookieService.getAll({
url: buildinOrigin,
});
const cookieString = cookies.map(o => `${o.name}=${o.value}`).join(';');
const header = await this.webRequestService.startChangeHeader({
urls: [`${buildinOrigin}*`],
requestHeaders: [
{
name: 'cookie',
value: cookieString,
},
],
});
try {
const result = await requestFunction(header);
await this.webRequestService.end(header);
return result;
} catch (error) {
await this.webRequestService.end(header);
throw error;
}
};
}
const compressImageSupport = /^(jpg|jpeg|png|bmp|webp|tiff)$/i;
function getImageCdnUrl(ossName?: string) {
if (!ossName) return '';
const index = ossName.lastIndexOf('.');
const extName = ossName.substring(index + 1);
let imgProcess = '';
if (compressImageSupport.test(extName.toLocaleLowerCase())) {
imgProcess = `img_process=/resize,w_${500 * Math.ceil(window.devicePixelRatio)}/quality,q_80/`;
}
return `https://cdn.buildin.ai/${ossName}?${imgProcess}`;
}
const sleep = (durationInMs: number): Promise => {
return new Promise(resolve => {
setTimeout(resolve, durationInMs);
});
};
const getPermission = (block: Block, userId: string, permissionGroups: any[]) => {
const data: Share = {
shared: false,
illegal: false,
isRestricted: false,
allowDuplicate: true,
permissions: [],
role: 'none',
roleWithoutPublic: 'none',
};
if (block.permissions.length) {
const getBiggerRole = (
type: keyof Block['permissions'][0],
value: any,
role?: keyof typeof ROLE_WEIGHT
) => {
const permissions = block.permissions.find(p => p[type] === value);
if (
permissions &&
role &&
permissions.role &&
ROLE_WEIGHT[permissions.role] > ROLE_WEIGHT[role]
) {
return permissions;
}
};
const newPermissions = block.permissions
.filter(o => {
return o.type !== 'illegal' && o.type !== 'restricted';
})
.map(o => {
if (o.type === 'space') {
return getBiggerRole('type', o.type, o.role) || o;
}
if (o.type === 'group') {
return getBiggerRole('groupId', o.groupId, o.role) || o;
}
if (o.type === 'user') {
return getBiggerRole('userId', o.userId, o.role) || o;
}
return o;
});
const diffPermissions = block.permissions.filter(o => {
if (o.type === 'illegal' || o.type === 'restricted') {
return false;
}
if (o.type === 'space' || o.type === 'public') {
return newPermissions.every(p => p.type !== o.type);
}
if (o.type === 'group') {
return newPermissions.every(p => p.groupId !== o.groupId);
}
return newPermissions.every(p => p.userId !== o.userId);
});
data.permissions = [...newPermissions, ...diffPermissions];
const ownPermission = data.permissions.find(p => p.userId === userId);
const groupPermissions = data.permissions.filter(p => {
const group = permissionGroups?.find(g => g.id === p.groupId);
return group?.userIds.includes(userId);
});
const allPermissions = [ownPermission, ...groupPermissions];
const spacePermission = block.permissions.find(p => p.type === 'space');
allPermissions.push(spacePermission);
data.roleWithoutPublic = allPermissions.reduce(
(pre: keyof typeof ROLE_WEIGHT, permission: Block['permissions'][0] | undefined) => {
if (!permission) return pre;
const role = permission.role ?? 'none';
return ROLE_WEIGHT[role] > ROLE_WEIGHT[pre] ? role : pre;
},
'none'
);
data.role = data.roleWithoutPublic;
}
return data;
};
================================================
FILE: src/common/backend/services/buildin/type.ts
================================================
import { Repository } from '../interface';
interface Space {
uuid: string;
title: string;
subNodes: string[];
permissionGroups?: any[];
}
interface SpaceView {
uuid: string;
spaceId: string;
title: string;
}
type BlockType = number;
export interface Block {
uuid: string;
spaceId: string;
parentId: string;
type: BlockType;
title: string;
subNodes: string[];
permissions: {
type: string;
role?: keyof typeof ROLE_WEIGHT;
userId?: string;
groupId?: string;
}[];
}
export interface BuildinSpace {
spaces: Record;
spaceViews: Record;
}
export interface OSSInfo {
ossName: string;
}
export interface BuildinToc {
data: {
blocks?: Record;
};
}
export interface BuildinUserInfo {
uuid: string;
phone: string;
nickname: string;
backgroundColor: string;
spaceViews: string;
avatar?: string;
ext?: {
email?: {
id: string;
email: string;
};
};
}
export interface BuildinRepository extends Repository {}
export interface TaskResult {
results: Record<
string,
{
taskId: string;
eventName: string;
status: string;
result?: {
status?: string;
url?: string;
size?: number;
ossName?: string;
uuid?: string;
msg?: string;
};
}
>;
}
export interface BuildinResponse {
msg: string;
code: number;
data: DATA;
}
export const ROLE_WEIGHT = {
none: 0,
reader: 1,
writer: 2,
editor: 3,
commenter: 4,
};
export interface Share {
shared: boolean;
title?: string;
illegal: boolean;
parentId?: string;
isRestricted: boolean;
/** 允许复制、打印、下载 */
allowDuplicate: boolean;
permissions: Block['permissions'];
role: keyof typeof ROLE_WEIGHT;
roleWithoutPublic: keyof typeof ROLE_WEIGHT;
}
================================================
FILE: src/common/backend/services/confluence/form.tsx
================================================
import React from 'react';
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input, Select } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import { useFetch } from '@shihengtech/hooks';
import { extend } from 'umi-request';
import {
ConfluenceListResult,
ConfluenceSpace,
ConfluenceServiceConfig,
} from '@/common/backend/services/confluence/interface';
import { FormattedMessage } from 'react-intl';
import useOriginForm from '@/hooks/useOriginForm';
interface ConfluenceFormProps extends FormComponentProps {
info?: ConfluenceServiceConfig;
}
const ConfluenceForm: React.FC = ({ form, info }) => {
const { verified, handleAuthentication, formRules } = useOriginForm({ form, initStatus: !!info });
const host = form.getFieldValue('origin');
const spaces = useFetch(
async () => {
if (!verified) {
return [];
}
const request = extend({
prefix: host,
});
const spaceList = await request.get>(`/rest/api/space`);
return spaceList.results;
},
[host, verified],
{
initialState: {
data: [],
},
}
);
return (
}
>
{form.getFieldDecorator('origin', {
initialValue: info?.origin,
rules: formRules,
})(
}
onSearch={handleAuthentication}
disabled={verified}
/>
)}
{verified && (
}
>
{form.getFieldDecorator('spaceId', {
initialValue: info?.spaceId,
rules: [
{
required: true,
},
],
})(
{spaces
.data!.filter(o => !!o._expandable.homepage)
.map(o => {
const spaceHomePage = o._expandable.homepage!.split('/');
return (
{o.name}
);
})}
)}
)}
);
};
export default ConfluenceForm;
================================================
FILE: src/common/backend/services/confluence/index.ts
================================================
import Service from './service';
import Form from './form';
export default () => {
return {
name: 'Confluence',
icon: 'confluence',
type: 'confluence',
service: Service,
form: Form,
};
};
================================================
FILE: src/common/backend/services/confluence/interface.ts
================================================
export interface ConfluenceListResult {
results: T[];
start: number;
limit: number;
size: number;
}
export interface ConfluenceSpace {
id: number;
name: string;
type: string;
_expandable: {
homepage?: string;
};
}
export interface ConfluencePage {
id: string;
title: string;
type: string;
}
export interface ConfluenceServiceConfig {
origin: string;
spaceId: number;
}
export interface ConfluenceSpace {
id: number;
name: string;
type: string;
}
export interface ConfluenceUserInfo {
displayName: string;
profilePicture: {
path: string;
};
}
/**
* Response of rest/api/content/:id
*/
export interface ConfluenceSpaceContent {
space: {
key: string;
id: number;
name: string;
};
}
================================================
FILE: src/common/backend/services/confluence/service.ts
================================================
import md5 from '@web-clipper/shared/lib/md5';
import {
ConfluenceServiceConfig,
ConfluenceUserInfo,
ConfluenceListResult,
ConfluencePage,
ConfluenceSpaceContent,
} from '@/common/backend/services/confluence/interface';
import { DocumentService } from '../../index';
import { extend, RequestMethod } from 'umi-request';
import { Repository, CreateDocumentRequest, CompleteStatus } from '../interface';
import showdown from 'showdown';
const converter = new showdown.Converter();
export default class GithubDocumentService implements DocumentService {
private config: ConfluenceServiceConfig;
private request: RequestMethod;
constructor(config: ConfluenceServiceConfig) {
this.config = config;
this.request = extend({
prefix: `${this.config.origin}/rest/api/`,
});
}
getId = () => {
return md5(`${this.config.origin}:${this.config.spaceId}`);
};
getUserInfo = async () => {
const response = await this.request.get('user/current');
return {
name: response.displayName,
avatar: `${this.config.origin}${response.profilePicture.path}`,
homePage: '',
description: 'Confluence user',
};
};
getRepositories = async (): Promise => {
const confluenceSpaceContent = await this.request.get(
`content/${this.config.spaceId}`
);
const response = await this.request.get>(
`content/${this.config.spaceId}/child/page`
);
return response.results.map(({ id, title }) => ({
id: id,
name: title,
groupId: confluenceSpaceContent.space.key,
groupName: confluenceSpaceContent.space.name,
}));
};
createDocument = async (req: CreateDocumentRequest): Promise => {
const confluenceSpaceContent = await this.request.get(
`content/${this.config.spaceId}`
);
const response = await this.request.post<{
_links: {
webui: string;
};
}>('content', {
data: {
type: 'page',
title: req.title,
ancestors: [{ id: req.repositoryId }],
space: { key: confluenceSpaceContent.space.key },
body: { storage: { value: converter.makeHtml(req.content), representation: 'storage' } },
},
});
return {
href: `${this.config.origin}${response._links.webui}`,
};
};
}
================================================
FILE: src/common/backend/services/dida365/headerForm.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Select } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment } from 'react';
import backend from '../..';
import Dida365DocumentService from './service';
import locale from '@/common/locales';
import { useFetch } from '@shihengtech/hooks';
const HeaderForm: React.FC = ({ form: { getFieldDecorator } }) => {
const service = backend.getDocumentService() as Dida365DocumentService;
const tagsResponse = useFetch(async () => service.getTags(), [service], {
initialState: {
data: [],
},
});
return (
{getFieldDecorator('tags', {
initialValue: [],
})(
{tagsResponse.data?.map(o => (
{o}
))}
)}
);
};
export default HeaderForm;
================================================
FILE: src/common/backend/services/dida365/index.ts
================================================
import localeService from '@/common/locales';
import { ServiceMeta } from '@/common/backend';
import Service from './service';
import headerForm from './headerForm';
export default (): ServiceMeta => {
return {
name: localeService.format({
id: 'backend.services.dida365.name',
}),
icon: 'dida365',
type: 'dida365',
headerForm,
service: Service,
permission: {
origins: ['https://api.dida365.com/*'],
permissions: [],
},
};
};
================================================
FILE: src/common/backend/services/dida365/service.ts
================================================
import { Container } from 'typedi';
import { generateUuid } from '@web-clipper/shared/lib/uuid';
import localeService from '@/common/locales';
import { IWebRequestService } from '@/service/common/webRequest';
import {
CreateDocumentRequest,
CompleteStatus,
UnauthorizedError,
Repository,
} from '@/common/backend/services/interface';
import { DocumentService } from '@/common/backend/index';
import { extend, RequestMethod } from 'umi-request';
interface Dida365Profile {
name: string;
username: string;
picture: string;
}
interface Dida365CheckResponse {
projectProfiles: {
id: string;
name: string;
isOwner: boolean;
closed: boolean;
groupId: string;
}[];
projectGroups?: {
id: string;
name: string;
}[];
tags?: {
name: string;
}[];
}
interface Dida365CreateDocumentRequest extends CreateDocumentRequest {
tags: string[];
}
export default class Dida365DocumentService implements DocumentService {
private request: RequestMethod;
constructor() {
const request = extend({
prefix: `https://api.dida365.com/api/v2/`,
});
request.interceptors.response.use(
(response) => {
if (response.clone().status === 401) {
throw new UnauthorizedError(
localeService.format({
id: 'backend.services.dida365.unauthorizedErrorMessage',
defaultMessage: 'Unauthorized! Please Login Dida365 Web.',
})
);
}
return response;
},
{ global: false }
);
this.request = request;
}
getId = () => {
return 'dida365';
};
getUserInfo = async () => {
const response = await this.request.get('user/profile');
return {
name: response.name,
avatar: response.picture,
homePage: '',
description: response.username,
};
};
getTags = async (): Promise => {
const dida365CheckResponse = await this.request.get(`batch/check/0`);
return dida365CheckResponse.tags.map((o) => o.name);
};
getRepositories = async (): Promise => {
const dida365CheckResponse = await this.request.get(`batch/check/0`);
const groupMap = new Map();
if (dida365CheckResponse.projectGroups) { // 检查 projectGroups 是否存在
dida365CheckResponse.projectGroups.forEach((group) => {
groupMap.set(group.id, group.name);
});
}
return dida365CheckResponse.projectProfiles
.filter((o) => !o.closed)
.map(({ id, name, groupId }) => ({
id: id,
name: name,
groupId: groupId
? groupId
: localeService.format({
id: 'backend.services.dida365.rootGroup',
defaultMessage: 'Root',
}),
groupName: groupId
? groupMap.get(groupId)!
: localeService.format({
id: 'backend.services.dida365.rootGroup',
defaultMessage: 'Root',
}),
}));
};
createDocument = async (request: Dida365CreateDocumentRequest): Promise => {
const webRequestService = Container.get(IWebRequestService);
const header = await webRequestService.startChangeHeader({
urls: ['https://api.dida365.com/*'],
requestHeaders: [
{
name: 'origin',
value: 'https://dida365.com',
},
],
});
const settings = await this.request.get<{ timeZone: string }>(
await webRequestService.changeUrl('user/preferences/settings?includeWeb=true', header)
);
const id = generateUuid().replace(/-/g, '').slice(0, 24);
const data = {
add: [
{
items: [],
reminders: [],
exDate: [],
dueDate: null,
priority: 0,
progress: 0,
assignee: null,
kind: 'NOTE',
sortOrder: -4611733297427382000,
startDate: null,
isFloating: false,
status: 0,
deleted: 0,
tags: request.tags,
projectId: request.repositoryId,
title: request.title,
content: request.content,
timeZone: settings.timeZone,
id: id,
},
],
update: [],
delete: [],
};
await this.request.post(await webRequestService.changeUrl('batch/task', header), {
data: data,
headers: {
[header.name]: header.value,
},
});
await webRequestService.end(header);
return {
href: `https://dida365.com/#p/${request.repositoryId}/tasks/${id}`,
};
};
}
================================================
FILE: src/common/backend/services/flomo/index.ts
================================================
import Service from './service';
export default () => {
return {
name: 'Flomo',
icon: 'flomo',
type: 'flomo',
service: Service,
homePage: 'https://flomoapp.com/',
permission: {
origins: ['https://flomoapp.com/*'],
permissions: ['cookies'],
},
};
};
================================================
FILE: src/common/backend/services/flomo/service.ts
================================================
import { IBasicRequestService } from '@/service/common/request';
import { CompleteStatus } from 'common/backend/interface';
import Container from 'typedi';
import { DocumentService, CreateDocumentRequest } from '../../index';
import showdown from 'showdown';
import { ICookieService } from '@/service/common/cookie';
import locale from '@/common/locales';
export default class GithubDocumentService implements DocumentService {
getId = () => {
return 'Flomo';
};
getUserInfo = async () => {
return {
name: 'Flomo',
avatar: '',
homePage: 'https://flomoapp.com/',
description: 'Flomo',
};
};
getRepositories = async () => {
/**
* Check Login
*/
await this.getXSRFToken();
return [
{
id: 'flomo',
name: 'Flomo',
groupId: 'flomo',
groupName: 'Flomo',
},
];
};
createDocument = async (info: CreateDocumentRequest): Promise => {
const request = Container.get(IBasicRequestService);
const converter = new showdown.Converter({});
converter.addExtension({
type: 'html',
filter: (html: string) => {
return html.replace(/ /g, '$1
');
},
});
const XSRFToken = await this.getXSRFToken();
const res = await request.request<{ code: number; message: string }>(
'https://flomoapp.com/api/memo/',
{
method: 'put',
requestType: 'json',
headers: {
'x-requested-with': 'XMLHttpRequest',
'x-xsrf-token': decodeURIComponent(XSRFToken),
},
data: {
source: 'web',
parent_memo_slug: null,
content: converter.makeHtml(info.content),
file_ids: [],
},
}
);
if (res.code !== 0) {
throw new Error(res.message);
}
return {
href: `https://flomoapp.com/mine`,
};
};
private async getXSRFToken(): Promise {
const cookies = await Container.get(ICookieService).get({
name: 'XSRF-TOKEN',
url: 'https://flomoapp.com/',
});
if (!cookies) {
throw new Error(
locale.format({
id: 'backend.services.flomo.login',
})
);
}
return cookies.value;
}
}
================================================
FILE: src/common/backend/services/flowus/index.ts
================================================
import { ServiceMeta } from '@/common/backend';
import Service from './service';
export const flowusOrigin = 'https://flowus.cn';
export default (): ServiceMeta => {
return {
name: 'FlowUs息流',
icon: 'https://cdn.flowus.cn/icon.png',
type: 'flowus',
homePage: 'https://flowus.cn/',
service: Service,
permission: {
origins: [`${flowusOrigin}/*`, ''],
permissions: ['cookies'],
},
};
};
================================================
FILE: src/common/backend/services/flowus/service.ts
================================================
import { CompleteStatus, UnauthorizedError } from '../interface';
import { DocumentService, CreateDocumentRequest } from '../../index';
import localeService from '@/common/locales';
import { extend, RequestMethod } from 'umi-request';
import { IWebRequestService, WebBlockHeader } from '@/service/common/webRequest';
import Container from 'typedi';
import { ICookieService } from '@/service/common/cookie';
import {
FlowUsToc,
FlowUsRepository,
FlowUsSpace,
FlowUsUserInfo,
Block,
OSSInfo,
TaskResult,
FlowUsResponse,
ROLE_WEIGHT,
Share,
} from './type';
import { generateUuid } from '@web-clipper/shared/lib/uuid';
import { flowusOrigin } from '.';
import showdown from 'showdown';
const converter = new showdown.Converter({});
export default class FlowUsDocumentService implements DocumentService {
private request: RequestMethod;
private repositories: FlowUsRepository[];
private userSpaces?: FlowUsSpace;
private tocPageBlocks?: Record;
private userInfo?: FlowUsUserInfo;
private webRequestService: IWebRequestService;
private cookieService: ICookieService;
constructor() {
const request = extend({
prefix: `${flowusOrigin}/api/`,
timeout: 10000,
credentials: 'include',
});
this.request = request;
this.repositories = [];
this.webRequestService = Container.get(IWebRequestService);
this.cookieService = Container.get(ICookieService);
request.interceptors.response.use(
(response) => {
if (response.status === 401) {
throw new UnauthorizedError(
localeService.format({
id: 'backend.services.flowus.unauthorizedErrorMessage',
defaultMessage: 'Unauthorized! Please Login FlowUs Web.',
})
);
}
return response;
},
{ global: false }
);
}
getId = () => {
return 'FlowUs';
};
getUserInfo = async () => {
if (!this.userInfo) {
const res = await this.fetchUserInfo();
this.userInfo = res.data;
}
const { nickname, avatar, ext } = this.userInfo;
return {
name: nickname,
avatar: avatar?.startsWith('http') ? avatar : getImageCdnUrl(avatar),
homePage: 'https://flowus.cn',
description: ext?.email?.email,
};
};
getRepositories = async () => {
if (!this.userInfo) {
const res = await this.fetchUserInfo();
this.userInfo = res.data;
}
if (!this.userSpaces) {
const res = await this.getUserSpaces();
this.userSpaces = res.data;
}
const { spaceViews, spaces } = this.userSpaces;
if (!spaceViews || !spaces) {
this.repositories = [];
return [];
}
const result: FlowUsRepository[] = [];
//拉取可用空间
const userSpaces = Object.values(spaceViews)
.filter((spaceView) => spaces[spaceView.spaceId])
.map((spaceView) => spaces[spaceView.spaceId]);
if (!this.tocPageBlocks) {
const allPromise = userSpaces.map((space) => {
return this.getSpaceRoot(space.uuid);
});
const allToc = await Promise.all(allPromise);
this.tocPageBlocks = allToc.reduce(
(pre, cur) => {
if (!cur.data.blocks) return pre;
Object.values(cur.data.blocks).forEach((b) => {
//保存所有的页面/多维表块
if ([0, 18, 19].includes(b.type)) {
pre[b.uuid] = b;
}
});
return pre;
},
{} as Record
);
userSpaces.forEach((sp) => {
sp.subNodes.forEach((id) => {
const block = this.tocPageBlocks?.[id];
if (!block) return;
if (block.permissions.some((o) => o.type === 'illegal')) return;
if (block.permissions.length === 0) return;
const { role } = getPermission(block, this.userInfo?.uuid!, sp.permissionGroups ?? []);
if (role === 'editor' || role === 'writer') {
//可保存到自页面的块
const spaceId = block.spaceId ?? sp.uuid;
let groupName = sp.title;
result.push({
id: block.uuid,
name: block.title || '未命名页面',
groupId: spaceId,
groupName,
});
}
});
});
}
this.repositories = result;
return result;
};
createDocument = async ({
repositoryId,
title,
content,
}: CreateDocumentRequest): Promise => {
const repository = this.repositories.find((o) => o.id === repositoryId);
if (!repository) {
throw new Error('Illegal repository');
}
const documentId = await this.createEmptyPage(repository, title);
const html = `
${title}
${converter.makeHtml(`${content}`)}
`;
const ossInfo = await this.requestWithCookie>(async (header) => {
return this.request.post(
await this.webRequestService.changeUrl(`import_temp_file?source=web-clipper`, header),
{
headers: {
[header.name]: header.value,
},
data: {
content: html,
extName: 'html',
},
}
);
});
if (ossInfo.code !== 200) {
throw new Error('upload md content failed');
}
//导入
const res = await this.requestWithCookie>(async (header) => {
return this.request.post(await this.webRequestService.changeUrl(`enqueueTask`, header), {
headers: {
[header.name]: header.value,
},
data: {
eventName: 'import',
request: {
blockId: documentId,
spaceId: repository.groupId,
importOptions: {
type: 'html',
ossName: ossInfo.data.ossName,
},
},
},
});
});
if (!res.data.taskId) {
throw new Error('enqueueTask failed');
}
const taskId = res.data.taskId;
const waitResult = async () => {
await sleep(2000);
const res = await this.requestWithCookie>(async (header) => {
return this.request.post(await this.webRequestService.changeUrl('getTasks', header), {
headers: {
[header.name]: header.value,
},
data: {
taskIds: [taskId],
},
});
});
if (res.code !== 200) {
throw new Error('getTasks failed');
}
const result = res.data.results[taskId];
if (result && result.status === 'success') {
if (result.result?.status === 'success') {
//do nothing
} else if (result.result?.msg) {
throw new Error(result.result?.msg);
}
} else {
await waitResult();
}
};
await waitResult();
this.changeTitle(documentId, repository.groupId, title);
return {
href: `${flowusOrigin}/${documentId}`,
};
};
createEmptyPage = async (repository: FlowUsRepository, title: string) => {
if (!this.tocPageBlocks) {
throw new Error('Illegal tocBlocks');
}
const documentId = generateUuid();
const parentId = repository.id;
const spaceId = repository.groupId;
const blocks = this.tocPageBlocks;
if (!blocks) {
throw new Error('Illegal blocks');
}
const subNodes = blocks[parentId].subNodes;
const after = subNodes[subNodes.length - 1];
const operations = {
requestId: generateUuid(),
transactions: [
{
id: generateUuid(),
spaceId,
operations: [
{
id: documentId,
path: [],
command: 'set',
table: 'block',
args: {
uuid: documentId,
spaceId,
parentId,
textColor: '',
backgroundColor: '',
type: 0,
status: 1,
permissions: [],
updateBy: this.userInfo?.uuid,
updateAt: Date.now(),
data: {
segments: [{ type: 0, text: title, enhancer: {} }],
},
},
},
{
id: parentId,
command: 'listAfter',
path: ['subNodes'],
table: 'block',
args: {
uuid: documentId,
after,
},
},
],
},
],
};
await this.requestWithCookie(async (header) => {
return this.request.post(
await this.webRequestService.changeUrl('blocks/transactions', header),
{
data: operations,
headers: {
[header.name]: header.value,
},
}
);
});
return documentId;
};
private changeTitle = async (documentId: string, spaceId: string, title: string) => {
const operations = {
requestId: generateUuid(),
transactions: [
{
id: generateUuid(),
spaceId,
operations: [
{
id: documentId,
path: ['data'],
command: 'update',
table: 'block',
args: {
segments: [{ type: 0, text: title, enhancer: {} }],
},
},
],
},
],
};
await this.requestWithCookie(async (header) => {
return this.request.post(
await this.webRequestService.changeUrl('blocks/transactions', header),
{
data: operations,
headers: {
[header.name]: header.value,
},
}
);
});
};
private getUserSpaces = async () => {
return this.requestWithCookie>(async (header) => {
return this.request.get(
await this.webRequestService.changeUrl(`users/${this.userInfo?.uuid}/root`, header),
{
headers: {
[header.name]: header.value,
},
}
);
});
};
private getSpaceRoot = async (spaceId: string) => {
return this.requestWithCookie(async (header) => {
return this.request.get(
await this.webRequestService.changeUrl(`spaces/${spaceId}/root`, header)
);
});
};
private fetchUserInfo = async () => {
return this.requestWithCookie>(async (header) => {
return this.request.get(await this.webRequestService.changeUrl('users/me', header));
});
};
/**
* Modify the cookie when request
*/
private requestWithCookie = async (
requestFunction: (header: WebBlockHeader) => Promise
) => {
const cookies = await this.cookieService.getAll({
url: flowusOrigin,
});
const cookieString = cookies.map((o) => `${o.name}=${o.value}`).join(';');
const header = await this.webRequestService.startChangeHeader({
urls: [`${flowusOrigin}*`],
requestHeaders: [
{
name: 'cookie',
value: cookieString,
},
],
});
try {
const result = await requestFunction(header);
await this.webRequestService.end(header);
return result;
} catch (error) {
await this.webRequestService.end(header);
throw error;
}
};
}
const compressImageSupport = /^(jpg|jpeg|png|bmp|webp|tiff)$/i;
function getImageCdnUrl(ossName?: string) {
if (!ossName) return '';
const index = ossName.lastIndexOf('.');
const extName = ossName.substring(index + 1);
let imgProcess = '';
if (compressImageSupport.test(extName.toLocaleLowerCase())) {
imgProcess = `img_process=/resize,w_${500 * Math.ceil(window.devicePixelRatio)}/quality,q_80/`;
}
return `https://cdn2.flowus.cn/${ossName}?${imgProcess}`;
}
const sleep = (durationInMs: number): Promise => {
return new Promise((resolve) => {
setTimeout(resolve, durationInMs);
});
};
const getPermission = (block: Block, userId: string, permissionGroups: any[]) => {
const data: Share = {
shared: false,
illegal: false,
isRestricted: false,
allowDuplicate: true,
permissions: [],
role: 'none',
roleWithoutPublic: 'none',
};
if (block.permissions.length) {
const getBiggerRole = (
type: keyof Block['permissions'][0],
value: any,
role?: keyof typeof ROLE_WEIGHT
) => {
const permissions = block.permissions.find((p) => p[type] === value);
if (
permissions &&
role &&
permissions.role &&
ROLE_WEIGHT[permissions.role] > ROLE_WEIGHT[role]
) {
return permissions;
}
};
const newPermissions = block.permissions
.filter((o) => {
return o.type !== 'illegal' && o.type !== 'restricted';
})
.map((o) => {
if (o.type === 'space') {
return getBiggerRole('type', o.type, o.role) || o;
}
if (o.type === 'group') {
return getBiggerRole('groupId', o.groupId, o.role) || o;
}
if (o.type === 'user') {
return getBiggerRole('userId', o.userId, o.role) || o;
}
return o;
});
const diffPermissions = block.permissions.filter((o) => {
if (o.type === 'illegal' || o.type === 'restricted') {
return false;
}
if (o.type === 'space' || o.type === 'public') {
return newPermissions.every((p) => p.type !== o.type);
}
if (o.type === 'group') {
return newPermissions.every((p) => p.groupId !== o.groupId);
}
return newPermissions.every((p) => p.userId !== o.userId);
});
data.permissions = [...newPermissions, ...diffPermissions];
const ownPermission = data.permissions.find((p) => p.userId === userId);
const groupPermissions = data.permissions.filter((p) => {
const group = permissionGroups?.find((g) => g.id === p.groupId);
return group?.userIds.includes(userId);
});
const allPermissions = [ownPermission, ...groupPermissions];
const spacePermission = block.permissions.find((p) => p.type === 'space');
allPermissions.push(spacePermission);
data.roleWithoutPublic = allPermissions.reduce(
(pre: keyof typeof ROLE_WEIGHT, permission: Block['permissions'][0] | undefined) => {
if (!permission) return pre;
const role = permission.role ?? 'none';
return ROLE_WEIGHT[role] > ROLE_WEIGHT[pre] ? role : pre;
},
'none'
);
data.role = data.roleWithoutPublic;
}
return data;
};
================================================
FILE: src/common/backend/services/flowus/type.ts
================================================
import { Repository } from '../interface';
interface Space {
uuid: string;
title: string;
subNodes: string[];
permissionGroups?: any[];
}
interface SpaceView {
uuid: string;
spaceId: string;
title: string;
}
type BlockType = number;
export interface Block {
uuid: string;
spaceId: string;
parentId: string;
type: BlockType;
title: string;
subNodes: string[];
permissions: {
type: string;
role?: keyof typeof ROLE_WEIGHT;
userId?: string;
groupId?: string;
}[];
}
export interface FlowUsSpace {
spaces: Record;
spaceViews: Record;
}
export interface OSSInfo {
ossName: string;
}
export interface FlowUsToc {
data: {
blocks?: Record;
};
}
export interface FlowUsUserInfo {
uuid: string;
phone: string;
nickname: string;
backgroundColor: string;
spaceViews: string;
avatar?: string;
ext?: {
email?: {
id: string;
email: string;
};
};
}
export interface FlowUsRepository extends Repository {}
export interface TaskResult {
results: Record<
string,
{
taskId: string;
eventName: string;
status: string;
result?: {
status?: string;
url?: string;
size?: number;
ossName?: string;
uuid?: string;
msg?: string;
};
}
>;
}
export interface FlowUsResponse {
msg: string;
code: number;
data: DATA;
}
export const ROLE_WEIGHT = {
none: 0,
reader: 1,
writer: 2,
editor: 3,
commenter: 4,
};
export interface Share {
shared: boolean;
title?: string;
illegal: boolean;
parentId?: string;
isRestricted: boolean;
/** 允许复制、打印、下载 */
allowDuplicate: boolean;
permissions: Block['permissions'];
role: keyof typeof ROLE_WEIGHT;
roleWithoutPublic: keyof typeof ROLE_WEIGHT;
}
================================================
FILE: src/common/backend/services/github/form.tsx
================================================
import { KeyOutlined } from '@ant-design/icons';
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input, Select, Tooltip } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment } from 'react';
import { GithubBackendServiceConfig } from './interface';
import { FormattedMessage } from 'react-intl';
import locale from '@/common/locales';
import { stringify } from 'qs';
interface GithubFormProps {
verified?: boolean;
info?: GithubBackendServiceConfig;
}
const GenerateNewTokenUrl = `https://github.com/settings/tokens/new?${stringify({
scopes: 'repo',
description: 'Web Clipper',
})}`;
const visibilityOptions = [
{
label: ,
value: 'all',
},
{
label: (
),
value: 'public',
},
{
label: (
),
value: 'private',
},
];
const GithubForm: React.FC = ({
form: { getFieldDecorator },
info,
verified,
}) => {
const disabled = verified || !!info;
let initAccessToken;
let visibility;
if (info) {
initAccessToken = info.accessToken;
visibility = info.visibility;
}
return (
}
>
{getFieldDecorator('visibility', {
initialValue: visibility,
})(
{visibilityOptions.map(o => (
{o.label}
))}
)}
{getFieldDecorator('accessToken', {
initialValue: initAccessToken,
rules: [
{
required: true,
message: (
),
},
],
})(
{locale.format({
id: 'backend.services.github.form.GenerateNewToken',
defaultMessage: 'Generate new token',
})}
}
>
}
/>
)}
);
};
export default GithubForm;
================================================
FILE: src/common/backend/services/github/headerForm.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Select, Badge } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment } from 'react';
import backend from '../..';
import GithubDocumentService from './service';
import locale from '@/common/locales';
import { useFetch } from '@shihengtech/hooks';
const HeaderForm: React.FC = ({
form: { getFieldDecorator },
currentRepository,
}) => {
const service = backend.getDocumentService() as GithubDocumentService;
// eslint-disable-next-line react-hooks/rules-of-hooks
const labelsResponse = useFetch(
async () => {
if (currentRepository) {
return service.getRepoLabels(currentRepository);
}
return [];
},
[currentRepository, service],
{
initialState: {
data: [],
},
}
);
return (
{getFieldDecorator('labels')(
{labelsResponse.data?.map(o => (
))}
)}
);
};
export default HeaderForm;
================================================
FILE: src/common/backend/services/github/index.ts
================================================
import { ServiceMeta } from './../interface';
import Service from './service';
import Form from './form';
import headerForm from './headerForm';
export default () => {
return {
name: 'Github',
icon: 'github',
type: 'github',
service: Service,
form: Form,
headerForm: headerForm,
homePage: 'https://github.com/',
permission: {
origins: ['https://api.github.com/*'],
},
} as ServiceMeta;
};
================================================
FILE: src/common/backend/services/github/interface.ts
================================================
import { Repository, CreateDocumentRequest } from '../interface';
export interface GithubBackendServiceConfig {
accessToken: string;
visibility: string;
}
export interface GithubCreateDocumentRequest extends CreateDocumentRequest {
labels: string[];
}
export interface GithubUserInfoResponse {
avatar_url: string;
name: string;
bio: string;
html_url: string;
}
export interface GithubRepository extends Repository {
namespace: string;
}
export interface GithubRepositoryResponse {
id: number;
name: string;
full_name: string;
created_at: string;
description: string;
private: boolean;
}
export interface GithubLabel {
color: string;
description: string;
name: string;
default: boolean;
}
================================================
FILE: src/common/backend/services/github/service.ts
================================================
import { CompleteStatus } from 'common/backend/interface';
import {
GithubBackendServiceConfig,
GithubUserInfoResponse,
GithubRepositoryResponse,
GithubRepository,
GithubLabel,
GithubCreateDocumentRequest,
} from './interface';
import { DocumentService } from '../../index';
import axios, { AxiosInstance } from 'axios';
import md5 from '@web-clipper/shared/lib/md5';
import { stringify } from 'qs';
const PAGE_LIMIT = 100;
export default class GithubDocumentService implements DocumentService {
private request: AxiosInstance;
private repositories: GithubRepository[];
private config: GithubBackendServiceConfig;
constructor(config: GithubBackendServiceConfig) {
const request = axios.create({
baseURL: 'https://api.github.com',
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `token ${config.accessToken}`,
},
timeout: 10000,
transformResponse: [
(data): string => {
return JSON.parse(data);
},
],
withCredentials: true,
});
this.request = request;
this.repositories = [];
this.config = config;
}
getId = () => {
return md5(this.config.accessToken);
};
getUserInfo = async () => {
const data = await this.request.get('user');
const { name, avatar_url: avatar, html_url: homePage, bio: description } = data.data;
return {
name,
avatar,
homePage,
description,
};
};
getRepositories = async (): Promise => {
let page = 1;
let foo = await this.getGithubRepositories({ page, visibility: this.config.visibility });
let result: GithubRepository[] = [];
result = result.concat(foo);
while (foo.length === PAGE_LIMIT) {
page++;
foo = await this.getGithubRepositories({ page, visibility: this.config.visibility });
result = result.concat(foo);
}
this.repositories = result;
return result;
};
createDocument = async (info: GithubCreateDocumentRequest): Promise => {
if (!this.repositories) {
this.getRepositories();
}
const { content: body, title, repositoryId, labels } = info;
const repository = this.repositories.find(o => o.id === repositoryId);
if (!repository) {
throw new Error('can not find repository');
}
const response = await this.request.post<{
html_url: string;
id: number;
}>(`/repos/${repository.namespace}/issues`, {
title,
body,
labels,
});
return {
href: response.data.html_url,
};
};
getRepoLabels = async (repo: GithubRepository): Promise => {
return (await this.request.get(`/repos/${repo.namespace}/labels`)).data;
};
private getGithubRepositories = async ({
page,
visibility,
}: {
page: number;
visibility: string;
}) => {
const response = await this.request.get(
`user/repos?${stringify({ page, per_page: PAGE_LIMIT, visibility })}`
);
const repositories = response.data;
return repositories.map(
(repository): GithubRepository => {
const { id, name, full_name: namespace } = repository;
return {
id: id.toString(),
name,
namespace,
groupId: namespace.split('/')[0],
groupName: namespace.split('/')[0],
};
}
);
};
}
================================================
FILE: src/common/backend/services/github_repository/form.tsx
================================================
import { KeyOutlined } from '@ant-design/icons';
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input, Select, Tooltip } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment } from 'react';
import { GithubBackendServiceConfig } from './interface';
import { FormattedMessage } from 'react-intl';
import locale from '@/common/locales';
import { stringify } from 'qs';
interface GithubFormProps {
verified?: boolean;
info?: GithubBackendServiceConfig;
}
const GenerateNewTokenUrl = `https://github.com/settings/tokens/new?${stringify({
scopes: 'repo',
description: 'Web Clipper',
})}`;
const visibilityOptions = [
{
label: ,
value: 'all',
},
{
label: ,
value: 'public',
},
{
label: ,
value: 'private',
},
];
const GithubForm: React.FC = ({
form: { getFieldDecorator },
info,
verified,
}) => {
const disabled = verified || !!info;
let initAccessToken;
let visibility;
let savePath;
if (info) {
initAccessToken = info.accessToken;
visibility = info.visibility;
savePath = info.savePath;
}
return (
}>
{getFieldDecorator('visibility', {
initialValue: visibility,
})(
{visibilityOptions.map(o => (
{o.label}
))}
)}
{getFieldDecorator('accessToken', {
initialValue: initAccessToken,
rules: [
{
required: true,
message: (
),
},
],
})(
{locale.format({
id: 'backend.services.github.form.GenerateNewToken',
defaultMessage: 'Generate new token',
})}
}
>
}
/>
)}
}
>
{getFieldDecorator('savePath', {
initialValue: savePath,
rules: [
{
required: false,
},
{
validator: (_r: unknown, value: string, callback: (message?: string) => {}) => {
if (typeof value === 'string') {
if (value.startsWith('/')) {
return callback('path cannot start with a slash');
}
}
return callback();
},
},
],
})(
)}
);
};
export default GithubForm;
================================================
FILE: src/common/backend/services/github_repository/index.ts
================================================
import { ServiceMeta } from '../interface';
import Service from './service';
import Form from './form';
export default () => {
return {
name: 'Github Repository',
icon: 'github_repository',
type: 'github_repository',
service: Service,
form: Form,
homePage: 'https://github.com/',
permission: {
origins: ['https://api.github.com/*'],
},
} as ServiceMeta;
};
================================================
FILE: src/common/backend/services/github_repository/interface.ts
================================================
import { Repository, CreateDocumentRequest } from '../interface';
export interface GithubBackendServiceConfig {
accessToken: string;
visibility: string;
storageLocation: string;
savePath: string;
}
export interface GithubCreateDocumentRequest extends CreateDocumentRequest {
labels: string[];
}
export interface GithubUserInfoResponse {
avatar_url: string;
name: string;
bio: string;
html_url: string;
}
export interface GithubRepository extends Repository {
namespace: string;
}
export interface GithubRepositoryResponse {
id: number;
name: string;
full_name: string;
created_at: string;
description: string;
private: boolean;
}
================================================
FILE: src/common/backend/services/github_repository/service.ts
================================================
import { CompleteStatus } from 'common/backend/interface';
import {
GithubBackendServiceConfig,
GithubUserInfoResponse,
GithubRepositoryResponse,
GithubRepository,
GithubCreateDocumentRequest,
} from './interface';
import { DocumentService } from '../../index';
import axios, { AxiosInstance } from 'axios';
import md5 from '@web-clipper/shared/lib/md5';
import { stringify } from 'qs';
import { isUndefined, toNumber } from 'lodash';
import fileNamify from 'filenamify';
const PAGE_LIMIT = 100;
export default class GithubRepositoryDocumentService implements DocumentService {
private request: AxiosInstance;
private repositories: GithubRepository[];
private config: GithubBackendServiceConfig;
constructor(config: GithubBackendServiceConfig) {
const request = axios.create({
baseURL: 'https://api.github.com',
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `token ${config.accessToken}`,
},
timeout: 10000,
transformResponse: [
(data): string => {
return JSON.parse(data);
},
],
withCredentials: true,
});
this.request = request;
this.repositories = [];
this.config = config;
}
getId = () => {
return md5(`${this.config.accessToken}_github_repository`);
};
getStorageLocation = () => {
return this.config.storageLocation;
};
getUserInfo = async () => {
const data = await this.request.get('user');
const { name, avatar_url: avatar, html_url: homePage, bio: description } = data.data;
return {
name,
avatar,
homePage,
description,
};
};
getRepositories = async (): Promise => {
let page = 1;
let foo = await this.getGithubRepositories({ page, visibility: this.config.visibility });
let result: GithubRepository[] = [];
result = result.concat(foo);
while (foo.length === PAGE_LIMIT) {
page++;
foo = await this.getGithubRepositories({ page, visibility: this.config.visibility });
result = result.concat(foo);
}
this.repositories = result;
return result;
};
createDocument = async (info: GithubCreateDocumentRequest): Promise => {
if (!this.repositories) {
this.getRepositories();
}
const { content: body, title, repositoryId } = info;
const repository = this.repositories.find(o => o.id === repositoryId);
if (!repository) {
throw new Error('can not find repository');
}
if (isUndefined(this.config.savePath)) this.config.savePath = '';
if (this.config.savePath.startsWith('/')) this.config.savePath.substr(1);
if (!this.config.savePath.endsWith('/') && this.config.savePath.length > 0)
this.config.savePath += '/';
let b64EncodeUnicode = (str: string) => {
return btoa(
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function toSolidBytes(_match, p1) {
return String.fromCharCode(toNumber(`0x${p1}`));
})
);
};
let fileContent: string = b64EncodeUnicode(`# ${title}\n${body}`);
let fileName: string = fileNamify(title, { replacement: ' ' });
let requestPath: string = `/repos/${repository.namespace}/contents/${this.config.savePath}${fileName}.md`;
const response = await this.request
.put<{
content: { html_url: string };
}>(requestPath, {
message: `Clip "${title}"`,
content: fileContent,
})
.catch(error => {
if (error.response) {
if (error.response.status === 422)
throw new Error('Response Status: 422. The file may already exist.');
} else if (error.request) {
throw new Error(error.request);
} else {
throw new Error(error.message);
}
});
return response ? { href: response.data.content.html_url } : {};
};
private getGithubRepositories = async ({
page,
visibility,
}: {
page: number;
visibility: string;
}) => {
const response = await this.request.get(
`user/repos?${stringify({ page, per_page: PAGE_LIMIT, visibility })}`
);
const repositories = response.data;
return repositories.map(
(repository): GithubRepository => {
const { id, name, full_name: namespace } = repository;
return {
id: id.toString(),
name,
namespace,
groupId: namespace.split('/')[0],
groupName: namespace.split('/')[0],
};
}
);
};
}
================================================
FILE: src/common/backend/services/interface.ts
================================================
export interface CreateDocumentRequest {
title: string;
content: string;
url?: string;
repositoryId: string;
}
export interface CompleteStatus {
href?: string;
}
export interface UserInfo {
name: string;
avatar: string;
homePage?: string;
description?: string;
}
export interface Repository {
/**
* 仓库 ID
*/
id: string;
/**
* 仓库名
*/
name: string;
/**
* 团队 ID
*/
groupId: string;
/**
* 团队 名称
*/
groupName: string;
disabled?: boolean;
}
export interface ServiceMeta {
/**
* Name of Backend Service
*/
name: string;
/**
* icon
*/
icon: string;
/**
* Type of Backend Service
*/
type: string;
/**
* Backend Service
*/
service: Type;
/**
* 主页
*/
homePage?: string;
/**
* 配置表单
*/
form?: any;
complete?: any;
oauthUrl?: string;
headerForm?: any;
permission?: chrome.permissions.Permissions;
}
export interface UpdateTOCRequest {}
export interface DocumentService {
getId(): string;
getRepositories(): Promise;
createDocument(request: T): Promise;
getUserInfo(): Promise;
refreshToken?(info: T): Promise;
}
interface ErrorOptions {
message: string;
}
class BaseError extends Error {
protected options?: T;
constructor(options?: T) {
super();
this.options = options || ({} as T);
this.message = this.options.message || '';
this.name = this.constructor.name;
}
public static from(err: Error): BaseError {
const ErrorClass = this;
const newErr = new ErrorClass();
newErr.message = err.message;
newErr.stack = err.stack;
return newErr;
}
}
interface HttpErrorOptions extends ErrorOptions {
status: number;
}
export class HttpError extends BaseError {
public status: number;
protected options: HttpErrorOptions;
constructor(options: HttpErrorOptions) {
super(options);
this.options = options;
this.status = this.options.status;
}
}
export class UnauthorizedError extends HttpError {
constructor(message?: string) {
const status = 401;
super({ message: message || 'Unauthorized', status });
}
}
================================================
FILE: src/common/backend/services/joplin/form.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input, Checkbox } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment } from 'react';
import { JoplinBackendServiceConfig } from '../../clients/joplin';
import { FormattedMessage } from 'react-intl';
interface FormProps extends FormComponentProps {
verified?: boolean;
info?: JoplinBackendServiceConfig;
}
const InitForm: React.FC = ({ form: { getFieldDecorator }, info }) => {
return (
{getFieldDecorator('token', {
initialValue: info?.token,
rules: [
{
required: true,
message: 'Authorization token is required!',
},
],
})( )}
}>
{getFieldDecorator('filterTags', {
initialValue: info?.filterTags ?? false,
valuePropName: 'checked',
})(
)}
);
};
export default InitForm;
================================================
FILE: src/common/backend/services/joplin/headerForm.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Select } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment } from 'react';
import backend from '../..';
import { useFetch } from '@shihengtech/hooks';
import JoplinDocumentService from './service';
import locale from '@/common/locales';
const HeaderForm: React.FC = ({ form: { getFieldDecorator } }) => {
const service = backend.getDocumentService() as JoplinDocumentService;
const tagResponse = useFetch(async () => service.getTags(), [service], {
initialState: {
data: [],
},
});
return (
{getFieldDecorator('tags', {
initialValue: [],
})(
{tagResponse.data?.map(o => (
{o.title}
))}
)}
);
};
export default HeaderForm;
================================================
FILE: src/common/backend/services/joplin/index.ts
================================================
import { ServiceMeta } from './../interface';
import Service from './service';
import Form from './form';
import localeService from '@/common/locales';
import headerForm from './headerForm';
export default (): ServiceMeta => {
return {
name: localeService.format({
id: 'backend.services.joplin.name',
}),
icon: 'joplin',
type: 'joplin',
service: Service,
headerForm,
form: Form,
homePage: 'https://joplinapp.org/',
permission: {
origins: ['http://localhost:41184/*'],
},
};
};
================================================
FILE: src/common/backend/services/joplin/service.ts
================================================
import { RequestHelper } from '@/service/request/common/request';
import { IBasicRequestService } from './../../../../service/common/request';
import { Container } from 'typedi';
import { DocumentService } from './../../index';
import {
LegacyJoplinClient,
JoplinClient,
JoplinBackendServiceConfig,
JoplinCreateDocumentRequest,
IJoplinClient,
} from '../../clients/joplin';
const HOST = 'http://localhost:41184/';
export default class JoplinDocumentService implements DocumentService {
private client?: Promise;
constructor(private config: JoplinBackendServiceConfig) {}
getId() {
return this.config.token;
}
getUserInfo = async () => {
return {
name: `Joplin`,
avatar: '',
homePage: 'https://joplinapp.org/',
description: `Save to Joplin`,
};
};
createDocument = async (data: JoplinCreateDocumentRequest) => {
const joplinClient = await this.getJoplinClient();
return joplinClient.createDocument(data);
};
getRepositories = async () => {
const joplinClient = await this.getJoplinClient();
return joplinClient.getRepositories();
};
getTags = async () => {
const joplinClient = await this.getJoplinClient();
return joplinClient.getTags(this.config.filterTags);
};
private async getJoplinClient(): Promise {
if (!this.client) {
this.client = this.getSupportToken();
}
return this.client;
}
private async getSupportToken() {
const tokens = this.config.token
.split('\n')
.map(o => o.trim())
.filter(p => !!p);
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
try {
const client = await this._getJoplinClient(token);
const repositories = await client.getRepositories();
if (Array.isArray(repositories)) {
console.log(`Check token ${i} success.`);
return client;
}
return client;
} catch (_error) {
//
console.log(`Check token ${i} error.`);
}
}
throw new Error('invalid Token');
}
private async _getJoplinClient(token: string): Promise {
const request = new RequestHelper({
baseURL: HOST,
request: Container.get(IBasicRequestService),
params: {
token,
},
});
const client = new JoplinClient(request);
if (await client.support()) {
return client;
}
return new LegacyJoplinClient(request);
}
}
================================================
FILE: src/common/backend/services/leanote/form.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment } from 'react';
import { LeanoteBackendServiceConfig } from '../../clients/leanote/interface';
import { FormattedMessage } from 'react-intl';
import i18n from '@/common/locales';
import useOriginForm from '@/hooks/useOriginForm';
interface OneNoteProps {
verified?: boolean;
info?: LeanoteBackendServiceConfig;
}
const ExtraForm: React.FC = props => {
const {
form: { getFieldDecorator },
form,
info,
} = props;
const { verified, handleAuthentication, formRules } = useOriginForm({
form,
initStatus: !!info,
originKey: 'leanote_host',
});
let initData: Partial = {};
if (info) {
initData = info;
}
let editMode = info ? true : false;
return (
}
>
{form.getFieldDecorator('leanote_host', {
initialValue: info?.leanote_host,
rules: formRules,
})(
}
onSearch={handleAuthentication}
disabled={verified}
/>
)}
}
>
{getFieldDecorator('email', {
initialValue: initData.email,
rules: [
{
required: true,
message: i18n.format({
id: 'backend.services.leanote.form.email',
defaultMessage: 'Email is required.',
}),
},
],
})( )}
}
>
{getFieldDecorator('pwd', {
initialValue: initData.pwd,
})( )}
);
};
export default ExtraForm;
================================================
FILE: src/common/backend/services/leanote/index.ts
================================================
import { ServiceMeta } from '@/common/backend';
import localeService from '@/common/locales';
import Service from './service';
import form from './form';
export default (): ServiceMeta => {
return {
name: localeService.format({
id: 'backend.services.leanote.name',
defaultMessage: 'Leanote',
}),
icon: 'leanote',
type: 'leanote',
service: Service,
form: form,
homePage: 'https://github.com/leanote/leanote',
};
};
================================================
FILE: src/common/backend/services/leanote/service.ts
================================================
import { CompleteStatus } from 'common/backend/interface';
import { DocumentService, CreateDocumentRequest } from '../../index';
import { IBasicRequestService } from '@/service/common/request';
import { Container } from 'typedi';
import LeanoteClient from '../../clients/leanote/client';
import md5 from '@web-clipper/shared/lib/md5';
import { LeanoteBackendServiceConfig, LeanoteNotebook } from '../../clients/leanote/interface';
/**
*
* Document service for self hosted leanote or leanote.com
*/
export default class LeanoteDocumentService implements DocumentService {
private client: LeanoteClient;
private config: LeanoteBackendServiceConfig;
/**
* This extension will need the user and password to connect to leanote and fetch a token
* You must supply the one of your leanote server.
*/
constructor(config: LeanoteBackendServiceConfig) {
this.config = config;
this.client = new LeanoteClient(config, Container.get(IBasicRequestService));
}
/** Unique account identification */
getId = () => {
return md5(`leanote_${this.config.leanote_host}_${this.config.email}`);
};
getUserInfo = async () => {
return {
name: this.config.leanote_host,
avatar: '',
homePage: this.config.leanote_host,
description: `send to ${this.config.email} account on leanote`,
};
};
/**
* If not logged, login then fetch notebook as repository
* change:getSyncNotebooks => getNotebooks
*
* @see documentation https://github.com/leanote/leanote/wiki/leanote-api
*/
getRepositories = async () => {
let response = await this.client.getNotebooks();
if ((response as any).Msg && (response as any).Msg === 'NOTLOGIN') {
await this.client.login();
response = await this.client.getNotebooks();
}
return response.map(function(leanoteNotebook: LeanoteNotebook) {
return {
id: leanoteNotebook.NotebookId,
name: leanoteNotebook.Title,
groupId: 'leanote',
groupName: 'leanote',
};
});
};
/**
* @TODO handle Error
* Use the leanote api to clip document as note in leanote
*
* @see documentation https://github.com/leanote/leanote/wiki/leanote-api
*/
createDocument = async (info: CreateDocumentRequest): Promise => {
const result = await this.client.createDocument(info);
if (result.NoteId) {
return {
href: `${this.config.leanote_host}/note/${result.NoteId}`,
};
}
return {
href: `${this.config.leanote_host}`,
};
};
/**
* Use the leanote api to embed image in the document as note in leanote
*
* @see documentation https://github.com/leanote/leanote/wiki/leanote-api
*/
uploadBlob = async (blob: Blob): Promise => {
return this.client.uploadBlob(blob);
};
}
================================================
FILE: src/common/backend/services/memos/form.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/es/form';
import React, { Fragment } from 'react';
import { MemosBackendServiceConfig } from './interface';
import useOriginForm from '@/hooks/useOriginForm';
import { FormattedMessage } from 'react-intl';
interface MemosFormProps {
verified?: boolean;
info?: MemosBackendServiceConfig;
}
const FormItem: React.FC = props => {
const {
form,
form: { getFieldDecorator },
info,
verified,
} = props;
const { verified: formVerified, handleAuthentication, formRules } = useOriginForm({
form,
initStatus: !!info,
});
let initData: Partial = {};
if (info) {
initData = info;
}
let editMode = info ? true : false;
return (
{getFieldDecorator('origin', {
initialValue: initData.origin || 'https://demo.usememos.com',
rules: [
{
required: true,
message: (
),
type: 'url',
},
...formRules,
],
})(
}
disabled={editMode || formVerified}
onSearch={handleAuthentication}
/>
)}
{getFieldDecorator('accessToken', {
initialValue: initData.accessToken,
rules: [
{
required: true,
message: (
),
},
],
})( )}
);
};
export default FormItem;
================================================
FILE: src/common/backend/services/memos/headerForm.tsx
================================================
import { Input, Tooltip, Select } from 'antd';
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment } from 'react';
import locales from '@/common/locales';
import { VisibilityType } from './interface';
const { Option } = Select;
const HeaderForm: React.FC = ({ form: { getFieldDecorator } }) => {
return (
{getFieldDecorator('tags', {
rules: [
{
pattern: /^(?! )[^\u4e00-\u9fa5~`!@#$%^&*()_+={}\[\]:;"'<>.?\/\\|]*[^\s.,;:!?"'()]*$/,
message: locales.format({
id: 'backend.services.memos.headerForm.tag_error',
}),
},
],
})(
)}
{getFieldDecorator('visibility', {
initialValue: VisibilityType[0].value,
})(
{VisibilityType.map(option => (
{option.label()}
))}
)}
);
};
export default HeaderForm;
================================================
FILE: src/common/backend/services/memos/index.ts
================================================
import { ServiceMeta } from '../interface';
import Service from './service';
import Form from './form';
import localeService from '@/common/locales';
import headerForm from './headerForm';
export default (): ServiceMeta => {
return {
name: localeService.format({
id: 'backend.services.memos.name',
}),
icon: '',
type: 'memos',
service: Service,
headerForm: headerForm,
form: Form,
homePage: 'https://www.usememos.com/',
};
};
================================================
FILE: src/common/backend/services/memos/interface.ts
================================================
import { CreateDocumentRequest } from './../interface';
import locales from '@/common/locales';
export const VisibilityType = [
{ label: () => locales.format({ id: 'backend.services.memos.headerForm.VisibilityType.private', defaultMessage: 'private' }), value: 'PRIVATE' },
{ label: () => locales.format({ id: 'backend.services.memos.headerForm.VisibilityType.public', defaultMessage: 'public' }), value: 'PUBLIC' },
] as const;
export type VisibilityType = typeof VisibilityType[number];
export interface MemosBackendServiceConfig {
accessToken: string;
origin: string;
}
export interface MemosUserResponse {
name: string;
username: string;
email: string;
avatarUrl: string;
description: string;
}
export interface MemosUserInfo {
name: string;
avatar: string;
homePage: string;
description: string;
}
export interface MemoCreateDocumentRequest extends CreateDocumentRequest {
visibility?: VisibilityType;
tags?: string;
}
================================================
FILE: src/common/backend/services/memos/service.ts
================================================
import { DocumentService } from '../../index';
import { extend, RequestMethod } from 'umi-request';
import { CompleteStatus } from '../interface';
import { Repository } from '@/common/backend/services/interface';
import {
MemosBackendServiceConfig,
MemoCreateDocumentRequest,
MemosUserResponse,
MemosUserInfo
} from './interface';
export default class MemosDocumentService implements DocumentService {
private request: RequestMethod;
private token: string;
private origin: string;
private UserInfo: MemosUserInfo | null;
constructor({ accessToken, origin }: MemosBackendServiceConfig) {
const realHost = origin || 'https://demo.usememos.com';
this.request = extend({
prefix: `${realHost}/api/`,
headers: { Authorization: `Bearer ${accessToken}` },
timeout: 5000,
});
this.request.interceptors.response.use(
async response => {
if (!response.ok) {
const json = await response.clone().json();
throw new Error(`(${response.status}) Err_id=${json.code || ''}: ${json.message || '未知错误'}`);
}
return response;
},
error => {
if (error.response) {
// 服务器返回错误
return error.response.json().then((json: any) => {
throw new Error(`(${error.response.status}) code=${json.id || ''}: ${json.message || error.message || '未知错误'}`);
});
}
// (50X)网络错误等
throw new Error(`(500): ${error.message || '网络错误'}`);
},
);
this.token = accessToken;
this.origin = realHost;
this.UserInfo = null;
}
getId = () => {
return '0';
};
getUserInfo = async (): Promise => {
const response = await this.request.post('v1/auth/status');
const MemosUserInfo: MemosUserInfo = {
name: response.username || 'Memos User',
avatar: response.avatarUrl
? `${this.origin}${response.avatarUrl}`
: 'https://demo.usememos.com/full-logo.webp',
homePage: this.origin,
description: response.description || 'Memos User',
};
this.UserInfo = MemosUserInfo;
return MemosUserInfo;
};
private addTag = (tags: string, content: string): string => {
const tagArray = tags.split(',').map(tag => tag.trim()).filter(tag => tag);
const formattedTags = tagArray.map(tag => `#${tag}`).join(' ');
return `${content}\n${formattedTags}`;
};
createDocument = async (
info: MemoCreateDocumentRequest
): Promise => {
if (!this.UserInfo) {
this.UserInfo = await this.getUserInfo();
}
if (info.tags) {
info.content = this.addTag(info.tags, info.content);
}
const response = await this.request.post<{
id: string;
content: string;
creator: string;
}>('v1/memos', {
data: {
content: info.content,
visibility: info.visibility || 'PRIVATE',
},
});
return {
href: `${this.origin}/u/${this.UserInfo.name}`,
};
};
getRepositories = async (): Promise => {
return [{
id: 'memos_default',
name: '默认分区 Default Repo',
groupId: 'memos',
groupName: '默认分组 Defualt Group',
}];
};
}
================================================
FILE: src/common/backend/services/notion/index.ts
================================================
import { ServiceMeta } from '@/common/backend';
import Service from './service';
export default (): ServiceMeta => {
return {
name: 'Notion',
icon: 'https://www.notion.so/images/favicon.ico',
type: 'notion',
homePage: 'https://www.notion.so/',
service: Service,
permission: {
origins: ['https://www.notion.so/*'],
permissions: ['cookies'],
},
};
};
================================================
FILE: src/common/backend/services/notion/service.ts
================================================
import localeService from '@/common/locales';
import { ICookieService } from '@/service/common/cookie';
import { IWebRequestService } from '@/service/common/webRequest';
import { generateUuid } from '@web-clipper/shared/lib/uuid';
import axios, { AxiosInstance } from 'axios';
import Container from 'typedi';
import { CreateDocumentRequest, DocumentService } from '../../index';
import { CompleteStatus, UnauthorizedError } from './../interface';
import { NotionRepository, NotionUserContent, RecentPages } from './types';
const PAGE = 'page';
const COLLECTION_VIEW_PAGE = 'collection_view_page';
const origin = 'https://www.notion.so/';
export default class NotionDocumentService implements DocumentService {
private request: AxiosInstance;
private repositories: NotionRepository[];
private userContent?: NotionUserContent;
private webRequestService: IWebRequestService;
private cookieService: ICookieService;
constructor() {
const request = axios.create({
baseURL: origin,
timeout: 10000,
transformResponse: [
(data): any => {
return JSON.parse(data);
},
],
withCredentials: true,
});
this.request = request;
this.repositories = [];
this.webRequestService = Container.get(IWebRequestService);
this.cookieService = Container.get(ICookieService);
this.request.interceptors.response.use(
(r) => r,
(error) => {
if (error.response && error.response.status === 401) {
return Promise.reject(
new UnauthorizedError(
localeService.format({
id: 'backend.services.notion.unauthorizedErrorMessage',
defaultMessage: 'Unauthorized! Please Login Notion Web.',
})
)
);
}
return Promise.reject(error);
}
);
}
getId = () => {
return 'notion';
};
getUserInfo = async () => {
if (!this.userContent) {
this.userContent = await this.getUserContent();
}
const user = this.userContent.recordMap.notion_user;
const userInfo = Object.values(user)[0];
const { email, profile_photo, name } = userInfo.value;
return {
name,
avatar: profile_photo,
homePage: 'https://www.notion.so/',
description: email,
};
};
getRepositories = async () => {
if (!this.userContent) {
this.userContent = await this.getUserContent();
}
const userId = Object.keys(this.userContent.recordMap.notion_user)[0] as string;
const spaces = (await this.getSpaces(userId)) as any;
const result: Array = await Promise.all(
Object.keys(spaces).map(async (p) => {
const space = spaces[p];
const recentPages = await this.getRecentPageVisits(space.spaceId, userId);
const spaceName = await this.getSpaceName(space.spaceId);
return this.loadSpace(space.spaceId, spaceName, recentPages);
})
);
this.repositories = result.flat() as NotionRepository[];
return this.repositories;
};
getSpaces = async (userId: string) => {
const response = await this.requestWithCookie.post<{
users: {
[id: string]: {
user_root: {
[id: string]: {
value: {
space_view_pointers: [
{
id: string;
table: string;
spaceId: string;
}
]
}
};
}
space: any;
};
};
}>('/api/v3/getSpacesInitial');
return response.data.users[userId].user_root[userId].value.space_view_pointers;
};
getSpaceName = async (spaceId: string) => {
const response = await this.requestWithCookie.post<{
results: [
{
name: string;
}
]
}>('api/v3/getPublicSpaceData', {
spaceIds: [spaceId],
type: 'space-ids'
});
return response.data.results[0].name;
}
createDocument = async ({
repositoryId,
title,
content,
}: CreateDocumentRequest): Promise => {
let fileName = `${title}.md`;
const repository = this.repositories.find((o) => o.id === repositoryId);
if (!repository) {
throw new Error('Illegal repository');
}
const documentId = await this.createEmptyFile(repository, content);
const fileUrl = await this.getFileUrl(encodeURI(fileName));
await axios.put(fileUrl.signedPutUrl, `${content}`, {
headers: {
'Content-Type': 'text/markdown',
},
});
if (!this.userContent) {
this.userContent = await this.getUserContent();
}
const spaceId = await this.getSpaceId();
await this.requestWithCookie.post('api/v3/enqueueTask', {
task: {
eventName: 'importFile',
request: {
fileURL: fileUrl.url,
fileName,
importType: 'ReplaceBlock',
block: {
id: documentId,
spaceId: spaceId,
},
spaceId: spaceId,
signedToken: fileUrl.signedToken,
},
},
});
return {
href: `https://www.notion.so/${repository.groupId}/${documentId.replace(/-/g, '')}`,
};
};
getSpaceId = async () => {
if (!this.userContent) {
this.userContent = await this.getUserContent();
}
const userId = Object.keys(this.userContent.recordMap.notion_user)[0] as string;
const spaces = (await this.getSpaces(userId)) as any;
return spaces[0].spaceId;
};
createEmptyFile = async (repository: NotionRepository, title: string) => {
if (!this.userContent) {
this.userContent = await this.getUserContent();
}
const spaceId = await this.getSpaceId();
const documentId = generateUuid();
const requestId = generateUuid();
const inner_requestId = generateUuid();
const parentId = repository.id;
const userId = Object.values(this.userContent.recordMap.notion_user)[0].value.id;
const time = new Date().getDate();
let operations;
if (repository.pageType === PAGE) {
operations = [
{
id: documentId,
table: 'block',
path: [],
command: 'set',
args: {
type: 'page',
id: documentId,
space_id: spaceId,
version: 1,
},
},
{
id: documentId,
table: 'block',
path: [],
command: 'update',
args: {
parent_id: parentId,
parent_table: 'block',
alive: true,
space_id: spaceId,
},
},
{
table: 'block',
id: parentId,
path: ['content'],
command: 'listAfter',
args: {
id: documentId,
space_id: spaceId,
},
},
{
id: documentId,
table: 'block',
path: [],
command: 'update',
args: {
created_by: userId,
created_time: time,
last_edited_time: time,
last_edited_by: userId,
space_id: spaceId,
},
},
{
id: parentId,
table: 'block',
path: [],
command: 'update',
args: {
last_edited_time: time,
space_id: spaceId,
},
},
{
id: documentId,
table: 'block',
path: ['properties', 'title'],
command: 'set',
args: [[title]],
},
{
id: documentId,
table: 'block',
path: [],
command: 'update',
args: {
last_edited_time: time,
space_id: spaceId,
},
},
];
} else if (repository.pageType === COLLECTION_VIEW_PAGE) {
operations = [
{
id: documentId,
table: 'block',
path: [],
command: 'set',
args: {
type: 'page',
id: documentId,
space_id: spaceId,
version: 1,
},
},
{
id: documentId,
table: 'block',
path: [],
command: 'update',
args: {
parent_id: parentId,
parent_table: 'collection',
space_id: spaceId,
alive: true,
},
},
];
}
await this.requestWithCookie.post('api/v3/saveTransactionsFanout', {
requestId: requestId,
transactions: [
{
id: inner_requestId,
operations: operations,
spaceId: spaceId,
}
]
});
return documentId;
};
getFileUrl = async (fileName: string) => {
const result = await this.requestWithCookie.post<{
url: string;
signedPutUrl: string;
signedToken: string;
}>('api/v3/getUploadFileUrl', {
bucket: 'temporary',
name: fileName,
contentType: 'text/markdown',
});
return result.data;
};
private async loadSpace(
spaceId: string,
spaceName: string,
recentPages: RecentPages
): Promise {
const response = await this.requestWithCookie.post<{
pages: string[];
recordMap: {
block: {
[id: string]: {
value: {
collection_id: string;
id: string;
type: string;
space_id: string;
properties: {
title: string[];
};
};
};
};
};
}>('api/v3/getUserSharedPagesInSpace', {
includeDeleted: false,
includeTeamSharedPages: false,
spaceId,
});
const pages: string[] = response.data.pages as string[];
return pages
.map((pageId): NotionRepository | null => {
const value = response.data.recordMap.block[pageId]!.value;
if (value.type === PAGE && !!value.properties && !!value.properties.title) {
return {
id: value.id,
name: value.properties.title.toString(),
groupId: spaceId,
groupName: spaceName,
pageType: PAGE,
};
}
const collections = recentPages.recordMap.collection;
if (
value.type === COLLECTION_VIEW_PAGE &&
!!value.collection_id &&
!!collections &&
!!collections[value.collection_id] &&
!!collections[value.collection_id].value &&
!!collections[value.collection_id].value.name
) {
return {
id: collections[value.collection_id].value.id,
name: collections[value.collection_id].value.name.toString(),
groupId: spaceId,
groupName: spaceName,
pageType: COLLECTION_VIEW_PAGE,
};
}
return null;
})
.filter((p): p is NotionRepository => !!p);
}
private async getRecentPageVisits(spaceId: string, userId: string): Promise {
const res = await this.requestWithCookie.post('api/v3/getRecentPageVisits', {
spaceId,
userId,
});
return res.data;
}
private getUserContent = async () => {
const response = await this.requestWithCookie.post('api/v3/loadUserContent');
return response.data;
};
/**
* Modify the cookie when request
*/
private get requestWithCookie() {
const post = async (url: string, data?: any) => {
const cookies = await this.cookieService.getAll({
url: origin,
});
const cookieString = cookies.map((o) => `${o.name}=${o.value}`).join(';');
const header = await this.webRequestService.startChangeHeader({
urls: [`${origin}*`],
requestHeaders: [
{
name: 'cookie',
value: cookieString,
},
{
name: `Content-Type`,
value: 'application/json',
},
],
});
try {
const result = await this.request.post(
await this.webRequestService.changeUrl(url, header),
data,
{}
);
await this.webRequestService.end(header);
return result;
} catch (error) {
await this.webRequestService.end(header);
throw error;
}
};
return {
post,
};
}
}
================================================
FILE: src/common/backend/services/notion/types.ts
================================================
import { Repository } from '../interface';
export interface NotionUserContent {
recordMap: {
notion_user: {
[uuid: string]: {
role: string;
value: {
name: string;
id: string;
email: string;
profile_photo: string;
};
};
};
space: {
[id: string]: {
role: string;
value: {
id: string;
name: string;
domain: string;
pages: string[];
};
};
};
block: {
[uuid: string]: {
role: string;
value: {
id: string;
version: string;
parent_id: string;
type: string;
created_time: number;
properties: {
title: string[][];
content: string[];
};
collection_id: string;
};
};
};
collection: {
[uuid: string]: {
role: string;
value: {
id: string;
version: string;
parent_id: string;
name: string[][];
};
};
};
};
}
export interface RecentPages {
recordMap: {
collection?: {
[uuid: string]: {
role: string;
value: {
id: string;
version: string;
parent_id: string;
name: string[][];
};
};
};
};
}
export interface NotionRepository extends Repository {
pageType: string;
}
================================================
FILE: src/common/backend/services/obsidian/form.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import { Input } from 'antd';
import React, { Component, Fragment } from 'react';
import { ObsidianFormConfig } from './interface';
interface OneNoteProps {
info?: ObsidianFormConfig;
}
export default class extends Component {
render() {
const {
form: { getFieldDecorator },
info,
} = this.props;
let initData: Partial = {};
if (info) {
initData = info;
}
return (
{getFieldDecorator('vault', {
initialValue: initData.vault,
rules: [
{
required: true,
message: 'Please input your vault!',
},
],
})( )}
{getFieldDecorator('folder', {
initialValue: initData.folder,
rules: [
{
required: true,
message: 'Please input the folders you want to save!',
},
],
})( )}
);
}
}
================================================
FILE: src/common/backend/services/obsidian/index.ts
================================================
import localeService from '@/common/locales';
import { ServiceMeta } from './../interface';
import Service from './service';
import From from './form';
export default (): ServiceMeta => {
return {
name: localeService.format({
id: 'backend.services.obsidian.name',
defaultMessage: 'Obsidian',
}),
form: From,
icon: 'obsidian',
type: 'obsidian',
service: Service,
};
};
================================================
FILE: src/common/backend/services/obsidian/interface.ts
================================================
export interface ObsidianFormConfig {
vault: string;
folder: string;
}
================================================
FILE: src/common/backend/services/obsidian/service.ts
================================================
import md5 from '@web-clipper/shared/lib/md5';
import { CreateDocumentRequest, DocumentService } from '../../index';
import { ObsidianFormConfig } from './interface';
import QueryString from 'query-string';
export default class ObsidianService implements DocumentService {
constructor(private config: ObsidianFormConfig) {}
getId = () => {
return md5(JSON.stringify(this.config));
};
getUserInfo = async () => {
return {
name: 'Obsidian',
avatar: '',
description: `Vault: ${this.config.vault}`,
};
};
getRepositories = async () => {
const folders = (this.config.folder || '').split('\n').map((folder) => {
return {
id: folder,
name: folder,
groupId: 'obsidian',
groupName: this.config.vault,
};
});
return folders;
};
createDocument = async (info: CreateDocumentRequest) => {
const file = `${info.repositoryId}/${info.title}`;
window.open(
QueryString.stringifyUrl({
url: 'obsidian://new',
query: {
silent: true,
vault: this.config.vault,
file,
content: info.content,
},
})
);
return {
href: QueryString.stringifyUrl({
url: 'obsidian://open',
query: {
vault: this.config.vault,
file,
},
}),
};
};
}
================================================
FILE: src/common/backend/services/onenote_oauth/form.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Component, Fragment } from 'react';
import { OneNoteBackendServiceConfig } from './interface';
interface OneNoteProps {
verified?: boolean;
info?: OneNoteBackendServiceConfig;
}
export default class extends Component {
render() {
const {
form: { getFieldDecorator },
info,
verified,
} = this.props;
let initData: Partial = {};
if (info) {
initData = info;
}
let editMode = info ? true : false;
return (
{getFieldDecorator('access_token', {
initialValue: initData.access_token,
rules: [
{
required: true,
message: 'AccessToken is required!',
},
],
})( )}
{getFieldDecorator('refresh_token', {
initialValue: initData.refresh_token,
rules: [
{
required: true,
message: 'RefreshToken is required!',
},
],
})( )}
);
}
}
================================================
FILE: src/common/backend/services/onenote_oauth/index.ts
================================================
import { IConfigService } from '@/service/common/config';
import { Container } from 'typedi';
import config from '@/config';
import { ServiceMeta } from './../interface';
import Service from './service';
import localeService from '@/common/locales';
import { stringify } from 'qs';
import form from './form';
export default (): ServiceMeta => {
const oauthUrl = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${stringify({
scope: 'Notes.Create User.Read offline_access',
client_id: config.oneNoteClientId,
state: Container.get(IConfigService).id,
response_type: 'code',
response_mode: 'query',
redirect_uri: config.oneNoteCallBack,
})}`;
return {
name: localeService.format({
id: 'backend.services.onenote_oauth.name',
defaultMessage: 'OneNote',
}),
icon: 'OneNote',
type: 'onenote_oauth',
service: Service,
oauthUrl,
form: form,
homePage: 'https://products.office.com/en-us/onenote/digital-note-taking-app',
permission: {
origins: ['https://graph.microsoft.com/*', 'https://login.microsoftonline.com/*'],
},
};
};
================================================
FILE: src/common/backend/services/onenote_oauth/interface.ts
================================================
export interface OneNoteBackendServiceConfig {
refresh_token: string;
access_token: string;
}
export interface OneNoteNotebooksResponse {
value: {
id: string;
displayName: string;
sections: {
id: string;
displayName: string;
}[];
}[];
}
export interface OneNoteUserInfoResponse {
id: string;
displayName: string;
userPrincipalName: string;
}
export interface OneNoteCreateDocumentResponse {
id: string;
links: {
oneNoteClientUrl: {
href: string;
};
oneNoteWebUrl: {
href: string;
};
};
}
export interface OneNoteRefreshTokenResponse {
access_token: string;
refresh_token: string;
}
================================================
FILE: src/common/backend/services/onenote_oauth/service.ts
================================================
import { DocumentService, CreateDocumentRequest } from './../../index';
import axios, { AxiosInstance } from 'axios';
import md5 from '@web-clipper/shared/lib/md5';
import {
OneNoteNotebooksResponse,
OneNoteUserInfoResponse,
OneNoteCreateDocumentResponse,
OneNoteBackendServiceConfig,
OneNoteRefreshTokenResponse,
} from './interface';
import _ from 'lodash';
import { Repository, UserInfo, UnauthorizedError } from '../interface';
import showdown from 'showdown';
import config from '@/config';
import { stringify } from 'qs';
const converter = new showdown.Converter();
const BASE_URL = `https://graph.microsoft.com/`;
export default class YuqueDocumentService implements DocumentService {
private request: AxiosInstance;
private config: OneNoteBackendServiceConfig;
private repositories: Repository[];
constructor({ access_token, refresh_token }: OneNoteBackendServiceConfig) {
this.config = { access_token, refresh_token };
this.request = axios.create({
baseURL: BASE_URL,
headers: { Authorization: `bearer ${access_token}` },
timeout: 100000,
transformResponse: [data => JSON.parse(data)],
withCredentials: true,
});
this.request.interceptors.response.use(
r => r,
error => {
if (error.response && error.response.status === 401) {
const ere = new UnauthorizedError();
return Promise.reject(ere);
}
return Promise.reject(error);
}
);
this.repositories = [];
}
getId = () => md5(this.config.access_token);
getUserInfo = async (): Promise => {
const response = await this.request.get('v1.0/me');
const { data } = response;
return {
name: data.displayName,
description: data.userPrincipalName,
avatar: '',
homePage: 'https://www.onenote.com/notebooks',
};
};
getRepositories = async (): Promise => {
const response = await this.request.get(
'/v1.0/me/onenote/notebooks??expand=sections,sectionGroups'
);
let promises = await response.data.value.map(({ id: groupId, displayName: groupName }) => {
return this.getSections(true, groupId, '', groupId, groupName);
});
for (const p of promises) {
this.repositories.push(..._.flatten(await p));
}
return this.repositories;
};
/**
* Add sections recursively (even for those in Section Groups)
*
* @param {boolean} notebook In Microsoft API, the topmost level should be "notebooks",
* then "sectionGroups" in later levels
* @param {string} preId The id passing in.
* @param {string} prefix to denote names of Section Groups.
* @param {sring} groupId for notebook id.
* @param {string} groupName for notebook name.
*/
getSections = async (
notebook: boolean,
preId: string,
prefix: string,
groupId: string,
groupName: string
): Promise => {
let repos = [];
// Handle sections
const sectionsRes = await this.request.get(
`/v1.0/users/me/onenote/${notebook ? 'notebooks/' : 'sectionGroups/'}${preId}/sections`
);
repos.push(
..._.flatten(
sectionsRes.data.value.map(({ id, displayName: tempName }) => {
let name = (prefix === '' ? '' : `${prefix}-`) + tempName;
return {
name,
id,
groupId,
groupName,
};
})
)
);
// Handle sectionGroups recursively
const sectionGroupsRes = await this.request.get(
`/v1.0/users/me/onenote/${notebook ? 'notebooks/' : 'sectionGroups/'}${preId}/sectionGroups`
);
let promises = await sectionGroupsRes.data.value.map(({ id, displayName: subPrefix }) => {
let newPrefix = (prefix === '' ? '' : `${prefix}-`) + subPrefix;
return this.getSections(false, id, newPrefix, groupId, groupName);
});
for (const p of promises) {
repos.push(..._.flatten(await p));
}
return repos;
};
createDocument = async (info: CreateDocumentRequest): Promise => {
const { repositoryId } = info;
const repository = this.repositories.find(o => o.id === repositoryId);
if (!repository) {
throw new Error('Illegal repositoryId');
}
let formData = new FormData();
const html = `
${info.title}
${converter.makeHtml(`${info.content}`)}
`;
const blob = new Blob([html], {
type: 'text/html',
});
formData.append('Presentation', blob);
const result = await this.request.post(
`v1.0/me/onenote/sections/${encodeURI(repositoryId)}/pages`,
formData
);
return {
href: result.data.links.oneNoteWebUrl.href,
};
};
refreshToken = async ({ access_token, refresh_token, ...rest }: OneNoteBackendServiceConfig) => {
const response = await this.request.post(
'https://login.microsoftonline.com/common/oauth2/v2.0/token',
stringify({
scope: 'Notes.Create User.Read offline_access',
redirect_uri: config.oneNoteCallBack,
grant_type: 'refresh_token',
client_id: config.oneNoteClientId,
refresh_token,
})
);
return {
...rest,
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
};
};
}
================================================
FILE: src/common/backend/services/server_chan/form.tsx
================================================
import { KeyOutlined } from '@ant-design/icons';
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment } from 'react';
import { FormattedMessage } from 'react-intl';
interface FormProps extends FormComponentProps {
verified?: boolean;
info?: {
accessToken: string;
};
}
const ConfigForm: React.FC = ({ form: { getFieldDecorator }, info, verified }) => {
const disabled = verified || !!info;
let initAccessToken;
if (info) {
initAccessToken = info.accessToken;
}
return (
{getFieldDecorator('accessToken', {
initialValue: initAccessToken,
rules: [
{
required: true,
message: (
),
},
],
})(
}
/>
)}
);
};
export default ConfigForm;
================================================
FILE: src/common/backend/services/server_chan/index.ts
================================================
import Service from './service';
import Form from './form';
import localeService from '@/common/locales';
export default () => {
return {
name: localeService.format({
id: 'backend.services.server_chan.name',
}),
icon: 'wechat',
type: 'server_chan',
service: Service,
form: Form,
homePage: 'https://sc.ftqq.com/',
permission: {
origins: ['https://sc.ftqq.com/*'],
},
};
};
================================================
FILE: src/common/backend/services/server_chan/service.ts
================================================
import localeService from '@/common/locales';
import { CompleteStatus } from 'common/backend/interface';
import { DocumentService, CreateDocumentRequest } from '../../index';
import request from 'umi-request';
export default class GithubDocumentService implements DocumentService {
private config: { accessToken: string };
constructor(config: { accessToken: string }) {
this.config = config;
}
getId = () => {
return this.config.accessToken;
};
getUserInfo = async () => {
return {
name: localeService.format({
id: 'backend.services.server_chan.name',
}),
avatar: '',
homePage: 'https://sc.ftqq.com/',
description: localeService.format({
id: 'backend.services.server_chan.name',
}),
};
};
getRepositories = async () => {
return [
{
id: 'server_chan',
name: localeService.format({
id: 'backend.services.server_chan.name',
}),
groupId: 'server_chan',
groupName: localeService.format({
id: 'backend.services.server_chan.name',
}),
},
];
};
createDocument = async (info: CreateDocumentRequest): Promise => {
const res = await request.post<{ errmsg: string; errno: number }>(
`https://sc.ftqq.com/${this.config.accessToken}.send`,
{
requestType: 'form',
data: {
text: info.title,
desp: info.content,
},
}
);
if (res.errno !== 0) {
throw new Error(res.errmsg);
}
return {
href: `http://sc.ftqq.com/`,
};
};
}
================================================
FILE: src/common/backend/services/siyuan/form.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment } from 'react';
import localeService from '@/common/locales';
interface SiyuanFormProps {
info?: SiyuanBackendServiceConfig;
}
interface SiyuanBackendServiceConfig {
accessToken?: string;
}
const form: React.FC = props => {
const {
form: { getFieldDecorator },
info,
} = props;
let initData: Partial = {};
if (info) {
initData = info;
}
let editMode = info ? true : false;
return (
{getFieldDecorator('accessToken', {
initialValue: initData.accessToken,
})( )}
);
};
export default form;
================================================
FILE: src/common/backend/services/siyuan/index.ts
================================================
import localeService from '@/common/locales';
import { ServiceMeta } from './../interface';
import Service from './service';
import form from './form';
/**
* @see https://github.com/siyuan-note/siyuan/issues/1266
*/
export default () => {
return {
name: localeService.format({
id: 'backend.services.siyuan.name',
}),
icon: 'siyuan',
form,
type: 'siyuan',
service: Service,
homePage: 'https://b3log.org/siyuan/',
permission: {
origins: ['http://localhost:6806/*', 'http://127.0.0.1:6806/*'],
},
} as ServiceMeta;
};
================================================
FILE: src/common/backend/services/siyuan/service.ts
================================================
import { CompleteStatus } from 'common/backend/interface';
import { Repository, CreateDocumentRequest } from './../interface';
import { DocumentService } from '../../index';
import { IBasicRequestService } from '@/service/common/request';
import { Container } from 'typedi';
import { SiYuanClient } from '../../clients/siyuan/client';
import localeService from '@/common/locales';
/**
*
* Document service for self hosted leanote or leanote.com
*/
export default class SiYuanDocumentService implements DocumentService {
private client: SiYuanClient;
constructor(config: { accessToken?: string }) {
this.client = new SiYuanClient({
request: Container.get(IBasicRequestService),
accessToken: config.accessToken,
});
}
/** Unique account identification */
getId = () => {
return 'siyuan';
};
getUserInfo = async () => {
return {
name: 'siyuan',
avatar: '',
homePage: '',
description: ``,
};
};
getRepositories = async () => {
let response = await this.client.listNotebooks();
return response.map(
({ name, id }): Repository => {
return {
groupId: 'siyuan',
groupName: localeService.format({
id: 'backend.services.siyuan.notes',
}),
id,
name,
};
}
);
};
createDocument = async (data: CreateDocumentRequest): Promise => {
const id = await this.client.createNote(data);
return {
href: `siyuan://blocks/${id}`,
};
};
}
================================================
FILE: src/common/backend/services/ticktick/headerForm.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Select } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment } from 'react';
import backend from '../..';
import Dida365DocumentService from './service';
import locale from '@/common/locales';
import { useFetch } from '@shihengtech/hooks';
const HeaderForm: React.FC = ({ form: { getFieldDecorator } }) => {
const service = backend.getDocumentService() as Dida365DocumentService;
const tagsResponse = useFetch(async () => service.getTags(), [service], {
initialState: {
data: [],
},
});
return (
{getFieldDecorator('tags', {
initialValue: [],
})(
{tagsResponse.data?.map(o => (
{o}
))}
)}
);
};
export default HeaderForm;
================================================
FILE: src/common/backend/services/ticktick/index.ts
================================================
import localeService from '@/common/locales';
import { ServiceMeta } from '@/common/backend';
import Service from './service';
import headerForm from './headerForm';
export default (): ServiceMeta => {
return {
name: localeService.format({
id: 'backend.services.ticktick.name',
defaultMessage: 'TickTick',
}),
icon: 'dida365',
type: 'ticktick',
headerForm,
service: Service,
permission: {
origins: ['https://api.ticktick.com/*'],
permissions: [],
},
};
};
================================================
FILE: src/common/backend/services/ticktick/service.ts
================================================
import { Container } from 'typedi';
import { generateUuid } from '@web-clipper/shared/lib/uuid';
import localeService from '@/common/locales';
import { IWebRequestService } from '@/service/common/webRequest';
import {
CreateDocumentRequest,
CompleteStatus,
UnauthorizedError,
Repository,
} from '@/common/backend/services/interface';
import { DocumentService } from '@/common/backend/index';
import { extend, RequestMethod } from 'umi-request';
interface TickTickProfile {
name: string;
username: string;
picture: string;
}
interface TickTickCheckResponse {
projectProfiles: {
id: string;
name: string;
isOwner: boolean;
closed: boolean;
groupId: string;
}[];
projectGroups: {
id: string;
name: string;
}[];
tags: {
name: string;
}[];
}
interface TickTickCreateDocumentRequest extends CreateDocumentRequest {
tags: string[];
}
export default class TickTickDocumentService implements DocumentService {
private request: RequestMethod;
constructor() {
const request = extend({
prefix: `https://api.ticktick.com/api/v2/`,
});
request.interceptors.response.use(
(response) => {
if (response.clone().status === 401) {
throw new UnauthorizedError(
localeService.format({
id: 'backend.services.ticktick.unauthorizedErrorMessage',
defaultMessage: 'Unauthorized! Please Login TickTick Web.',
})
);
}
return response;
},
{ global: false }
);
this.request = request;
}
getId = () => {
return 'TickTick';
};
getUserInfo = async () => {
const response = await this.request.get('user/profile');
return {
name: response.name,
avatar: response.picture,
homePage: '',
description: response.username,
};
};
getTags = async (): Promise => {
const TickTickCheckResponse = await this.request.get(`batch/check/0`);
return TickTickCheckResponse.tags.map((o) => o.name);
};
getRepositories = async (): Promise => {
const TickTickCheckResponse = await this.request.get(`batch/check/0`);
const groupMap = new Map();
TickTickCheckResponse.projectGroups.forEach((group) => {
groupMap.set(group.id, group.name);
});
return TickTickCheckResponse.projectProfiles
.filter((o) => !o.closed)
.map(({ id, name, groupId }) => ({
id: id,
name: name,
groupId: groupId
? groupId
: localeService.format({
id: 'backend.services.ticktick.rootGroup',
defaultMessage: 'Root',
}),
groupName: groupId
? groupMap.get(groupId)!
: localeService.format({
id: 'backend.services.ticktick.rootGroup',
defaultMessage: 'Root',
}),
}));
};
createDocument = async (request: TickTickCreateDocumentRequest): Promise => {
const webRequestService = Container.get(IWebRequestService);
const header = await webRequestService.startChangeHeader({
urls: ['https://api.ticktick.com/*'],
requestHeaders: [
{
name: 'origin',
value: 'https://ticktick.com',
},
],
});
const settings = await this.request.get<{ timeZone: string }>(
await webRequestService.changeUrl('user/preferences/settings?includeWeb=true', header)
);
const id = generateUuid().replace(/-/g, '').slice(0, 24);
const data = {
add: [
{
items: [],
reminders: [],
exDate: [],
dueDate: null,
priority: 0,
progress: 0,
assignee: null,
kind: 'NOTE',
sortOrder: -4611733297427382000,
startDate: null,
isFloating: false,
status: 0,
deleted: 0,
tags: request.tags,
projectId: request.repositoryId,
title: request.title,
content: request.content,
timeZone: settings.timeZone,
id: id,
},
],
update: [],
delete: [],
};
await this.request.post(await webRequestService.changeUrl('batch/task', header), {
data: data,
headers: {
[header.name]: header.value,
},
});
await webRequestService.end(header);
return {
href: `https://ticktick.com/#p/${request.repositoryId}/tasks/${id}`,
};
};
}
================================================
FILE: src/common/backend/services/ulysses/form.tsx
================================================
import React from 'react';
import { FormattedMessage } from 'react-intl';
export default () => (
);
================================================
FILE: src/common/backend/services/ulysses/index.ts
================================================
import Service from './service';
import Form from './form';
export default () => {
return {
name: 'Ulysses',
icon: 'ulysses',
type: 'ulysses',
service: Service,
form: Form,
};
};
================================================
FILE: src/common/backend/services/ulysses/service.ts
================================================
import { ITabService } from './../../../../service/common/tab';
import { CompleteStatus } from 'common/backend/interface';
import { DocumentService, CreateDocumentRequest } from '../../index';
import Container from 'typedi';
export default class GithubDocumentService implements DocumentService {
getId = () => {
return 'ulysses';
};
getUserInfo = async () => {
return {
name: 'Ulysses',
avatar: '',
description: 'Ulysses app',
};
};
getRepositories = async () => {
return [
{
id: 'ulysses',
name: 'Ulysses',
groupId: 'ulysses',
groupName: 'Ulysses',
},
];
};
createDocument = async (info: CreateDocumentRequest): Promise => {
const text = `# ${info.title}\n\n${info.content}`;
const url = `ulysses://x-callback-url/new-sheet?text=${encodeURIComponent(text)}`;
Container.get(ITabService).create({ url });
return {
href: `ulysses://x-callback-url/open-recent`,
};
};
}
================================================
FILE: src/common/backend/services/webdav/form.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment } from 'react';
import { WebDAVServiceConfig } from './interface';
import useOriginForm from '@/hooks/useOriginForm';
import { FormattedMessage } from 'react-intl';
interface FormProps extends FormComponentProps {
info?: WebDAVServiceConfig;
}
const ConfigForm: React.FC = ({ form, form: { getFieldDecorator }, info }) => {
const { verified, handleAuthentication, formRules } = useOriginForm({ form, initStatus: !!info });
return (
}
>
{form.getFieldDecorator('origin', {
initialValue: info?.origin,
rules: formRules,
})(
}
onSearch={handleAuthentication}
disabled={verified}
/>
)}
{verified && (
{getFieldDecorator('username', {
initialValue: info?.username,
rules: [
{
required: true,
},
],
})( )}
{getFieldDecorator('password', {
initialValue: info?.password,
rules: [
{
required: true,
},
],
})( )}
)}
);
};
export default ConfigForm;
================================================
FILE: src/common/backend/services/webdav/index.ts
================================================
import Service from './service';
import Form from './form';
export default () => {
return {
name: 'WebDAV',
icon: 'webdav',
type: 'webdav',
service: Service,
form: Form,
homePage: '',
};
};
================================================
FILE: src/common/backend/services/webdav/interface.ts
================================================
export interface WebDAVServiceConfig {
origin: string;
username: string;
password: string;
}
================================================
FILE: src/common/backend/services/webdav/service.ts
================================================
import { WebDAVServiceConfig } from './interface';
import { DocumentService, CreateDocumentRequest, Repository } from './../interface';
//@ts-ignore
//@TODO use webdav/web
import { FileStat, WebDAVClient, createClient } from 'webdav/dist/web';
export default class WebDAVDocumentService implements DocumentService {
private auth: string;
private client: WebDAVClient;
constructor(private config: WebDAVServiceConfig) {
this.auth = btoa(`${this.config.username}:${this.config.password}`);
const originData: {
[key: string]: string;
} = {
'https://dav.jianguoyun.com': 'https://dav.jianguoyun.com/dav',
};
const entryPoint = originData[this.config.origin] ?? this.config.origin;
this.client = createClient(entryPoint, {
username: this.config.username,
password: this.config.password,
});
}
getId = () => {
return this.auth;
};
getUserInfo = async () => {
return {
name: 'WebDAV',
avatar: '',
description: this.config.username,
};
};
getRepositories = async (): Promise => {
const result = await this.getChildrenList();
return result.map(o => ({
id: o.href,
name: o.displayname,
groupId: 'Root',
groupName: 'Root',
}));
};
getChildrenList = async (parent = '/'): Promise<{ displayname: string; href: string }[]> => {
const list = await this.client.getDirectoryContents(parent).then(files =>
(files as FileStat[]).map(file => ({
href: file.filename,
displayname: file.basename,
}))
);
console.log({ list });
return list;
};
createDocument = async (info: CreateDocumentRequest): Promise => {
await this.client.putFileContents(
`${info.repositoryId}/${info.title.replace(/[\\/]/g, '_')}.md`,
info.content
);
return;
};
}
================================================
FILE: src/common/backend/services/wiznote/form.tsx
================================================
import React from 'react';
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import { WizNoteConfig } from '@/common/backend/services/wiznote/interface';
import { FormattedMessage } from 'react-intl';
import useOriginForm from '@/hooks/useOriginForm';
interface WizNoteFormProps extends FormComponentProps {
info?: WizNoteConfig;
}
const WizNoteForm: React.FC = ({ form, info }) => {
const { verified, handleAuthentication, formRules } = useOriginForm({ form, initStatus: !!info });
return (
}
>
{form.getFieldDecorator('origin', {
initialValue: info?.origin ?? 'https://note.wiz.cn',
rules: formRules,
})(
}
onSearch={handleAuthentication}
disabled={verified}
/>
)}
);
};
export default WizNoteForm;
================================================
FILE: src/common/backend/services/wiznote/headerForm.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Select } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment } from 'react';
import backend from '../..';
import { useFetch } from '@shihengtech/hooks';
import WizNoteDocumentService from './service';
import locale from '@/common/locales';
const HeaderForm: React.FC = ({ form: { getFieldDecorator } }) => {
const service = backend.getDocumentService() as WizNoteDocumentService;
const tagResponse = useFetch(async () => service.getTags(), [service], {
initialState: {
data: [],
},
});
return (
{getFieldDecorator('tags', {
initialValue: [],
})(
{tagResponse.data?.map(o => (
{o.name}
))}
)}
);
};
export default HeaderForm;
================================================
FILE: src/common/backend/services/wiznote/index.ts
================================================
import localeService from '@/common/locales';
import Service from './service';
import Form from './form';
import headerForm from './headerForm';
export default () => {
return {
name: localeService.format({
id: 'backend.services.wiznote.name',
}),
icon: 'wiznote',
type: 'WizNote',
headerForm,
service: Service,
form: Form,
permission: {
permissions: ['cookies'],
},
};
};
================================================
FILE: src/common/backend/services/wiznote/interface.ts
================================================
import { CreateDocumentRequest } from '../interface';
export interface WizNoteConfig {
origin: string;
spaceId: number;
}
export interface WizNoteUserInfo {
result: {
email: string;
userGuid: string;
displayName: string;
token: string;
kbGuid: string;
};
}
export interface WizNoteCreateDocumentRequest extends CreateDocumentRequest {
tags: string[];
}
export interface WizNoteCreateTagResponse {
result: {
tagGuid: string;
};
}
export interface WizNoteGetTagsResponse {
result: {
id: string;
name: string;
tagGuid: string;
}[];
}
export interface WizNoteGetRepositoriesResponse {
result: string[];
}
================================================
FILE: src/common/backend/services/wiznote/service.ts
================================================
import { IWebRequestService, RequestInBackgroundOptions } from '@/service/common/webRequest';
import { Container } from 'typedi';
import {
WizNoteUserInfo,
WizNoteConfig,
WizNoteGetTagsResponse,
WizNoteCreateTagResponse,
WizNoteGetRepositoriesResponse,
WizNoteCreateDocumentRequest,
} from '@/common/backend/services/wiznote/interface';
import md5 from '@web-clipper/shared/lib/md5';
import { DocumentService } from '@/common/backend/index';
import { Repository, CompleteStatus } from '../interface';
import { IBasicRequestService } from '@/service/common/request';
import { RequestHelper } from '@/service/request/common/request';
import { generateUuid } from '@web-clipper/shared/lib/uuid';
interface WizTempDoc {
docGuid: string;
resources: string[];
}
export default class WizNoteDocumentService implements DocumentService {
private config: WizNoteConfig;
private webRequestService: IWebRequestService;
private imageRequest: RequestHelper;
private userInfo?: WizNoteUserInfo['result'];
private tempDoc?: WizTempDoc | null;
constructor(config: WizNoteConfig) {
this.config = config;
this.imageRequest = new RequestHelper({
baseURL: this.config.origin,
request: Container.get(IBasicRequestService),
});
this.webRequestService = Container.get(IWebRequestService);
}
getId = () => {
return md5(`${this.config.origin}`);
};
getUserInfo = async () => {
if (!this.userInfo) {
const response = await this.request(
'/as/user/login/auto?clientType=web&clientVersion=4.0&lang=zh-cn'
);
this.userInfo = response.result;
}
return {
name: this.userInfo.displayName,
avatar: `${this.config.origin}/as/user/avatar/${this.userInfo.userGuid}?avatarVersion=1`,
homePage: '',
description: this.userInfo.email,
};
};
getRepositories = async (): Promise => {
await this.getUserInfo();
const response = await this.request(
`/ks/category/all/${this.userInfo!.kbGuid}`
);
return response.result
.sort((a, b) => a.localeCompare(b))
.map(o => {
return {
id: o,
name: o,
groupId: '为知笔记',
groupName: '为知笔记',
};
});
};
getTags = async () => {
const response = await this.request(
`/ks/tag/all/${this.userInfo?.kbGuid}?clientType=web&clientVersion=4.0&lang=zh-cn`
);
return response.result;
};
createTag = async (name: string) => {
const response = await this.request(
`/ks/tag/create/${this.userInfo?.kbGuid}?clientType=web&clientVersion=4.0&lang=zh-cn`,
{
method: 'post',
data: {
parentTagGuid: null,
name,
},
}
);
return response.result.tagGuid;
};
uploadBlob = async (blob: Blob) => {
if (!this.tempDoc) {
const response = await this.doCreateDocument({
kbGuid: this.userInfo?.kbGuid,
html: ` `,
owner: this.userInfo?.email,
title: `tmp-${generateUuid()}.md`,
params: null,
appInfo: null,
});
this.tempDoc = {
docGuid: response.result.docGuid,
resources: [],
};
}
const kbGuid = this.userInfo?.kbGuid ?? '';
const docGuid = this.tempDoc.docGuid;
const formData = new FormData();
formData.append('kbGuid', kbGuid);
formData.append('docGuid', docGuid);
formData.append('data', blob);
const response = await this.imageRequest.postForm<{
result: {
name: string;
url: string;
};
}>(`/ks/resource/upload/${kbGuid}/${docGuid}`, {
data: formData,
headers: {
'X-Wiz-Referer': this.config.origin,
'X-Wiz-Token': this.userInfo?.token ?? '',
},
});
this.tempDoc.resources.push(response.result.name);
return response.result.url;
};
doCreateDocument = async (data: any) => {
const response = await this.request<{
result: {
docGuid: string;
};
}>(`/ks/note/create/${this.userInfo?.kbGuid}?clientType=web&clientVersion=4.0&lang=zh-cn`, {
method: 'post',
data,
});
return response;
};
doUpdateDocument = async (data: any) => {
const response = await this.request<{
result: {
docGuid: string;
};
}>(
`/ks/note/save/${this.userInfo?.kbGuid}/${data.docGuid}?clientType=web&clientVersion=4.0&lang=zh-cn`,
{
method: 'put',
data,
}
);
return response;
};
createDocument = async (req: WizNoteCreateDocumentRequest): Promise => {
const existTags = await this.getTags();
const tags = await Promise.all(
req.tags.map(async tag => {
const exist = existTags.find(o => o.name === tag);
if (exist) {
return exist.tagGuid;
}
return this.createTag(tag);
})
);
const html = `${req.content} `;
let response;
const data = {
kbGuid: this.userInfo?.kbGuid,
html: ` ${html}`,
category: req.repositoryId,
url: req.url,
owner: this.userInfo?.email,
tags: tags.join('*'),
title: `${req.title}.md`,
params: null,
appInfo: null,
};
if (this.tempDoc) {
response = await this.doUpdateDocument({
...data,
...this.tempDoc,
});
} else {
response = await this.doCreateDocument({
...data,
});
this.tempDoc = null;
}
return {
href: `${this.config.origin}/wapp/folder/${this.userInfo!.kbGuid}?c=${encodeURIComponent(
req.repositoryId
)}&docGuid=${response.result.docGuid}`,
};
};
private request(
url: string,
options?: Omit
) {
return this.webRequestService.requestInBackground(url, {
prefix: this.config.origin,
headers: {
'X-Wiz-Referer': this.config.origin,
'X-Wiz-Token': this.userInfo?.token ?? '',
},
...options,
});
}
}
================================================
FILE: src/common/backend/services/wolai/index.ts
================================================
import { ServiceMeta } from '@/common/backend';
import Service from './service';
export default (): ServiceMeta => {
return {
name: '我来',
icon: 'https://static2.wolai.com/dist/favicon.ico',
type: 'wolai',
homePage: 'https://www.wolai.com/',
service: Service,
permission: {
origins: ['https://api.wolai.com/*'],
permissions: ['cookies'],
},
};
};
================================================
FILE: src/common/backend/services/wolai/service.ts
================================================
import { CompleteStatus, UnauthorizedError } from '../interface';
import { DocumentService, CreateDocumentRequest } from '../../index';
import localeService from '@/common/locales';
import short from 'short-uuid';
import { extend, RequestMethod } from 'umi-request';
import { WolaiRepository, WolaiUserContent, WolaiUserInfo } from './type';
import { IWebRequestService, WebBlockHeader } from '@/service/common/webRequest';
import Container from 'typedi';
import { ICookieService } from '@/service/common/cookie';
const PAGE = 'page';
const origin = 'https://api.wolai.com/';
export default class WolaiDocumentService implements DocumentService {
private request: RequestMethod;
private repositories: WolaiRepository[];
private userContent?: WolaiUserContent;
private userInfo?: WolaiUserInfo;
private webRequestService: IWebRequestService;
private cookieService: ICookieService;
constructor() {
const request = extend({
prefix: origin,
timeout: 10000,
credentials: 'include',
});
this.request = request;
this.repositories = [];
this.webRequestService = Container.get(IWebRequestService);
this.cookieService = Container.get(ICookieService);
/**
* TODO handle error
*/
request.interceptors.response.use(
(response) => {
if (response.clone().status === 401) {
throw new UnauthorizedError(
localeService.format({
id: 'backend.services.wolai.unauthorizedErrorMessage',
defaultMessage: 'Unauthorized! Please Login Wolai Web.',
})
);
}
return response;
},
{ global: false }
);
}
getUuid = () => {
return short.generate();
};
getId = () => {
return 'wolai';
};
getUserInfo = async () => {
if (!this.userInfo) {
this.userInfo = await this.fetchUserInfo();
}
const { email, userName } = this.userInfo.data;
return {
name: userName,
avatar: '',
homePage: 'https://www.wolai.com/',
description: email,
};
};
getRepositories = async () => {
if (!this.userContent) {
this.userContent = await this.getUserContent();
}
if (!this.userInfo) {
this.userInfo = await this.fetchUserInfo();
}
const { blocks, workspaces } = this.userContent.data;
if (!blocks) {
this.repositories = [];
return [];
}
const result: WolaiRepository[] = [];
Object.values(blocks).forEach((value) => {
const space = workspaces.find((workspace) => workspace.id === value.parent_id);
if (value.type === PAGE && !!value.attributes && !!value.attributes.title && !!space) {
result.push({
id: value.id,
spaceId: space.id,
name: value.attributes.title.toString(),
groupId: space.id,
groupName: space.name,
pageType: PAGE,
});
}
});
this.repositories = result;
return result;
};
createDocument = async ({
repositoryId,
title,
content,
}: CreateDocumentRequest): Promise => {
const fileName = `${title}.md`;
const filekey = `import/${this.getUuid()}/${fileName}`;
const repository = this.repositories.find((o) => o.id === repositoryId);
if (!repository) {
throw new Error('Illegal repository');
}
const documentId = await this.createEmptyFile(repository, title);
const file = new File([content], filekey, {
type: 'text/markdown',
});
const { code, data } = await this.getFileUrl(repository, file);
if (code !== 1000) throw new Error('getSignedPostUrl error');
const formData = new FormData();
Object.keys(data.policyData.formData).forEach((key) => {
formData.append(key, data.policyData.formData[key]);
});
formData.append('key', data.fileUrl);
formData.append('success_action_status', '200');
formData.append('file', file);
await this.requestWithCookie(async (header) => {
//TODO fixme
return extend({}).post(await this.webRequestService.changeUrl(data.policyData.url, header), {
headers: {
[header.name]: header.value,
},
data: formData,
});
});
await this.requestWithCookie(async (header) => {
return this.request.post(
await this.webRequestService.changeUrl('v1/import/getImportPageData', header),
{
data: {
spaceId: repository.spaceId,
type: 'string',
bucket: data.policyData.bucket,
filename: data.fileUrl,
pageTitle: title,
pageId: documentId,
},
}
);
});
return {
href: `https://www.wolai.com/${documentId}`,
};
};
createEmptyFile = async (repository: WolaiRepository, title: string) => {
const documentId = this.getUuid();
const parentId = repository.id;
const spaceId = repository.spaceId;
const operations = {
requestId: this.getUuid(),
transactions: [
{
id: this.getUuid(),
operations: [
{
id: documentId,
table: 'wolai.block',
path: [],
command: 'set',
args: {
type: 'page',
id: documentId,
workspace_id: spaceId,
parent_id: parentId,
parent_type: 'page',
active: true,
},
done: true,
},
{
id: documentId,
table: 'wolai.block',
path: [],
command: 'update',
args: {
sub_nodes: '[]',
setting: '{}',
page_id: parentId,
},
done: true,
},
{
id: parentId,
table: 'wolai.block',
path: ['sub_nodes'],
command: 'listAfter',
args: {
id: documentId,
},
done: true,
},
{
id: documentId,
table: 'wolai.block',
path: ['attributes'],
command: 'update',
args: {
title: [[title]],
},
done: true,
},
{
id: documentId,
table: 'wolai.block',
path: [],
command: 'update',
args: {
type: 'page',
},
done: true,
},
],
},
],
};
await this.requestWithCookie(async (header) => {
return this.request.post(
await this.webRequestService.changeUrl('v1/transaction/updateChanges', header),
{
data: operations,
}
);
});
return documentId;
};
getFileUrl = async (repository: WolaiRepository, file: File) => {
return this.requestWithCookie(async (header) => {
// FIXME: 这里简单获取了文件后缀名,考虑到网页上的文件类型都是比较简单的,不会有类似 xxx.tar.gz 这种长后缀
// 构造一个合法的新文件名,避免上传接口报错
const fileName = `${this.getUuid()}.${file?.name?.split('.').pop()}`
return this.request.post(
await this.webRequestService.changeUrl('v1/file/getSignedPostUrl', header),
{
data: {
spaceId: repository.spaceId,
fileSize: file.size,
type: 'import',
fileName,
},
}
);
});
};
private getUserContent = async () => {
return this.requestWithCookie(async (header) => {
return this.request.post(
await this.webRequestService.changeUrl('v1/transaction/getUserData', header)
);
});
};
private fetchUserInfo = async () => {
return this.requestWithCookie(async (header) => {
return this.request.post(
await this.webRequestService.changeUrl('v1/authentication/user/getUserInfo', header)
);
});
};
/**
* Modify the cookie when request
*/
private requestWithCookie = async (
requestFunction: (header: WebBlockHeader) => Promise
) => {
const cookies = await this.cookieService.getAll({
url: origin,
});
const cookieString = cookies.map((o) => `${o.name}=${o.value}`).join(';');
const header = await this.webRequestService.startChangeHeader({
urls: [`${origin}*`],
requestHeaders: [
{
name: 'cookie',
value: cookieString,
},
{
name: 'origin',
value: 'https://www.wolai.com',
},
],
});
try {
const result = await requestFunction(header);
await this.webRequestService.end(header);
return result;
} catch (error) {
await this.webRequestService.end(header);
throw error;
}
};
}
================================================
FILE: src/common/backend/services/wolai/type.ts
================================================
import { Repository } from '../interface';
export interface WolaiUserContent {
code: number;
message: string;
data: {
spaceViews: {
[uuid: string]: {
id: string;
user_id: string;
workspace_id: string;
created_time: number;
notify_desktop: boolean;
notify_email: boolean;
notify_mobile: boolean;
favorite_pages: any[];
};
};
workspaces: {
id: string;
created_by: string;
created_time: number;
domain: string;
edited_by: string;
edited_time: number;
icon: string;
members: number;
name: string;
pages: string[];
plan_type: string;
team_type: string;
}[];
blocks: {
[uuid: string]: {
id: string;
active: boolean;
attributes: {
title?: string[][];
};
created_by: string;
created_time: number;
edited_by: string;
edited_time: number;
parent_id: string;
parent_type: string;
permissions: {
type: string;
role: string;
user_id: string;
}[];
sub_nodes: string[];
text_content: string;
type: string;
ver: number;
workspace_id: string;
setting: {};
};
};
};
}
export interface WolaiUserInfo {
code: number;
data: {
userId: string;
mobile: string[];
email: string;
userName: string;
avatar: string;
userHash: string;
recommendCode: string;
registerTime: number;
isNewUser: boolean;
inviteRemainingCount: number;
invitedUserCount: number;
};
message: string;
}
export interface WolaiRepository extends Repository {
pageType: string;
spaceId: string;
}
================================================
FILE: src/common/backend/services/youdao/index.ts
================================================
import { ServiceMeta } from '@/common/backend';
import localeService from '@/common/locales';
import Service from './service';
export default (): ServiceMeta => {
return {
name: localeService.format({
id: 'backend.services.youdao.name',
defaultMessage: 'Youdao',
}),
icon: 'https://note.youdao.com/web/favicon.ico',
type: 'youdao',
homePage: 'https://note.youdao.com/web/',
service: Service,
permission: {
origins: ['https://note.youdao.com/*'],
permissions: ['cookies'],
},
};
};
================================================
FILE: src/common/backend/services/youdao/service.ts
================================================
import { CompleteStatus, UnauthorizedError } from './../interface';
import { DocumentService, Repository, CreateDocumentRequest } from '../../index';
import axios, { AxiosInstance } from 'axios';
import { stringify } from 'qs';
import { generateUuid } from '@web-clipper/shared/lib/uuid';
import localeService from '@/common/locales';
import Container from 'typedi';
import { ICookieService } from '@/service/common/cookie';
interface YouDaoRepository {
fileEntry: {
id: string;
name: string;
parentId: string;
};
}
export default class YoudaoDocumentService implements DocumentService {
private request: AxiosInstance;
constructor() {
const request = axios.create({
baseURL: 'https://note.youdao.com',
timeout: 10000,
transformResponse: [
(data): any => {
return JSON.parse(data);
},
],
withCredentials: true,
});
this.request = request;
this.request.interceptors.response.use(
r => r,
error => {
if (error.response) {
const { response } = error;
if (response.status === 500 && response.data && response.data.error === '207') {
return Promise.reject(
new UnauthorizedError(
localeService.format({
id: 'backend.services.youdao.unauthorizedErrorMessage',
defaultMessage: 'Unauthorized! Please Login Youdao Web.',
})
)
);
}
}
return Promise.reject(error);
}
);
}
getId = () => {
return 'youdao';
};
getRepositories = async () => {
const cstk = await this.getCSTK();
let formData = new FormData();
formData.append('path', '/');
formData.append('dirOnly', 'true');
formData.append('f', 'true');
formData.append('cstk', cstk);
const response = await this.request.post(
`/yws/api/personal/file?${stringify({
method: 'listEntireByParentPath',
keyfrom: 'web',
cstk,
})}`,
formData
);
return response.data.map(
({ fileEntry: { parentId, name, id } }): Repository => ({
id,
name,
groupId: parentId,
groupName: localeService.format({
id: 'backend.services.youdao.myFolders',
defaultMessage: 'My Folders',
}),
})
);
};
createDocument = async ({
repositoryId,
title,
content,
}: CreateDocumentRequest): Promise => {
const cstk = await this.getCSTK();
let formData = new FormData();
let uuid = generateUuid().replace(/-/g, '');
let fileId = `WEB${uuid}`;
const timestamp = String(Math.floor(Date.now() / 1000));
formData.append('fileId', fileId);
formData.append('parentId', repositoryId);
formData.append('name', `${title}.md`);
formData.append('domain', `1`);
formData.append('rootVersion', `-1`);
formData.append('dir', `false`);
formData.append('sessionId', '');
formData.append('createTime', timestamp);
formData.append('modifyTime', timestamp);
formData.append('transactionId', fileId);
formData.append('bodyString', content);
formData.append('transactionTime', timestamp);
formData.append('cstk', cstk);
try {
await this.request.post(
`/yws/api/personal/sync?${stringify({
method: 'push',
keyfrom: 'web',
cstk,
})}`,
formData
);
} catch (_error) {
uuid = generateUuid().replace(/-/g, '');
fileId = `WEB${uuid}`;
formData.set('fileId', fileId);
formData.set('transactionId', fileId);
formData.set('name', `${title}-${uuid}.md`);
await this.request.post(
`/yws/api/personal/sync?${stringify({
method: 'push',
keyfrom: 'web',
cstk,
})}`,
formData
);
}
return {
href: `https://note.youdao.com/web/#/file/recent/markdown/${fileId}`,
};
};
getUserInfo = async () => {
const cstk = await this.getCSTK();
const response = await this.request.get<{ name: string; photo: string }>(
`/yws/api/self?${stringify({
method: 'get',
keyfrom: 'web',
cstk,
})}`
);
const { data } = response;
return {
name: data.name,
avatar: `https://note.youdao.com${data.photo}`,
homePage: 'https://note.youdao.com/web',
};
};
private getCSTK = async () => {
const cookie = await Container.get(ICookieService).get({
url: 'https://note.youdao.com',
name: 'YNOTE_CSTK',
});
if (!cookie) {
throw new UnauthorizedError(
localeService.format({
id: 'backend.services.youdao.unauthorizedErrorMessage',
defaultMessage: 'Unauthorized! Please Login Youdao Web.',
})
);
}
return cookie.value;
};
}
================================================
FILE: src/common/backend/services/yuque/form.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input, Select } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Component, Fragment } from 'react';
import { YuqueBackendServiceConfig, RepositoryType } from './interface';
import { FormattedMessage } from 'react-intl';
interface YuqueFormProps {
verified?: boolean;
info?: YuqueBackendServiceConfig;
}
const RepositoryTypeOptions = [
{
key: RepositoryType.all,
label: (
),
},
{
key: RepositoryType.self,
label: (
),
},
{
key: RepositoryType.group,
label: (
),
},
];
export default class extends Component {
render() {
const {
form: { getFieldDecorator },
info,
verified,
} = this.props;
let initData: Partial = {
repositoryType: RepositoryType.self,
};
if (info) {
initData = info;
}
let editMode = info ? true : false;
return (
{getFieldDecorator('accessToken', {
initialValue: initData.accessToken,
rules: [
{
required: true,
message: 'AccessToken is required!',
},
],
})( )}
}
>
{getFieldDecorator('repositoryType', {
initialValue: initData.repositoryType,
rules: [{ required: true, message: 'repositoryType is required!' }],
})(
{RepositoryTypeOptions.map(o => (
{o.label}
))}
)}
);
}
}
================================================
FILE: src/common/backend/services/yuque/headerForm.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment } from 'react';
import locales from '@/common/locales';
const HeaderForm: React.FC = ({ form: { getFieldDecorator } }) => {
return (
{getFieldDecorator('slug', {
rules: [
{
pattern: /^[\w-.]{2,190}$/,
message: locales.format({
id: 'backend.services.yuque.headerForm.slug_error',
}),
},
],
})(
)}
);
};
export default HeaderForm;
================================================
FILE: src/common/backend/services/yuque/index.ts
================================================
import { ServiceMeta } from './../interface';
import Service from './service';
import Form from './form';
import localeService from '@/common/locales';
import headerForm from './headerForm';
export default (): ServiceMeta => {
return {
name: localeService.format({
id: 'backend.services.yuque.name',
defaultMessage: 'Yuque',
}),
icon: 'yuque',
type: 'yuque',
service: Service,
headerForm: headerForm,
form: Form,
homePage: 'https://www.yuque.com',
permission: {
origins: ['https://www.yuque.com/*'],
},
};
};
================================================
FILE: src/common/backend/services/yuque/interface.ts
================================================
import { CompleteStatus, CreateDocumentRequest, UpdateTOCRequest } from './../interface';
import { Repository } from '../interface';
export enum RepositoryType {
all = 'all',
self = 'self',
group = 'group',
}
export interface YuqueBackendServiceConfig {
accessToken: string;
repositoryType: RepositoryType;
}
export interface YuqueUserInfoResponse {
id: number;
avatar_url: string;
name: string;
login: string;
description: string;
}
export interface YuqueGroupResponse {
id: number;
name: string;
login: string;
}
export interface YuqueRepository extends Repository {
namespace: string;
}
export interface YuqueRepositoryResponse {
id: number;
name: string;
namespace: string;
}
export interface YuqueCreateDocumentResponse {
id: number;
slug: string;
title: string;
created_at: string;
updated_at: string;
}
export interface YuqueCompleteStatus extends CompleteStatus {
documentId: string;
repositoryId: string;
accessToken: string;
}
export interface YuqueCreateDocumentRequest extends CreateDocumentRequest {
slug?: string;
}
export interface YuqueUpdateTOCRequest extends UpdateTOCRequest{
repositoryId: string;
documentId: number[];
}
================================================
FILE: src/common/backend/services/yuque/service.ts
================================================
import { IBasicRequestService } from '@/service/common/request';
import { Container } from 'typedi';
import { RequestHelper } from '@/service/request/common/request';
import { DocumentService } from './../../index';
import { generateUuid } from '@web-clipper/shared/lib/uuid';
import * as qs from 'qs';
import md5 from '@web-clipper/shared/lib/md5';
import {
YuqueBackendServiceConfig,
YuqueUserInfoResponse,
RepositoryType,
YuqueRepositoryResponse,
YuqueGroupResponse,
YuqueCreateDocumentResponse,
YuqueRepository,
YuqueCompleteStatus,
YuqueCreateDocumentRequest,
YuqueUpdateTOCRequest,
} from './interface';
const HOST = 'https://www.yuque.com';
const BASE_URL = `${HOST}/api/v2/`;
export default class YuqueDocumentService implements DocumentService {
private request: RequestHelper;
private userInfo?: YuqueUserInfoResponse;
private config: YuqueBackendServiceConfig;
private repositories: YuqueRepository[];
constructor({ accessToken, repositoryType = RepositoryType.all }: YuqueBackendServiceConfig) {
this.config = { accessToken, repositoryType };
this.request = new RequestHelper({
baseURL: BASE_URL,
headers: {
'X-Auth-Token': accessToken,
},
request: Container.get(IBasicRequestService),
interceptors: {
response: e => (e as any).data,
},
});
this.repositories = [];
}
getId = () => md5(this.config.accessToken);
getUserInfo = async () => {
if (!this.userInfo) {
this.userInfo = await this.getYuqueUserInfo();
}
const { avatar_url: avatar, name, login, description } = this.userInfo;
const homePage = `${HOST}/${login}`;
return {
avatar,
name,
homePage,
description,
login,
};
};
getRepositories = async () => {
let response: YuqueRepository[] = [];
if (this.config.repositoryType !== RepositoryType.group) {
if (!this.userInfo) {
this.userInfo = await this.getYuqueUserInfo();
}
const repos = await this.getAllRepositories(false, this.userInfo.id, this.userInfo.name);
response = response.concat(repos);
}
if (this.config.repositoryType !== RepositoryType.self) {
const groups = await this.getUserGroups();
for (const group of groups) {
const repos = await this.getAllRepositories(true, group.id, group.name);
response = response.concat(repos);
}
}
this.repositories = response;
return response.map(({ namespace, ...rest }) => ({ ...rest }));
};
createDocument = async (info: YuqueCreateDocumentRequest): Promise => {
if (!this.userInfo) {
this.userInfo = await this.getYuqueUserInfo();
}
const { content: body, title, repositoryId } = info;
const repository = this.repositories.find(o => o.id === repositoryId);
if (!repository) {
throw new Error('illegal repositoryId');
}
const request = {
title,
slug: info.slug || generateUuid(),
body,
private: true,
};
const response = await this.request.post(
`repos/${repositoryId}/docs`,
{
data: request,
}
);
const data = response;
await this.updateYuqueTOC({ repositoryId, documentId: [data.id] });
return {
href: `${HOST}/${repository.namespace}/${data.slug}`,
repositoryId,
documentId: data.id.toString(),
accessToken: this.config.accessToken,
};
};
private getUserGroups = async () => {
if (!this.userInfo) {
this.userInfo = await this.getYuqueUserInfo();
}
return this.request.get(`users/${this.userInfo.login}/groups`);
};
private getYuqueUserInfo = async () => {
return this.request.get('user');
};
private getAllRepositories = async (isGroup: boolean, groupId: number, groupName: string) => {
let offset = 0;
let result = await this.getYuqueRepositories(offset, isGroup, String(groupId));
while (result.length - offset === 20) {
offset = offset + 20;
result = result.concat(await this.getYuqueRepositories(offset, isGroup, String(groupId)));
}
return result.map(
({ id, name, namespace }): YuqueRepository => ({
id: String(id),
name,
groupId: String(groupId),
groupName: groupName,
namespace,
})
);
};
private getYuqueRepositories = async (offset: number, isGroup: boolean, slug: string) => {
const query = {
offset: offset,
};
try {
const response = await this.request.get(
`${isGroup ? 'groups' : 'users'}/${slug}/repos?${qs.stringify(query)}`
);
return response;
} catch (_error) {
return [];
}
};
private updateYuqueTOC = async (info: YuqueUpdateTOCRequest) => {
const { repositoryId, documentId } = info;
const requestBody = {
action: 'prependNode',
action_mode: 'child',
doc_ids: documentId,
type: 'DOC',
};
try {
const response = await this.request.put(`repos/${repositoryId}/toc`, {
data: requestBody,
});
return response;
} catch (_error) {
return {};
}
};
}
================================================
FILE: src/common/backend/services/yuque_oauth/form.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input, Select } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Component, Fragment } from 'react';
import { YuqueBackendServiceConfig, RepositoryType } from './interface';
import { FormattedMessage } from 'react-intl';
interface YuqueFormProps {
verified?: boolean;
info?: YuqueBackendServiceConfig;
}
const RepositoryTypeOptions = [
{
key: RepositoryType.all,
label: (
),
},
{
key: RepositoryType.self,
label: (
),
},
{
key: RepositoryType.group,
label: (
),
},
];
export default class extends Component {
render() {
const {
form: { getFieldDecorator },
info,
verified,
} = this.props;
let initData: Partial = {
repositoryType: RepositoryType.self,
};
if (info) {
initData = info;
}
let editMode = info ? true : false;
return (
{getFieldDecorator('access_token', {
initialValue: initData.access_token,
rules: [
{
required: true,
message: 'AccessToken is required!',
},
],
})( )}
}
>
{getFieldDecorator('repositoryType', {
initialValue: initData.repositoryType || RepositoryType.all,
rules: [{ required: true, message: 'repositoryType is required!' }],
})(
{RepositoryTypeOptions.map(o => (
{o.label}
))}
)}
);
}
}
================================================
FILE: src/common/backend/services/yuque_oauth/headerForm.tsx
================================================
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.less';
import { Input } from 'antd';
import { FormComponentProps } from '@ant-design/compatible/lib/form';
import React, { Fragment } from 'react';
import locales from '@/common/locales';
const HeaderForm: React.FC = ({ form: { getFieldDecorator } }) => {
return (
{getFieldDecorator('slug', {
rules: [
{
pattern: /^[\w-.]{2,190}$/,
message: locales.format({
id: 'backend.services.yuque.headerForm.slug_error',
}),
},
],
})(
)}
);
};
export default HeaderForm;
================================================
FILE: src/common/backend/services/yuque_oauth/index.ts
================================================
import config from '@/config';
import { ServiceMeta } from './../interface';
import Service from './service';
import localeService from '@/common/locales';
import { stringify } from 'qs';
import form from './form';
import headerForm from './headerForm';
import { IConfigService } from '@/service/common/config';
import { Container } from 'typedi';
const oauthUrl = `https://www.yuque.com/oauth2/authorize?${stringify({
client_id: config.yuqueClientId,
scope: config.yuqueScope,
redirect_uri: config.yuqueCallback,
state: Container.get(IConfigService).id,
response_type: 'code',
})}`;
export default (): ServiceMeta => {
return {
name: localeService.format({
id: 'backend.services.yuque_oauth.name',
}),
icon: 'yuque',
type: 'yuque_oauth',
headerForm: headerForm,
service: Service,
oauthUrl,
form: form,
homePage: 'https://www.yuque.com',
permission: {
origins: ['https://www.yuque.com/*'],
},
};
};
================================================
FILE: src/common/backend/services/yuque_oauth/interface.ts
================================================
import { CompleteStatus, CreateDocumentRequest } from './../interface';
import { Repository } from '../interface';
export enum RepositoryType {
all = 'all',
self = 'self',
group = 'group',
}
export interface YuqueBackendServiceConfig {
access_token: string;
repositoryType: RepositoryType;
}
export interface YuqueUserInfoResponse {
id: number;
avatar_url: string;
name: string;
login: string;
description: string;
}
export interface YuqueGroupResponse {
id: number;
name: string;
login: string;
}
export interface YuqueRepository extends Repository {
namespace: string;
}
export interface YuqueRepositoryResponse {
id: number;
name: string;
namespace: string;
}
export interface YuqueCreateDocumentResponse {
id: number;
slug: string;
title: string;
created_at: string;
updated_at: string;
}
export interface YuqueCompleteStatus extends CompleteStatus {
documentId: string;
repositoryId: string;
}
export interface YuqueCreateDocumentRequest extends CreateDocumentRequest {
slug?: string;
}
================================================
FILE: src/common/backend/services/yuque_oauth/service.ts
================================================
import { RequestHelper } from './../../../../service/request/common/request';
import { IBasicRequestService } from './../../../../service/common/request';
import { Container } from 'typedi';
import { DocumentService } from './../../index';
import { generateUuid } from '@web-clipper/shared/lib/uuid';
import * as qs from 'qs';
import md5 from '@web-clipper/shared/lib/md5';
import {
YuqueBackendServiceConfig,
YuqueUserInfoResponse,
RepositoryType,
YuqueRepositoryResponse,
YuqueGroupResponse,
YuqueCreateDocumentResponse,
YuqueRepository,
YuqueCompleteStatus,
YuqueCreateDocumentRequest,
} from './interface';
const HOST = 'https://www.yuque.com';
const BASE_URL = `${HOST}/api/v2/`;
export default class YuqueDocumentService implements DocumentService {
private request: RequestHelper;
private userInfo?: YuqueUserInfoResponse;
private config: YuqueBackendServiceConfig;
private repositories: YuqueRepository[];
constructor({ access_token, repositoryType = RepositoryType.all }: YuqueBackendServiceConfig) {
this.config = { access_token, repositoryType };
this.request = new RequestHelper({
baseURL: BASE_URL,
headers: {
'X-Auth-Token': access_token,
},
request: Container.get(IBasicRequestService),
interceptors: {
response: e => (e as any).data,
},
});
this.repositories = [];
}
getId = () => md5(this.config.access_token);
getUserInfo = async () => {
if (!this.userInfo) {
this.userInfo = await this.getYuqueUserInfo();
}
const { avatar_url: avatar, name, login, description } = this.userInfo;
const homePage = `${HOST}/${login}`;
return {
avatar,
name,
homePage,
description,
login,
};
};
getRepositories = async () => {
let response: YuqueRepository[] = [];
if (this.config.repositoryType !== RepositoryType.group) {
if (!this.userInfo) {
this.userInfo = await this.getYuqueUserInfo();
}
const repos = await this.getAllRepositories(false, this.userInfo.id, this.userInfo.name);
response = response.concat(repos);
}
if (this.config.repositoryType !== RepositoryType.self) {
const groups = await this.getUserGroups();
for (const group of groups) {
const repos = await this.getAllRepositories(true, group.id, group.name);
response = response.concat(repos);
}
}
this.repositories = response;
return response.map(({ namespace, ...rest }) => ({ ...rest }));
};
createDocument = async (info: YuqueCreateDocumentRequest): Promise => {
if (!this.userInfo) {
this.userInfo = await this.getYuqueUserInfo();
}
const { content: body, title, repositoryId } = info;
const repository = this.repositories.find(o => o.id === repositoryId);
if (!repository) {
throw new Error('illegal repositoryId');
}
const request = {
title,
slug: info.slug || generateUuid(),
body,
private: true,
};
const response = await this.request.post(
`repos/${repositoryId}/docs`,
{
data: request,
}
);
const data = response;
return {
href: `${HOST}/${repository.namespace}/${data.slug}`,
repositoryId,
documentId: data.id.toString(),
};
};
private getUserGroups = async () => {
if (!this.userInfo) {
this.userInfo = await this.getYuqueUserInfo();
}
return this.request.get(`users/${this.userInfo.login}/groups`);
};
private getYuqueUserInfo = async () => {
const response = await this.request.get('user');
return response;
};
private getAllRepositories = async (isGroup: boolean, groupId: number, groupName: string) => {
let offset = 0;
let result = await this.getYuqueRepositories(offset, isGroup, String(groupId));
while (result.length - offset === 20) {
offset = offset + 20;
result = result.concat(await this.getYuqueRepositories(offset, isGroup, String(groupId)));
}
return result.map(
({ id, name, namespace }): YuqueRepository => ({
id: String(id),
name,
groupId: String(groupId),
groupName: groupName,
namespace,
})
);
};
private getYuqueRepositories = async (offset: number, isGroup: boolean, slug: string) => {
const query = {
offset: offset,
};
try {
const response = await this.request.get