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

CI Test Status Release resource status Codecov

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: , }, ], })( { 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, }, ], })( )} )} ); }; 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: [], })( )} ); }; 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, })( )} {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')( )} ); }; 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, })( )} {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: [], })( )} ); }; 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, })( )} ); }; 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: [], })( )} ); }; 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: [], })( )} ); }; 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!' }], })( )} ); } } ================================================ 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!' }], })( )} ); } } ================================================ 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( `${isGroup ? 'groups' : 'users'}/${slug}/repos?${qs.stringify(query)}` ); return response; } catch (error) { console.log(error); return []; } }; } ================================================ FILE: src/common/blob.ts ================================================ const Base64ImageToBlob = (image: string): Blob => { const arr = image.split(','); const mime = arr[0].match(/:(.*?);/)![1] || 'image/png'; const bytes = window.atob(arr[1]); let ab = new ArrayBuffer(bytes.length); let ia = new Uint8Array(ab); for (let i = 0; i < bytes.length; i++) { ia[i] = bytes.charCodeAt(i); } const blob = new Blob([ab], { type: mime, }); return blob; }; const BlobToBase64 = (blob: Blob): Promise => { const reader = new FileReader(); reader.readAsDataURL(blob); return new Promise(resolve => { reader.onloadend = () => { resolve(reader.result as string); }; }); }; function loadImage(date: string): Promise { return new Promise((resolve, reject) => { let img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = date; }); } export { Base64ImageToBlob, loadImage, BlobToBase64 }; ================================================ FILE: src/common/buffer.ts ================================================ /* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable no-dupe-class-members */ /* eslint-disable no-param-reassign */ /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as strings from './strings'; declare const Buffer: any; const hasBuffer = typeof Buffer !== 'undefined'; const hasTextEncoder = typeof TextEncoder !== 'undefined'; const hasTextDecoder = typeof TextDecoder !== 'undefined'; let textEncoder: TextEncoder | null; let textDecoder: TextDecoder | null; export class VSBuffer { static alloc(byteLength: number): VSBuffer { if (hasBuffer) { return new VSBuffer(Buffer.allocUnsafe(byteLength)); } return new VSBuffer(new Uint8Array(byteLength)); } static wrap(actual: Uint8Array): VSBuffer { if (hasBuffer && !Buffer.isBuffer(actual)) { // https://nodejs.org/dist/latest-v10.x/docs/api/buffer.html#buffer_class_method_buffer_from_arraybuffer_byteoffset_length // Create a zero-copy Buffer wrapper around the ArrayBuffer pointed to by the Uint8Array actual = Buffer.from(actual.buffer, actual.byteOffset, actual.byteLength); } return new VSBuffer(actual); } static fromString(source: string, options?: { dontUseNodeBuffer?: boolean }): VSBuffer { const dontUseNodeBuffer = options?.dontUseNodeBuffer || false; if (!dontUseNodeBuffer && hasBuffer) { return new VSBuffer(Buffer.from(source)); } if (hasTextEncoder) { if (!textEncoder) { textEncoder = new TextEncoder(); } return new VSBuffer(textEncoder.encode(source)); } return new VSBuffer(strings.encodeUTF8(source)); } static concat(buffers: VSBuffer[], totalLength?: number): VSBuffer { if (typeof totalLength === 'undefined') { totalLength = 0; for (let i = 0, len = buffers.length; i < len; i++) { totalLength += buffers[i].byteLength; } } const ret = VSBuffer.alloc(totalLength); let offset = 0; for (let i = 0, len = buffers.length; i < len; i++) { const element = buffers[i]; ret.set(element, offset); offset += element.byteLength; } return ret; } readonly buffer: Uint8Array; readonly byteLength: number; private constructor(buffer: Uint8Array) { this.buffer = buffer; this.byteLength = this.buffer.byteLength; } toString(): string { if (hasBuffer) { return this.buffer.toString(); } if (hasTextDecoder) { if (!textDecoder) { textDecoder = new TextDecoder(); } return textDecoder.decode(this.buffer); } return strings.decodeUTF8(this.buffer); } slice(start?: number, end?: number): VSBuffer { // IMPORTANT: use subarray instead of slice because TypedArray#slice // creates shallow copy and NodeBuffer#slice doesn't. The use of subarray // ensures the same, performant, behaviour. return new VSBuffer(this.buffer.subarray(start! /*bad lib.d.ts*/, end)); } set(array: VSBuffer, offset?: number): void; set(array: Uint8Array, offset?: number): void; set(array: VSBuffer | Uint8Array, offset?: number): void { if (array instanceof VSBuffer) { this.buffer.set(array.buffer, offset); } else { this.buffer.set(array, offset); } } readUInt32BE(offset: number): number { return readUInt32BE(this.buffer, offset); } writeUInt32BE(value: number, offset: number): void { writeUInt32BE(this.buffer, value, offset); } readUInt32LE(offset: number): number { return readUInt32LE(this.buffer, offset); } writeUInt32LE(value: number, offset: number): void { writeUInt32LE(this.buffer, value, offset); } readUInt8(offset: number): number { return readUInt8(this.buffer, offset); } writeUInt8(value: number, offset: number): void { writeUInt8(this.buffer, value, offset); } } export function readUInt16LE(source: Uint8Array, offset: number): number { return ((source[offset + 0] << 0) >>> 0) | ((source[offset + 1] << 8) >>> 0); } export function writeUInt16LE(destination: Uint8Array, value: number, offset: number): void { destination[offset + 0] = value & 0b11111111; value = value >>> 8; destination[offset + 1] = value & 0b11111111; } export function readUInt32BE(source: Uint8Array, offset: number): number { return ( source[offset] * 2 ** 24 + source[offset + 1] * 2 ** 16 + source[offset + 2] * 2 ** 8 + source[offset + 3] ); } export function writeUInt32BE(destination: Uint8Array, value: number, offset: number): void { destination[offset + 3] = value; value = value >>> 8; destination[offset + 2] = value; value = value >>> 8; destination[offset + 1] = value; value = value >>> 8; destination[offset] = value; } export function readUInt32LE(source: Uint8Array, offset: number): number { return ( ((source[offset + 0] << 0) >>> 0) | ((source[offset + 1] << 8) >>> 0) | ((source[offset + 2] << 16) >>> 0) | ((source[offset + 3] << 24) >>> 0) ); } export function writeUInt32LE(destination: Uint8Array, value: number, offset: number): void { destination[offset + 0] = value & 0b11111111; value = value >>> 8; destination[offset + 1] = value & 0b11111111; value = value >>> 8; destination[offset + 2] = value & 0b11111111; value = value >>> 8; destination[offset + 3] = value & 0b11111111; } export function readUInt8(source: Uint8Array, offset: number): number { return source[offset]; } export function writeUInt8(destination: Uint8Array, value: number, offset: number): void { destination[offset] = value; } ================================================ FILE: src/common/chrome/storage.ts ================================================ import { AbstractStorageService } from '@web-clipper/shared/lib/storage'; import * as browser from '@web-clipper/chrome-promise'; class LocalStorageService extends AbstractStorageService { constructor() { super(browser.storage.local, browser.storage.onChanged, 'local'); } } class SyncStorageService extends AbstractStorageService { constructor() { super(browser.storage.sync, browser.storage.onChanged, 'sync'); } } const localStorageService = new LocalStorageService(); const syncStorageService = new SyncStorageService(); export { localStorageService, syncStorageService }; ================================================ FILE: src/common/error.ts ================================================ export interface SerializedError { readonly $isError: true; readonly name: string; readonly message: string; readonly stack: string; } export function transformErrorForSerialization(error: Error): SerializedError; export function transformErrorForSerialization(error: any): any; export function transformErrorForSerialization(error: any): any { if (error instanceof Error) { let { name, message } = error; const stack: string = (error).stacktrace || (error).stack; return { $isError: true, name, message, stack, }; } // return as is return error; } ================================================ FILE: src/common/getResource.ts ================================================ export function getResourcePath(name: string) { let isFirefox = chrome.runtime.getURL(name).startsWith('moz-extension'); if (isFirefox) { return `chrome/${name}`; } return name; } ================================================ FILE: src/common/hooks/useFilterExtensions.ts ================================================ import { useMemo } from 'react'; import { ExtensionType, SerializedExtensionInfo } from '@/extensions/common'; const useFilterExtensions = (extensions: T[]) => { return useMemo(() => { const toolExtensions: T[] = []; const clipExtensions: T[] = []; extensions.forEach(o => { if (o.type === ExtensionType.Tool) { toolExtensions.push(o); return; } clipExtensions.push(o); }); return [toolExtensions, clipExtensions]; }, [extensions]); }; export default useFilterExtensions; ================================================ FILE: src/common/hooks/useFilterImageHostingServices.ts ================================================ import { ImageHostingServiceMeta } from '../backend'; import { ImageHosting } from '../modelTypes/userPreference'; interface Props { backendServiceType: string; imageHostingServices: ImageHosting[]; imageHostingServicesMap: { [type: string]: ImageHostingServiceMeta; }; } export type ImageHostingWithMeta = { imageHostingServices: ImageHosting; meta: ImageHostingServiceMeta; }; const useFilterImageHostingServices = ({ backendServiceType, imageHostingServices, imageHostingServicesMap, }: Props) => { return imageHostingServices .map(o => { const meta = imageHostingServicesMap[o.type]; if (!meta) { return null; } if (meta.builtIn && meta.type !== backendServiceType) { return null; } if (meta.support && !meta.support(backendServiceType)) { return null; } return { imageHostingServices: o, meta }; }) .filter((o): o is ImageHostingWithMeta => !!o); }; export default useFilterImageHostingServices; ================================================ FILE: src/common/hooks/useOriginPermission.ts ================================================ import { IPermissionsService } from '@/service/common/permissions'; import { useState } from 'react'; import Container from 'typedi'; const useOriginPermission = (initData: boolean) => { const [verified, setVerified] = useState(initData); const permissionsService = Container.get(IPermissionsService); const requestOriginPermission = async (origin: string) => { const result = await permissionsService.request({ origins: [`${origin}/*`], }); setVerified(result); }; return [verified, requestOriginPermission] as const; }; export default useOriginPermission; ================================================ FILE: src/common/hooks/useVerifiedAccount.tsx ================================================ import { UserPreferenceStore } from '@/common/types'; import { FormComponentProps } from '@ant-design/compatible/lib/form'; import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { omit, isEqual } from 'lodash'; import { FormattedMessage } from 'react-intl'; import { message } from 'antd'; import { useFetch } from '@shihengtech/hooks'; type UseVerifiedAccountProps = FormComponentProps & { services: UserPreferenceStore['servicesMeta']; initAccount?: any; }; function useDeepCompareMemoize(value: T) { const ref = React.useRef(); if (!isEqual(value, ref.current)) { ref.current = value; } return ref.current; } const useVerifiedAccount = ({ form, services, initAccount }: UseVerifiedAccountProps) => { const [type, _setType] = useState( initAccount ? initAccount.type : Object.values(services)[0].type ); const service = services[type]; const changeType = (type: string) => { _setType(type); const values = form.getFieldsValue(); form.resetFields(Object.keys(omit(values, ['type']))); }; const { data, run, loading } = useFetch( async (info: any) => { const Service = service.service; const instance = new Service(info); const userInfo = await instance.getUserInfo(); const repositories = await instance.getRepositories(); const id = await instance.getId(); return { userInfo, repositories, id }; }, [service], { auto: false, onError: e => { message.error(e.message); }, } ); let loadAccount = useCallback(() => { form.validateFields((error, values) => { if (error) { return; } const { type, defaultRepositoryId, imageHosting, ...info } = values; run(info); }); }, [form, run]); const accountStatus = { repositories: data?.repositories ?? [], userInfo: data?.userInfo ?? null, verified: !!data && !loading, id: data?.id ?? null, }; let serviceForm = useMemo(() => { if (!service.form) { return null; } return ( ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [accountStatus.verified, form, initAccount, loadAccount, service.form]); const okText = useMemo(() => { if (loading) { return ; } return accountStatus.verified ? ( ) : ( ); }, [accountStatus.verified, loading]); let oauthLink = useMemo(() => { return service.oauthUrl ? ( ) : null; }, [service.oauthUrl]); const _formInfo = useMemo(() => { const values = form.getFieldsValue(); const { defaultRepositoryId, type: curT, imageHosting, ...info } = values; if (type !== curT) { return null; } return info; }, [form, type]); const formInfo = useDeepCompareMemoize(_formInfo); const verifiedRef = useRef(accountStatus.verified); verifiedRef.current = accountStatus.verified; useEffect(() => { if (!verifiedRef.current || !formInfo) { return; } run(formInfo); }, [verifiedRef, formInfo, run, form]); return { type, service, accountStatus: accountStatus, verifying: loading, verifyAccount: run, loadAccount, changeType, serviceForm, okText, oauthLink, }; }; export default useVerifiedAccount; ================================================ FILE: src/common/loading.test.ts ================================================ /* eslint-disable no-loop-func */ import { loading, loadingStatus } from './loading'; import { autorun, action, observable } from 'mobx'; import * as vitest from 'vitest'; function flushPromises() { return new Promise(resolve => setImmediate(resolve)); } vitest.vi.useFakeTimers(); class Test { @observable public actionAfterLoadingCount = 0; @observable public actionBeforeLoadingCount = 0; @loading exec = (time: number) => { return new Promise(r => setTimeout(r, time)); }; @action @loading actionAfterLoading() { this.actionAfterLoadingCount++; this.actionAfterLoadingCount++; } @loading @action actionBeforeLoading() { this.actionBeforeLoadingCount++; this.actionBeforeLoadingCount++; } } describe.skip('test loading decorator', () => { beforeEach(() => { vitest.vi.useFakeTimers(); }); it('test race condition', async () => { const instance = new Test(); instance.exec(3000); instance.exec(9000); await vitest.vi.advanceTimersByTime(5000); expect(loadingStatus(instance).exec).toBe(true); await vitest.vi.advanceTimersByTime(5000); await flushPromises(); expect(loadingStatus(instance).exec).toBe(false); }); it('test auto run', async () => { const instance = new Test(); const log = vitest.vi.fn(); instance.exec(3000); autorun(() => { log(loadingStatus(instance).exec); }); await vitest.vi.advanceTimersByTime(1000); expect(log).toBeCalledTimes(1); expect(log).toHaveBeenLastCalledWith(true); await vitest.vi.advanceTimersByTime(3000); await flushPromises(); expect(log).toBeCalledTimes(2); expect(log).toHaveBeenLastCalledWith(false); }); describe('should work correct with action', () => { it('actionBeforeLoading ', async () => { const instance = new Test(); const result: string[] = []; const log = (daa: string) => { result.push(daa); }; autorun(() => { const loading = loadingStatus(instance).actionBeforeLoading; const count = instance.actionBeforeLoadingCount; log(`${loading}-${count}`); }); instance.actionBeforeLoading(); expect(loadingStatus(instance).actionBeforeLoading).toBe(true); await vitest.vi.advanceTimersByTime(1000); expect(loadingStatus(instance).actionBeforeLoading).toBe(false); expect(result).toEqual(['undefined-0', 'true-0', 'true-2', 'false-2']); }); it('actionAfterLoading', async () => { const instance = new Test(); const result: string[] = []; const log = (daa: string) => { result.push(daa); }; autorun(() => { const loading = loadingStatus(instance).actionAfterLoading; const count = instance.actionAfterLoadingCount; log(`${loading}-${count}`); }); instance.actionAfterLoading(); expect(loadingStatus(instance).actionAfterLoading).toBe(true); await vitest.vi.advanceTimersByTime(1000); expect(loadingStatus(instance).actionAfterLoading).toBe(false); expect(result).toEqual(['undefined-0', 'true-2', 'false-2']); }); }); }); ================================================ FILE: src/common/loading.ts ================================================ import { observable } from 'mobx'; import { generateUuid } from '@web-clipper/shared/lib/uuid'; type FunctionKeys = { [K in keyof T]: T[K] extends Function ? K : never; }[keyof T]; const loadingMap = observable.map(); const cache = new Map(); export function loadingStatus( instance: T ): { [P in FunctionKeys]: boolean; } { if (!cache.has(instance)) { const result = {}; Object.getOwnPropertyNames(Object.getPrototypeOf(instance)).forEach(key => { Object.defineProperty(result, key, { get: () => { const uuid = Reflect.getMetadata(`loading:${key}`, instance); if (!uuid) { throw new Error(); } return loadingMap.get(uuid); }, }); }); cache.set(instance, result); } return cache.get(instance); } function LoadingHoc(uuidKey: string, fn: Function) { let execCount = 0; return async function() { const execCountCache = execCount + 1; execCount = execCountCache; try { loadingMap.set(uuidKey, true); //@ts-ignore return await fn.apply(this, arguments); } catch (err) { throw err; } finally { if (execCountCache === execCount) { loadingMap.set(uuidKey, false); } } }; } export function loading(target: any, key: string, descriptor?: any) { const uuidKey = generateUuid(); Reflect.defineMetadata(`loading:${key}`, uuidKey, target); if (descriptor) { descriptor.value = LoadingHoc(uuidKey, descriptor.value); } else { Object.defineProperty(target, key, { enumerable: false, configurable: true, set(v: any) { Object.defineProperty(this, key, { enumerable: false, writable: true, configurable: true, value: LoadingHoc(uuidKey, v), }); }, get() { // eslint-disable-next-line no-undefined return undefined; }, }); } } ================================================ FILE: src/common/locales/antd.ts ================================================ import en_US from 'antd/lib/locale-provider/en_US'; import ja_JP from 'antd/lib/locale-provider/ja_JP'; import ru_RU from 'antd/lib/locale-provider/ru_RU'; import zh_CN from 'antd/lib/locale-provider/zh_CN'; import zh_TW from 'antd/lib/locale-provider/zh_TW'; const localeProvider = { 'en-US': en_US, 'ja-JP': ja_JP, 'ru-RU': ru_RU, 'zh-CN': zh_CN, 'zh-TW': zh_TW, }; export { localeProvider }; ================================================ FILE: src/common/locales/data/de-DE.json ================================================ { "auth.modal.title": "Konto konfigurieren", "backend.error.store": "Die Erweiterungsgalerie kann nicht skriptgesteuert werden.", "backend.imageHosting.baklib.builtInRemark": "Baklib integrierter Bildhosting-Dienst.", "backend.imageHosting.baklib.name": "Baklib", "backend.imageHosting.github.form.accessToken": "Zugriffstoken", "backend.imageHosting.github.form.accessToken.errorMessage": "Zugriffstoken ist erforderlich.", "backend.imageHosting.github.form.generateNewToken": "Neues Token generieren", "backend.imageHosting.github.form.repo": "Repository", "backend.imageHosting.github.form.repo.errorMessage": "Bitte wählen Sie ein Repository aus.", "backend.imageHosting.github.form.savePath": "", "backend.imageHosting.github.repo.errorMessage": "Bitte konfigurieren Sie das GitHub-Bildhosting erneut.", "backend.imageHosting.joplin.builtInRemark": "Joplin integrierter Bildhosting-Dienst.", "backend.imageHosting.joplin.name": "Joplin", "backend.imageHosting.leanote.builtInRemark": "Leanote integrierter Bildhosting-Dienst.", "backend.imageHosting.leanote.name": "Leanote", "backend.imageHosting.siyuan.builtInRemark": "Siyuan Note integrierter Bildhosting-Dienst.", "backend.imageHosting.siyuan.name": "SiYuan", "backend.imageHosting.yuque_oauth.builtInRemark": "Yuque integrierter Bildhosting-Dienst.", "backend.imageHosting.yuque_oauth.error_401": "Keine Berechtigung, das aktuelle Konto löschen und erneut autorisieren.", "backend.imageHosting.yuque_oauth.error_403": "Keine Berechtigung, das aktuelle Konto löschen und erneut autorisieren.", "backend.imageHosting.yuque_oauth.error_429": "Zu viele Anfragen. Limit: 100 pro Stunde.", "backend.imageHosting.yuque_oauth.name": "Yuque Oauth", "backend.imageHosting.yuque.name": "Yuque", "backend.not.unavailable": "Das Ausschneiden dieser Art von Seite ist vorübergehend nicht verfügbar.\n\nDas Aktualisieren der Seite kann das Problem lösen.", "backend.services.baklib.form.authentication": "Authentifizierung", "backend.services.baklib.headerForm.channel": "", "backend.services.baklib.headerForm.description": "", "backend.services.baklib.headerForm.tags": "", "backend.services.baklib.name": "Baklib", "backend.services.bear.form.confirm": "", "backend.services.confluence.form.authentication": "", "backend.services.confluence.form.origin": "", "backend.services.confluence.form.space": "", "backend.services.dida365.headerForm.applyTags": "", "backend.services.dida365.name": "Dida365", "backend.services.dida365.rootGroup": "", "backend.services.dida365.unauthorizedErrorMessage": "", "backend.services.flomo.login": "Nicht autorisiert! Bitte Flomo Web anmelden.", "backend.services.github.form.GenerateNewToken": "", "backend.services.github.form.storageLocation": "", "backend.services.github.form.storageLocation.code": "", "backend.services.github.form.storageLocation.code.savePath": "", "backend.services.github.form.storageLocation.code.savePathPlaceHolder": "", "backend.services.github.form.storageLocation.issue": "", "backend.services.github.form.visibility": "Sichtbarkeit", "backend.services.github.form.visibility.all": "Alle", "backend.services.github.form.visibility.private": "Privat", "backend.services.github.form.visibility.public": "Öffentlich", "backend.services.github.headerForm.applyLabels": "", "backend.services.joplin.filter_tags": "Filter-Tags", "backend.services.joplin.filter_unused_tags": "Unbenutzte Tags filtern", "backend.services.joplin.headerForm.tags": "Tags", "backend.services.joplin.name": "Joplin", "backend.services.kindle.form.alert": "", "backend.services.kindle.name": "An Kindle senden", "backend.services.leanote.form.email": "E-Mail", "backend.services.leanote.name": "Leanote", "backend.services.mail.form.address.is.required": "", "backend.services.mail.form.buy.powerpack": "", "backend.services.mail.form.homepage": "", "backend.services.mail.form.homepage.is.required": "", "backend.services.mail.form.homepage.of.mail": "", "backend.services.mail.form.powerpack": "", "backend.services.mail.form.powerpack.is.expired": "", "backend.services.mail.form.powerpack.is.required": "", "backend.services.mail.form.send.html": "", "backend.services.mail.form.send.html.or.markdown": "", "backend.services.mail.form.send.to": "", "backend.services.mail.name": "Mail", "backend.services.notion.unauthorizedErrorMessage": "", "backend.services.onenote_oauth.name": "", "backend.services.server_chan.accessToken.message": "", "backend.services.server_chan.name": "ServerChan", "backend.services.siyuan.form.accessToken": "Token", "backend.services.siyuan.name": "SiYuan Note", "backend.services.siyuan.notes": "Notizen", "backend.services.ticktick.headerForm.applyTags": "", "backend.services.ticktick.name": "", "backend.services.ticktick.rootGroup": "", "backend.services.ticktick.unauthorizedErrorMessage": "", "backend.services.wiznote.form.authentication": "", "backend.services.wiznote.form.origin": "", "backend.services.wiznote.name": "WizNote", "backend.services.wolai.unauthorizedErrorMessage": "", "backend.services.youdao.name": "Youdao", "backend.services.youdao.unauthorizedErrorMessage": "", "backend.services.yuque_oauth.name": "Yuque Oauth", "backend.services.yuque.form.repositoryType": "", "backend.services.yuque.form.showAllRepository": "", "backend.services.yuque.form.showGroupRepository": "", "backend.services.yuque.form.showSelfRepository": "", "backend.services.yuque.headerForm.slug": "Slug", "backend.services.yuque.headerForm.slug_error": "Der Slug darf nicht leer sein. Es sind nur Buchstaben, Zahlen, Bindestriche, Unterstriche und Punkte erlaubt. Mindestens drei Zeichen.", "backend.services.yuque.name": "Yuque", "background.not_support_message": "", "component.accountItem.delete": "Löschen", "component.accountItem.edit": "Bearbeiten", "component.imagehostingListItem.delete": "Löschen", "component.imagehostingListItem.edit": "Bearbeiten", "component.imagehostingListItem.noDescription": "Keine Beschreibung", "component.imageHostingSelectOption.noDescription": "", "component.powerpackForm.expired": "", "component.powerpackForm.required": "", "contextMenus.selection.save.description": "Auswahl speichern", "contextMenus.selection.save.template": "Speichern von: [{TITLE}]({URL}) \n\n## Inhalt\n{CONTENT}\n## Notiz", "contextMenus.selection.save.title": "Auswahl speichern", "extension.link.config.autoRunExclude": "AutoRun ausschließen", "extension.link.config.template": "Vorlage", "hooks.useOriginForm.origin.message": "", "page.complete.close": "", "page.complete.error": "Ein Fehler ist aufgetreten", "page.complete.message": "Zu {name} gehen", "page.complete.share": "", "page.complete.success": "Erfolg", "preference.account.add": "", "preference.accountList.add": "", "preference.accountList.addAccount": "", "preference.accountList.confirm": "", "preference.accountList.defaultRepository": "", "preference.accountList.editAccount": "", "preference.accountList.imageHost": "", "preference.accountList.login": "", "preference.accountList.type": "", "preference.accountList.verify": "", "preference.accountList.verifying": "", "preference.basic.configLanguage.description": "Meine Muttersprache ist Chinesisch. Willkommen, eine Übersetzung auf {GitHub} einzureichen.", "preference.basic.configLanguage.title": "", "preference.basic.iconColor.auto": "Automatisch", "preference.basic.iconColor.dark": "Dunkel", "preference.basic.iconColor.description": "Icon-Farbe", "preference.basic.iconColor.light": "Hell", "preference.basic.iconColor.title": "Icon-Farbe", "preference.basic.liveRendering.description": "", "preference.basic.liveRendering.title": "", "preference.basic.update.button": "", "preference.basic.update.description": "", "preference.basic.update.title": "", "preference.bind.message": "", "preference.extensions.automaticOperationIsProhibited": "", "preference.extensions.CancelSetting": "", "preference.extensions.clipExtensions": "", "preference.extensions.clipExtensions.tooltip": "Klicken Sie auf den Stern 🌟, um die Standarderweiterung auszuwählen.", "preference.extensions.ConfiguredAsDefaultExtension": "", "preference.extensions.contextMenus": "Kontextmenüs", "preference.extensions.form.reset": "Zurücksetzen", "preference.extensions.install": "", "preference.extensions.no.Description": "Keine Beschreibung", "preference.extensions.require.powerpack": "", "preference.extensions.require.update": "", "preference.extensions.runAutomaticOnSaving": "Automatische Ausführung beim Speichern", "preference.extensions.toolExtensions": "", "preference.extensions.Uninstall": "Deinstallieren", "preference.extensions.update": "Update", "preference.imageHosting.add": "", "preference.imageHosting.edit": "", "preference.imageHosting.remark": "", "preference.imageHosting.type": "", "preference.powerpack.activate": "", "preference.powerpack.expiry": "Ablaufdatum", "preference.powerpack.failed": "Fehler beim Laden von Powerpack-Informationen.", "preference.powerpack.feature.coffee": "", "preference.powerpack.feature.coffee.description": "", "preference.powerpack.feature.ocr": "", "preference.powerpack.feature.ocr.description": "", "preference.powerpack.feature.saveToEmail": "", "preference.powerpack.feature.saveToEmail.description": "", "preference.powerpack.feature.sendToKindle": "", "preference.powerpack.feature.sendToKindle.description": "", "preference.powerpack.features": "", "preference.powerpack.free.trial": "", "preference.powerpack.login.github": "Mit Github anmelden", "preference.powerpack.login.google": "Mit Google anmelden", "preference.powerpack.logout": "Abmelden", "preference.powerpack.reload": "Neu laden", "preference.powerpack.upgrade": "Upgrade", "preference.tab.account": "Konto", "preference.tab.basic": "", "preference.tab.changelog": "Änderungsprotokoll", "preference.tab.extensions": "", "preference.tab.imageHost": "", "preference.tab.powerpack": "", "preference.tab.privacy": "", "tool.clipExtensions": "Clip-Erweiterung", "tool.repository": "Repository", "tool.save": "Inhalt speichern", "tool.saveButton.noRepository": "Bitte wählen Sie ein Repository aus.", "tool.title": "Titel", "tool.title.required": "Titel ist erforderlich", "tool.toolExtensions": "Tool-Erweiterung" } ================================================ FILE: src/common/locales/data/de-DE.ts ================================================ import { LocaleModel } from '@/common/locales/interface'; import messages from './de-DE.json'; const model: LocaleModel = { name: 'Deutsch', locale: 'de-DE', messages, alias: ['de'], }; export default model; ================================================ FILE: src/common/locales/data/en-US.json ================================================ { "auth.modal.title": "Account Config", "backend.error.store": "The extensions gallery cannot be scripted.", "backend.imageHosting.baklib.builtInRemark": "Baklib built in image hosting service.", "backend.imageHosting.baklib.name": "Baklib", "backend.imageHosting.github.form.accessToken": "AccessToken", "backend.imageHosting.github.form.accessToken.errorMessage": "AccessToken is required.", "backend.imageHosting.github.form.generateNewToken": "Generate new token", "backend.imageHosting.github.form.repo": "Repository", "backend.imageHosting.github.form.repo.errorMessage": "Please select a repository", "backend.imageHosting.github.form.savePath": "", "backend.imageHosting.github.repo.errorMessage": "Please config the github imageHosting again.", "backend.imageHosting.joplin.builtInRemark": "Joplin built in image hosting service.", "backend.imageHosting.joplin.name": "Joplin", "backend.imageHosting.leanote.builtInRemark": "Leanote built in image hosting service.", "backend.imageHosting.leanote.name": "Leanote", "backend.imageHosting.siyuan.builtInRemark": "Siyuan Note built in image hosting service.", "backend.imageHosting.siyuan.name": "SiYuan", "backend.imageHosting.yuque_oauth.builtInRemark": "Yuque built in image hosting service.", "backend.imageHosting.yuque_oauth.error_401": "No permission, need to delete the current account and re authorize.", "backend.imageHosting.yuque_oauth.error_403": "No permission, need to delete the current account and re authorize.", "backend.imageHosting.yuque_oauth.error_429": "Requests are too frequent.Request limit 100 per hour.", "backend.imageHosting.yuque_oauth.name": "Yuque Oauth", "backend.imageHosting.yuque.name": "Yuque", "backend.not.unavailable": "Clipping of this type of page is temporarily unavailable.\n\nRefreshing the page can resolve。", "backend.services.baklib.form.authentication": "Authentication", "backend.services.baklib.headerForm.channel": "", "backend.services.baklib.headerForm.description": "", "backend.services.baklib.headerForm.tags": "", "backend.services.baklib.name": "Baklib", "backend.services.bear.form.confirm": "", "backend.services.confluence.form.authentication": "", "backend.services.confluence.form.origin": "", "backend.services.confluence.form.space": "", "backend.services.dida365.headerForm.applyTags": "", "backend.services.dida365.name": "Dida365", "backend.services.dida365.rootGroup": "", "backend.services.dida365.unauthorizedErrorMessage": "", "backend.services.flomo.login": "Unauthorized! Please Login Flomo Web.", "backend.services.github.form.GenerateNewToken": "", "backend.services.github.form.storageLocation": "", "backend.services.github.form.storageLocation.code": "", "backend.services.github.form.storageLocation.code.savePath": "", "backend.services.github.form.storageLocation.code.savePathPlaceHolder": "", "backend.services.github.form.storageLocation.issue": "", "backend.services.github.form.visibility": "Visibility", "backend.services.github.form.visibility.all": "All", "backend.services.github.form.visibility.private": "Private", "backend.services.github.form.visibility.public": "Public", "backend.services.github.headerForm.applyLabels": "", "backend.services.joplin.filter_tags": "Filter tags", "backend.services.joplin.filter_unused_tags": "Filter unused tags", "backend.services.joplin.headerForm.tags": "Tags", "backend.services.joplin.name": "Joplin", "backend.services.kindle.form.alert": "", "backend.services.kindle.name": "Send to Kindle", "backend.services.leanote.form.email": "Email", "backend.services.leanote.name": "Leanote", "backend.services.mail.form.address.is.required": "", "backend.services.mail.form.buy.powerpack": "", "backend.services.mail.form.homepage": "", "backend.services.mail.form.homepage.is.required": "", "backend.services.mail.form.homepage.of.mail": "", "backend.services.mail.form.powerpack": "", "backend.services.mail.form.powerpack.is.expired": "", "backend.services.mail.form.powerpack.is.required": "", "backend.services.mail.form.send.html": "", "backend.services.mail.form.send.html.or.markdown": "", "backend.services.mail.form.send.to": "", "backend.services.mail.name": "Mail", "backend.services.notion.unauthorizedErrorMessage": "", "backend.services.onenote_oauth.name": "", "backend.services.server_chan.accessToken.message": "", "backend.services.server_chan.name": "ServerChan", "backend.services.siyuan.form.accessToken": "Token", "backend.services.siyuan.name": "SiYuan Note", "backend.services.siyuan.notes": "Notes", "backend.services.ticktick.headerForm.applyTags": "", "backend.services.ticktick.name": "", "backend.services.ticktick.rootGroup": "", "backend.services.ticktick.unauthorizedErrorMessage": "", "backend.services.wiznote.form.authentication": "", "backend.services.wiznote.form.origin": "", "backend.services.wiznote.name": "WizNote", "backend.services.wolai.unauthorizedErrorMessage": "", "backend.services.buildin.unauthorizedErrorMessage": "Auth failed, Please login build.ai first", "backend.services.youdao.name": "Youdao", "backend.services.youdao.unauthorizedErrorMessage": "", "backend.services.yuque_oauth.name": "Yuque Oauth", "backend.services.yuque.form.repositoryType": "", "backend.services.yuque.form.showAllRepository": "", "backend.services.yuque.form.showGroupRepository": "", "backend.services.yuque.form.showSelfRepository": "", "backend.services.yuque.headerForm.slug": "Slug", "backend.services.yuque.headerForm.slug_error": "The slug cannot be empty. Only letters, numbers, hyphen, underscore and dot are allowed. At least three characters.", "backend.services.yuque.name": "Yuque", "background.not_support_message": "", "component.accountItem.delete": "Delete", "component.accountItem.edit": "Edit", "component.imagehostingListItem.delete": "Delete", "component.imagehostingListItem.edit": "Edit", "component.imagehostingListItem.noDescription": "No Description", "component.imageHostingSelectOption.noDescription": "", "component.powerpackForm.expired": "", "component.powerpackForm.required": "", "contextMenus.selection.save.description": "Save selection context", "contextMenus.selection.save.template": "Save From : [{TITLE}]({URL}) \n\n## Content\n{CONTENT}\n## Note", "contextMenus.selection.save.title": "Save selection", "extension.link.config.autoRunExclude": "AutoRun Exclude", "extension.link.config.template": "Template", "hooks.useOriginForm.origin.message": "", "page.complete.close": "", "page.complete.error": "Some Error", "page.complete.message": "Go to {name}", "page.complete.share": "", "page.complete.success": "Success", "preference.account.add": "", "preference.accountList.add": "", "preference.accountList.addAccount": "", "preference.accountList.confirm": "", "preference.accountList.defaultRepository": "", "preference.accountList.editAccount": "", "preference.accountList.imageHost": "", "preference.accountList.login": "", "preference.accountList.type": "", "preference.accountList.verify": "", "preference.accountList.verifying": "", "preference.basic.configLanguage.description": "My native language is Chinese,Welcome to submit a translation on {GitHub}.", "preference.basic.configLanguage.title": "", "preference.basic.iconColor.auto": "Auto", "preference.basic.iconColor.dark": "Dark", "preference.basic.iconColor.description": "Icon Color", "preference.basic.iconColor.light": "Light", "preference.basic.iconColor.title": "Icon Color", "preference.basic.liveRendering.description": "", "preference.basic.liveRendering.title": "", "preference.basic.update.button": "", "preference.basic.update.description": "", "preference.basic.update.title": "", "preference.bind.message": "", "preference.extensions.automaticOperationIsProhibited": "", "preference.extensions.CancelSetting": "", "preference.extensions.clipExtensions": "", "preference.extensions.clipExtensions.tooltip": "Click on the 🌟 to choose the default extension.", "preference.extensions.ConfiguredAsDefaultExtension": "", "preference.extensions.contextMenus": "Context Menus", "preference.extensions.form.reset": "Rest", "preference.extensions.install": "", "preference.extensions.no.Description": "No Description", "preference.extensions.require.powerpack": "", "preference.extensions.require.update": "", "preference.extensions.runAutomaticOnSaving": "Run Automatic On Saving", "preference.extensions.toolExtensions": "", "preference.extensions.Uninstall": "Uninstall", "preference.extensions.update": "Update", "preference.imageHosting.add": "", "preference.imageHosting.edit": "", "preference.imageHosting.remark": "", "preference.imageHosting.type": "", "preference.powerpack.activate": "", "preference.powerpack.expiry": "Expiry", "preference.powerpack.failed": "Failed to load powerpack info.", "preference.powerpack.feature.coffee": "", "preference.powerpack.feature.coffee.description": "", "preference.powerpack.feature.ocr": "", "preference.powerpack.feature.ocr.description": "", "preference.powerpack.feature.saveToEmail": "", "preference.powerpack.feature.saveToEmail.description": "", "preference.powerpack.feature.sendToKindle": "", "preference.powerpack.feature.sendToKindle.description": "", "preference.powerpack.features": "", "preference.powerpack.free.trial": "", "preference.powerpack.login.github": "Login with Github", "preference.powerpack.login.google": "Login with Google", "preference.powerpack.logout": "Logout", "preference.powerpack.reload": "Reload", "preference.powerpack.upgrade": "Upgrade", "preference.tab.account": "Account", "preference.tab.basic": "", "preference.tab.changelog": "Changelog", "preference.tab.extensions": "", "preference.tab.imageHost": "", "preference.tab.powerpack": "", "preference.tab.privacy": "", "tool.clipExtensions": "Clip Extension", "tool.repository": "Repository", "tool.save": "Save Content", "tool.saveButton.noRepository": "Please select a repository", "tool.title": "Title", "tool.title.required": "Title is Required", "tool.toolExtensions": "Tool Extension" } ================================================ FILE: src/common/locales/data/en-US.ts ================================================ import { LocaleModel } from '@/common/locales/interface'; import messages from './en-US.json'; const model: LocaleModel = { name: 'English', locale: 'en-US', messages, alias: ['en'], }; export default model; ================================================ FILE: src/common/locales/data/ja-JP.json ================================================ { "auth.modal.title": "", "backend.error.store": "", "backend.imageHosting.baklib.builtInRemark": "", "backend.imageHosting.baklib.name": "", "backend.imageHosting.github.form.accessToken": "", "backend.imageHosting.github.form.accessToken.errorMessage": "", "backend.imageHosting.github.form.generateNewToken": "", "backend.imageHosting.github.form.repo": "", "backend.imageHosting.github.form.repo.errorMessage": "", "backend.imageHosting.github.form.savePath": "", "backend.imageHosting.github.repo.errorMessage": "", "backend.imageHosting.joplin.builtInRemark": "", "backend.imageHosting.joplin.name": "", "backend.imageHosting.leanote.builtInRemark": "", "backend.imageHosting.leanote.name": "", "backend.imageHosting.siyuan.builtInRemark": "", "backend.imageHosting.siyuan.name": "", "backend.imageHosting.yuque_oauth.builtInRemark": "", "backend.imageHosting.yuque_oauth.error_401": "", "backend.imageHosting.yuque_oauth.error_403": "", "backend.imageHosting.yuque_oauth.error_429": "", "backend.imageHosting.yuque_oauth.name": "", "backend.imageHosting.yuque.name": "", "backend.not.unavailable": "", "backend.services.baklib.form.authentication": "", "backend.services.baklib.headerForm.channel": "", "backend.services.baklib.headerForm.description": "", "backend.services.baklib.headerForm.tags": "", "backend.services.baklib.name": "", "backend.services.bear.form.confirm": "", "backend.services.confluence.form.authentication": "", "backend.services.confluence.form.origin": "", "backend.services.confluence.form.space": "", "backend.services.dida365.headerForm.applyTags": "", "backend.services.dida365.name": "", "backend.services.dida365.rootGroup": "", "backend.services.dida365.unauthorizedErrorMessage": "", "backend.services.flomo.login": "", "backend.services.github.form.GenerateNewToken": "", "backend.services.github.form.storageLocation": "", "backend.services.github.form.storageLocation.code": "", "backend.services.github.form.storageLocation.code.savePath": "", "backend.services.github.form.storageLocation.code.savePathPlaceHolder": "", "backend.services.github.form.storageLocation.issue": "", "backend.services.github.form.visibility": "", "backend.services.github.form.visibility.all": "", "backend.services.github.form.visibility.private": "", "backend.services.github.form.visibility.public": "", "backend.services.github.headerForm.applyLabels": "", "backend.services.joplin.filter_tags": "", "backend.services.joplin.filter_unused_tags": "", "backend.services.joplin.headerForm.tags": "", "backend.services.joplin.name": "", "backend.services.kindle.form.alert": "", "backend.services.kindle.name": "", "backend.services.leanote.form.email": "", "backend.services.leanote.name": "", "backend.services.mail.form.address.is.required": "", "backend.services.mail.form.buy.powerpack": "", "backend.services.mail.form.homepage": "", "backend.services.mail.form.homepage.is.required": "", "backend.services.mail.form.homepage.of.mail": "", "backend.services.mail.form.powerpack": "", "backend.services.mail.form.powerpack.is.expired": "", "backend.services.mail.form.powerpack.is.required": "", "backend.services.mail.form.send.html": "", "backend.services.mail.form.send.html.or.markdown": "", "backend.services.mail.form.send.to": "", "backend.services.mail.name": "", "backend.services.notion.unauthorizedErrorMessage": "", "backend.services.onenote_oauth.name": "", "backend.services.server_chan.accessToken.message": "", "backend.services.server_chan.name": "", "backend.services.siyuan.form.accessToken": "", "backend.services.siyuan.name": "", "backend.services.siyuan.notes": "", "backend.services.ticktick.headerForm.applyTags": "", "backend.services.ticktick.name": "", "backend.services.ticktick.rootGroup": "", "backend.services.ticktick.unauthorizedErrorMessage": "", "backend.services.wiznote.form.authentication": "", "backend.services.wiznote.form.origin": "", "backend.services.wiznote.name": "", "backend.services.wolai.unauthorizedErrorMessage": "", "backend.services.youdao.name": "", "backend.services.youdao.unauthorizedErrorMessage": "", "backend.services.yuque_oauth.name": "", "backend.services.yuque.form.repositoryType": "", "backend.services.yuque.form.showAllRepository": "", "backend.services.yuque.form.showGroupRepository": "", "backend.services.yuque.form.showSelfRepository": "", "backend.services.yuque.headerForm.slug": "", "backend.services.yuque.headerForm.slug_error": "", "backend.services.yuque.name": "", "background.not_support_message": "", "component.accountItem.delete": "", "component.accountItem.edit": "", "component.imagehostingListItem.delete": "", "component.imagehostingListItem.edit": "", "component.imagehostingListItem.noDescription": "", "component.imageHostingSelectOption.noDescription": "", "component.powerpackForm.expired": "", "component.powerpackForm.required": "", "contextMenus.selection.save.description": "", "contextMenus.selection.save.template": "", "contextMenus.selection.save.title": "", "extension.link.config.autoRunExclude": "", "extension.link.config.template": "", "hooks.useOriginForm.origin.message": "", "page.complete.close": "", "page.complete.error": "", "page.complete.message": "", "page.complete.share": "", "page.complete.success": "", "preference.account.add": "", "preference.accountList.add": "", "preference.accountList.addAccount": "", "preference.accountList.confirm": "", "preference.accountList.defaultRepository": "", "preference.accountList.editAccount": "", "preference.accountList.imageHost": "", "preference.accountList.login": "", "preference.accountList.type": "", "preference.accountList.verify": "", "preference.accountList.verifying": "", "preference.basic.configLanguage.description": "私の母国語は中国語です,{GitHub}で翻訳を送信してください.", "preference.basic.configLanguage.title": "", "preference.basic.iconColor.auto": "", "preference.basic.iconColor.dark": "", "preference.basic.iconColor.description": "", "preference.basic.iconColor.light": "", "preference.basic.iconColor.title": "", "preference.basic.liveRendering.description": "", "preference.basic.liveRendering.title": "", "preference.basic.update.button": "", "preference.basic.update.description": "", "preference.basic.update.title": "", "preference.bind.message": "", "preference.extensions.automaticOperationIsProhibited": "", "preference.extensions.CancelSetting": "", "preference.extensions.clipExtensions": "", "preference.extensions.clipExtensions.tooltip": "", "preference.extensions.ConfiguredAsDefaultExtension": "", "preference.extensions.contextMenus": "", "preference.extensions.form.reset": "", "preference.extensions.install": "", "preference.extensions.no.Description": "", "preference.extensions.require.powerpack": "", "preference.extensions.require.update": "", "preference.extensions.runAutomaticOnSaving": "", "preference.extensions.toolExtensions": "", "preference.extensions.Uninstall": "", "preference.extensions.update": "", "preference.imageHosting.add": "", "preference.imageHosting.edit": "", "preference.imageHosting.remark": "", "preference.imageHosting.type": "", "preference.powerpack.activate": "", "preference.powerpack.expiry": "", "preference.powerpack.failed": "", "preference.powerpack.feature.coffee": "", "preference.powerpack.feature.coffee.description": "", "preference.powerpack.feature.ocr": "", "preference.powerpack.feature.ocr.description": "", "preference.powerpack.feature.saveToEmail": "", "preference.powerpack.feature.saveToEmail.description": "", "preference.powerpack.feature.sendToKindle": "", "preference.powerpack.feature.sendToKindle.description": "", "preference.powerpack.features": "", "preference.powerpack.free.trial": "", "preference.powerpack.login.github": "", "preference.powerpack.login.google": "", "preference.powerpack.logout": "", "preference.powerpack.reload": "", "preference.powerpack.upgrade": "", "preference.tab.account": "", "preference.tab.basic": "", "preference.tab.changelog": "", "preference.tab.extensions": "", "preference.tab.imageHost": "", "preference.tab.powerpack": "", "preference.tab.privacy": "", "tool.clipExtensions": "", "tool.repository": "", "tool.save": "コンテンツを保存する", "tool.saveButton.noRepository": "", "tool.title": "記事タイトル", "tool.title.required": "", "tool.toolExtensions": "" } ================================================ FILE: src/common/locales/data/ja-JP.ts ================================================ import { LocaleModel } from '@/common/locales/interface'; import messages from './ja-JP.json'; const model: LocaleModel = { name: '日本語', locale: 'ja-JP', messages, alias: ['jp'], }; export default model; ================================================ FILE: src/common/locales/data/ko-KR.json ================================================ { "auth.modal.title": "계정 설정", "backend.error.store": "", "backend.imageHosting.baklib.builtInRemark": "", "backend.imageHosting.baklib.name": "Baklib", "backend.imageHosting.github.form.accessToken": "", "backend.imageHosting.github.form.accessToken.errorMessage": "", "backend.imageHosting.github.form.generateNewToken": "", "backend.imageHosting.github.form.repo": "", "backend.imageHosting.github.form.repo.errorMessage": "", "backend.imageHosting.github.form.savePath": "", "backend.imageHosting.github.repo.errorMessage": "", "backend.imageHosting.joplin.builtInRemark": "", "backend.imageHosting.joplin.name": "", "backend.imageHosting.leanote.builtInRemark": "", "backend.imageHosting.leanote.name": "", "backend.imageHosting.siyuan.builtInRemark": "", "backend.imageHosting.siyuan.name": "", "backend.imageHosting.yuque_oauth.builtInRemark": "", "backend.imageHosting.yuque_oauth.error_401": "", "backend.imageHosting.yuque_oauth.error_403": "", "backend.imageHosting.yuque_oauth.error_429": "", "backend.imageHosting.yuque_oauth.name": "", "backend.imageHosting.yuque.name": "", "backend.not.unavailable": "", "backend.services.baklib.form.authentication": "", "backend.services.baklib.headerForm.channel": "", "backend.services.baklib.headerForm.description": "", "backend.services.baklib.headerForm.tags": "", "backend.services.baklib.name": "", "backend.services.bear.form.confirm": "", "backend.services.confluence.form.authentication": "", "backend.services.confluence.form.origin": "", "backend.services.confluence.form.space": "", "backend.services.dida365.headerForm.applyTags": "", "backend.services.dida365.name": "", "backend.services.dida365.rootGroup": "", "backend.services.dida365.unauthorizedErrorMessage": "", "backend.services.flomo.login": "", "backend.services.github.form.GenerateNewToken": "", "backend.services.github.form.storageLocation": "", "backend.services.github.form.storageLocation.code": "", "backend.services.github.form.storageLocation.code.savePath": "", "backend.services.github.form.storageLocation.code.savePathPlaceHolder": "", "backend.services.github.form.storageLocation.issue": "", "backend.services.github.form.visibility": "공개범위", "backend.services.github.form.visibility.all": "전체", "backend.services.github.form.visibility.private": "프라이빗", "backend.services.github.form.visibility.public": "퍼블릭", "backend.services.github.headerForm.applyLabels": "", "backend.services.joplin.filter_tags": "Filter tags", "backend.services.joplin.filter_unused_tags": "Filter unused tags", "backend.services.joplin.headerForm.tags": "Tags", "backend.services.joplin.name": "Joplin", "backend.services.kindle.form.alert": "", "backend.services.kindle.name": "Kindle로 보내기", "backend.services.leanote.form.email": "이메일", "backend.services.leanote.name": "Leanote", "backend.services.mail.form.address.is.required": "", "backend.services.mail.form.buy.powerpack": "", "backend.services.mail.form.homepage": "", "backend.services.mail.form.homepage.is.required": "", "backend.services.mail.form.homepage.of.mail": "", "backend.services.mail.form.powerpack": "", "backend.services.mail.form.powerpack.is.expired": "", "backend.services.mail.form.powerpack.is.required": "", "backend.services.mail.form.send.html": "", "backend.services.mail.form.send.html.or.markdown": "", "backend.services.mail.form.send.to": "", "backend.services.mail.name": "메일", "backend.services.notion.unauthorizedErrorMessage": "", "backend.services.onenote_oauth.name": "", "backend.services.server_chan.accessToken.message": "", "backend.services.server_chan.name": "", "backend.services.siyuan.form.accessToken": "", "backend.services.siyuan.name": "", "backend.services.siyuan.notes": "", "backend.services.ticktick.headerForm.applyTags": "", "backend.services.ticktick.name": "", "backend.services.ticktick.rootGroup": "", "backend.services.ticktick.unauthorizedErrorMessage": "", "backend.services.wiznote.form.authentication": "", "backend.services.wiznote.form.origin": "", "backend.services.wiznote.name": "", "backend.services.wolai.unauthorizedErrorMessage": "", "backend.services.youdao.name": "", "backend.services.youdao.unauthorizedErrorMessage": "", "backend.services.yuque_oauth.name": "", "backend.services.yuque.form.repositoryType": "", "backend.services.yuque.form.showAllRepository": "", "backend.services.yuque.form.showGroupRepository": "", "backend.services.yuque.form.showSelfRepository": "", "backend.services.yuque.headerForm.slug": "", "backend.services.yuque.headerForm.slug_error": "", "backend.services.yuque.name": "", "background.not_support_message": "", "component.accountItem.delete": "삭제", "component.accountItem.edit": "수정", "component.imagehostingListItem.delete": "삭제", "component.imagehostingListItem.edit": "수정", "component.imagehostingListItem.noDescription": "설명 없음", "component.imageHostingSelectOption.noDescription": "", "component.powerpackForm.expired": "", "component.powerpackForm.required": "", "contextMenus.selection.save.description": "영역의 콘텐츠 저장", "contextMenus.selection.save.template": "페이지: [{TITLE}]({URL}) \n\n## 내용\n{CONTENT}\n##", "contextMenus.selection.save.title": "영역 저장", "extension.link.config.autoRunExclude": "자동실행 제외", "extension.link.config.template": "템플릿", "hooks.useOriginForm.origin.message": "", "page.complete.close": "", "page.complete.error": "일부 에러", "page.complete.message": "{name}으로 이동", "page.complete.share": "", "page.complete.success": "성공", "preference.account.add": "", "preference.accountList.add": "", "preference.accountList.addAccount": "", "preference.accountList.confirm": "", "preference.accountList.defaultRepository": "", "preference.accountList.editAccount": "", "preference.accountList.imageHost": "", "preference.accountList.login": "", "preference.accountList.type": "", "preference.accountList.verify": "", "preference.accountList.verifying": "", "preference.basic.configLanguage.description": "제 모국어는 중국어입니다, 번역은 {GitHub}에서 함께하실 수 있습니다.", "preference.basic.configLanguage.title": "", "preference.basic.iconColor.auto": "자동", "preference.basic.iconColor.dark": "다크", "preference.basic.iconColor.description": "아이콘 색상", "preference.basic.iconColor.light": "라이트", "preference.basic.iconColor.title": "아이콘 색상", "preference.basic.liveRendering.description": "", "preference.basic.liveRendering.title": "", "preference.basic.update.button": "", "preference.basic.update.description": "", "preference.basic.update.title": "", "preference.bind.message": "", "preference.extensions.automaticOperationIsProhibited": "", "preference.extensions.CancelSetting": "", "preference.extensions.clipExtensions": "", "preference.extensions.clipExtensions.tooltip": "🌟를 클릭하여 기본 확장을 선택하세요", "preference.extensions.ConfiguredAsDefaultExtension": "", "preference.extensions.contextMenus": "", "preference.extensions.form.reset": "초기화", "preference.extensions.install": "", "preference.extensions.no.Description": "설명 없음", "preference.extensions.require.powerpack": "", "preference.extensions.require.update": "", "preference.extensions.runAutomaticOnSaving": "", "preference.extensions.toolExtensions": "", "preference.extensions.Uninstall": "설치 제거", "preference.extensions.update": "업데이트", "preference.imageHosting.add": "", "preference.imageHosting.edit": "", "preference.imageHosting.remark": "", "preference.imageHosting.type": "", "preference.powerpack.activate": "", "preference.powerpack.expiry": "만료", "preference.powerpack.failed": "파워팩 정보를 읽어오지 못했습니다", "preference.powerpack.feature.coffee": "", "preference.powerpack.feature.coffee.description": "", "preference.powerpack.feature.ocr": "", "preference.powerpack.feature.ocr.description": "", "preference.powerpack.feature.saveToEmail": "", "preference.powerpack.feature.saveToEmail.description": "", "preference.powerpack.feature.sendToKindle": "", "preference.powerpack.feature.sendToKindle.description": "", "preference.powerpack.features": "", "preference.powerpack.free.trial": "", "preference.powerpack.login.github": "Github으로 로그인", "preference.powerpack.login.google": "Google로 로그인", "preference.powerpack.logout": "로그아웃", "preference.powerpack.reload": "새로 고침", "preference.powerpack.upgrade": "업그레이드", "preference.tab.account": "계정", "preference.tab.basic": "", "preference.tab.changelog": "변경사항", "preference.tab.extensions": "", "preference.tab.imageHost": "", "preference.tab.powerpack": "", "preference.tab.privacy": "", "tool.clipExtensions": "클립 확장", "tool.repository": "저장소", "tool.save": "컨텐츠 저장", "tool.saveButton.noRepository": "저장소를 선택하세요", "tool.title": "컨텐츠 저장", "tool.title.required": "제목을 입력해주세요", "tool.toolExtensions": "도구 확장" } ================================================ FILE: src/common/locales/data/ko-KR.ts ================================================ import { LocaleModel } from '@/common/locales/interface'; import messages from './ko-KR.json'; const model: LocaleModel = { name: '한국어', locale: 'ko-KR', messages, alias: [], }; export default model; ================================================ FILE: src/common/locales/data/ru-RU.json ================================================ { "auth.modal.title": "", "backend.error.store": "", "backend.imageHosting.baklib.builtInRemark": "", "backend.imageHosting.baklib.name": "", "backend.imageHosting.github.form.accessToken": "", "backend.imageHosting.github.form.accessToken.errorMessage": "", "backend.imageHosting.github.form.generateNewToken": "", "backend.imageHosting.github.form.repo": "", "backend.imageHosting.github.form.repo.errorMessage": "", "backend.imageHosting.github.form.savePath": "", "backend.imageHosting.github.repo.errorMessage": "", "backend.imageHosting.joplin.builtInRemark": "", "backend.imageHosting.joplin.name": "", "backend.imageHosting.leanote.builtInRemark": "", "backend.imageHosting.leanote.name": "", "backend.imageHosting.siyuan.builtInRemark": "", "backend.imageHosting.siyuan.name": "", "backend.imageHosting.yuque_oauth.builtInRemark": "", "backend.imageHosting.yuque_oauth.error_401": "", "backend.imageHosting.yuque_oauth.error_403": "", "backend.imageHosting.yuque_oauth.error_429": "", "backend.imageHosting.yuque_oauth.name": "", "backend.imageHosting.yuque.name": "", "backend.not.unavailable": "", "backend.services.baklib.form.authentication": "", "backend.services.baklib.headerForm.channel": "", "backend.services.baklib.headerForm.description": "", "backend.services.baklib.headerForm.tags": "", "backend.services.baklib.name": "", "backend.services.bear.form.confirm": "", "backend.services.confluence.form.authentication": "", "backend.services.confluence.form.origin": "", "backend.services.confluence.form.space": "", "backend.services.dida365.headerForm.applyTags": "", "backend.services.dida365.name": "", "backend.services.dida365.rootGroup": "", "backend.services.dida365.unauthorizedErrorMessage": "", "backend.services.flomo.login": "", "backend.services.github.form.GenerateNewToken": "", "backend.services.github.form.storageLocation": "", "backend.services.github.form.storageLocation.code": "", "backend.services.github.form.storageLocation.code.savePath": "", "backend.services.github.form.storageLocation.code.savePathPlaceHolder": "", "backend.services.github.form.storageLocation.issue": "", "backend.services.github.form.visibility": "", "backend.services.github.form.visibility.all": "", "backend.services.github.form.visibility.private": "", "backend.services.github.form.visibility.public": "", "backend.services.github.headerForm.applyLabels": "", "backend.services.joplin.filter_tags": "", "backend.services.joplin.filter_unused_tags": "", "backend.services.joplin.headerForm.tags": "", "backend.services.joplin.name": "", "backend.services.kindle.form.alert": "", "backend.services.kindle.name": "", "backend.services.leanote.form.email": "", "backend.services.leanote.name": "", "backend.services.mail.form.address.is.required": "", "backend.services.mail.form.buy.powerpack": "", "backend.services.mail.form.homepage": "", "backend.services.mail.form.homepage.is.required": "", "backend.services.mail.form.homepage.of.mail": "", "backend.services.mail.form.powerpack": "", "backend.services.mail.form.powerpack.is.expired": "", "backend.services.mail.form.powerpack.is.required": "", "backend.services.mail.form.send.html": "", "backend.services.mail.form.send.html.or.markdown": "", "backend.services.mail.form.send.to": "", "backend.services.mail.name": "", "backend.services.notion.unauthorizedErrorMessage": "", "backend.services.onenote_oauth.name": "", "backend.services.server_chan.accessToken.message": "", "backend.services.server_chan.name": "", "backend.services.siyuan.form.accessToken": "", "backend.services.siyuan.name": "", "backend.services.siyuan.notes": "", "backend.services.ticktick.headerForm.applyTags": "", "backend.services.ticktick.name": "", "backend.services.ticktick.rootGroup": "", "backend.services.ticktick.unauthorizedErrorMessage": "", "backend.services.wiznote.form.authentication": "", "backend.services.wiznote.form.origin": "", "backend.services.wiznote.name": "", "backend.services.wolai.unauthorizedErrorMessage": "", "backend.services.youdao.name": "", "backend.services.youdao.unauthorizedErrorMessage": "", "backend.services.yuque_oauth.name": "", "backend.services.yuque.form.repositoryType": "", "backend.services.yuque.form.showAllRepository": "", "backend.services.yuque.form.showGroupRepository": "", "backend.services.yuque.form.showSelfRepository": "", "backend.services.yuque.headerForm.slug": "", "backend.services.yuque.headerForm.slug_error": "", "backend.services.yuque.name": "", "background.not_support_message": "", "component.accountItem.delete": "", "component.accountItem.edit": "", "component.imagehostingListItem.delete": "", "component.imagehostingListItem.edit": "", "component.imagehostingListItem.noDescription": "", "component.imageHostingSelectOption.noDescription": "", "component.powerpackForm.expired": "", "component.powerpackForm.required": "", "contextMenus.selection.save.description": "", "contextMenus.selection.save.template": "", "contextMenus.selection.save.title": "", "extension.link.config.autoRunExclude": "", "extension.link.config.template": "", "hooks.useOriginForm.origin.message": "", "page.complete.close": "", "page.complete.error": "", "page.complete.message": "", "page.complete.share": "", "page.complete.success": "", "preference.account.add": "", "preference.accountList.add": "", "preference.accountList.addAccount": "", "preference.accountList.confirm": "", "preference.accountList.defaultRepository": "", "preference.accountList.editAccount": "", "preference.accountList.imageHost": "", "preference.accountList.login": "", "preference.accountList.type": "", "preference.accountList.verify": "", "preference.accountList.verifying": "", "preference.basic.configLanguage.description": "Мой родной язык китайский, добро пожаловать на перевод на {GitHub}.", "preference.basic.configLanguage.title": "", "preference.basic.iconColor.auto": "", "preference.basic.iconColor.dark": "", "preference.basic.iconColor.description": "", "preference.basic.iconColor.light": "", "preference.basic.iconColor.title": "", "preference.basic.liveRendering.description": "", "preference.basic.liveRendering.title": "", "preference.basic.update.button": "", "preference.basic.update.description": "", "preference.basic.update.title": "", "preference.bind.message": "", "preference.extensions.automaticOperationIsProhibited": "", "preference.extensions.CancelSetting": "", "preference.extensions.clipExtensions": "", "preference.extensions.clipExtensions.tooltip": "", "preference.extensions.ConfiguredAsDefaultExtension": "", "preference.extensions.contextMenus": "", "preference.extensions.form.reset": "", "preference.extensions.install": "", "preference.extensions.no.Description": "", "preference.extensions.require.powerpack": "", "preference.extensions.require.update": "", "preference.extensions.runAutomaticOnSaving": "", "preference.extensions.toolExtensions": "", "preference.extensions.Uninstall": "", "preference.extensions.update": "", "preference.imageHosting.add": "", "preference.imageHosting.edit": "", "preference.imageHosting.remark": "", "preference.imageHosting.type": "", "preference.powerpack.activate": "", "preference.powerpack.expiry": "", "preference.powerpack.failed": "", "preference.powerpack.feature.coffee": "", "preference.powerpack.feature.coffee.description": "", "preference.powerpack.feature.ocr": "", "preference.powerpack.feature.ocr.description": "", "preference.powerpack.feature.saveToEmail": "", "preference.powerpack.feature.saveToEmail.description": "", "preference.powerpack.feature.sendToKindle": "", "preference.powerpack.feature.sendToKindle.description": "", "preference.powerpack.features": "", "preference.powerpack.free.trial": "", "preference.powerpack.login.github": "", "preference.powerpack.login.google": "", "preference.powerpack.logout": "", "preference.powerpack.reload": "", "preference.powerpack.upgrade": "", "preference.tab.account": "", "preference.tab.basic": "", "preference.tab.changelog": "", "preference.tab.extensions": "", "preference.tab.imageHost": "", "preference.tab.powerpack": "", "preference.tab.privacy": "", "tool.clipExtensions": "", "tool.repository": "", "tool.save": "", "tool.saveButton.noRepository": "", "tool.title": "Сохранить контент", "tool.title.required": "", "tool.toolExtensions": "" } ================================================ FILE: src/common/locales/data/ru-RU.ts ================================================ import { LocaleModel } from '@/common/locales/interface'; import messages from './ru-RU.json'; const model: LocaleModel = { name: 'русский', locale: 'ru-RU', messages, alias: [], }; export default model; ================================================ FILE: src/common/locales/data/zh-CN.json ================================================ { "auth.modal.title": "账户配置", "backend.error.store": "插件商店不允许执行脚本", "backend.imageHosting.baklib.builtInRemark": "Baklib 内置图床", "backend.imageHosting.baklib.name": "Baklib", "backend.imageHosting.github.form.accessToken": "AccessToken", "backend.imageHosting.github.form.accessToken.errorMessage": "请填写 AccessToken。", "backend.imageHosting.github.form.generateNewToken": "生成新 Token", "backend.imageHosting.github.form.repo": "仓库", "backend.imageHosting.github.form.repo.errorMessage": "请选择仓库。", "backend.imageHosting.github.form.savePath": "保存路径", "backend.imageHosting.github.repo.errorMessage": "请重新设置 GitHub 图床", "backend.imageHosting.joplin.builtInRemark": "Joplin 内置图床", "backend.imageHosting.joplin.name": "Joplin", "backend.imageHosting.leanote.builtInRemark": "蚂蚁笔记内置图床", "backend.imageHosting.leanote.name": "蚂蚁笔记", "backend.imageHosting.siyuan.builtInRemark": "", "backend.imageHosting.siyuan.name": "思源笔记", "backend.imageHosting.yuque_oauth.builtInRemark": "语雀内置图床。", "backend.imageHosting.yuque_oauth.error_401": "没有权限,需要删除当前账户重新授权。", "backend.imageHosting.yuque_oauth.error_403": "没有权限,需要删除当前账户重新授权。", "backend.imageHosting.yuque_oauth.error_429": "请求太频繁,接口限制每小时最多 100 次。", "backend.imageHosting.yuque_oauth.name": "语雀(一键授权)", "backend.imageHosting.yuque.name": "语雀", "backend.imageHosting.wiznote.name": "为知笔记", "backend.imageHosting.wiznote.builtInRemark": "为知笔记内置图床", "backend.not.unavailable": "暂时无法剪辑此类型的页面。\n\n刷新页面可以解决。", "backend.services.memos.name": "Memos", "backend.services.memos.form.hostTest": "检验", "backend.services.memos.accessToken.message": "请输入 AccessToken", "backend.services.memos.form.authentication": "请输入服务器地址", "backend.services.memos.headerForm.tag": "请输入标签名称,多个标签用英文逗号分隔,如 tag1,tag2...", "backend.services.memos.headerForm.visibility": "文档类型", "backend.services.memos.headerForm.VisibilityType.private": "私人", "backend.services.memos.headerForm.VisibilityType.public": "公开", "backend.services.memos.headerForm.tag_error": "标签格式错误,请检查", "backend.services.baklib.form.hostTest": "测试", "backend.services.baklib.form.authentication": "授权", "backend.services.baklib.headerForm.channel": "栏目", "backend.services.baklib.headerForm.description": "描述", "backend.services.baklib.headerForm.tags": "标签", "backend.services.baklib.name": "Baklib", "backend.services.bear.form.confirm": "请确认您安装了 Bear 客户端。", "backend.services.confluence.form.authentication": "授权", "backend.services.confluence.form.origin": "Origin", "backend.services.confluence.form.space": "空间", "backend.services.dida365.headerForm.applyTags": "选择标签", "backend.services.dida365.name": "滴答清单", "backend.services.dida365.rootGroup": "根目录", "backend.services.dida365.unauthorizedErrorMessage": "授权失败,请登录网页版滴答清单。", "backend.services.flomo.login": "授权失败,请登录网页版浮墨笔记。", "backend.services.github.form.GenerateNewToken": "生成新 Token", "backend.services.github.form.storageLocation": "保存位置", "backend.services.github.form.storageLocation.code": "Code", "backend.services.github.form.storageLocation.code.savePath": "保存路径", "backend.services.github.form.storageLocation.code.savePathPlaceHolder": "仅在保存到Code时生效", "backend.services.github.form.storageLocation.issue": "Issue", "backend.services.github.form.visibility": "可见性", "backend.services.github.form.visibility.all": "全部", "backend.services.github.form.visibility.private": "私有仓库", "backend.services.github.form.visibility.public": "公开仓库", "backend.services.github.headerForm.applyLabels": "设置标签", "backend.services.joplin.filter_tags": "过滤标签", "backend.services.joplin.filter_unused_tags": "过滤没有被使用的标签", "backend.services.joplin.headerForm.tags": "标签", "backend.services.joplin.name": "Joplin", "backend.services.kindle.form.alert": "你必须告诉 Amazon 允许 {mail} 发送邮件到你的 Kindle.", "backend.services.kindle.name": "发送到 Kindle", "backend.services.leanote.form.email": "邮箱", "backend.services.leanote.name": "蚂蚁笔记", "backend.services.mail.form.address.is.required": "请填写邮件地址", "backend.services.mail.form.buy.powerpack": "购买加强包", "backend.services.mail.form.homepage": "邮箱首页", "backend.services.mail.form.homepage.is.required": "请填写首页地址", "backend.services.mail.form.homepage.of.mail": "邮箱首页", "backend.services.mail.form.powerpack": "加强包", "backend.services.mail.form.powerpack.is.expired": "加强包已过期", "backend.services.mail.form.powerpack.is.required": "需要购买加强包", "backend.services.mail.form.send.html": "发送 Html", "backend.services.mail.form.send.html.or.markdown": "发送 Html 或 Markdown", "backend.services.mail.form.send.to": "发送给", "backend.services.mail.name": "邮件", "backend.services.notion.unauthorizedErrorMessage": "授权失败,请登录网页版 Notion。", "backend.services.onenote_oauth.name": "OneNote", "backend.services.qcloud.name": "腾讯云", "backend.services.server_chan.accessToken.message": "请输入 AccessToken", "backend.services.server_chan.name": "Server酱", "backend.services.siyuan.form.accessToken": "", "backend.services.siyuan.name": "思源笔记", "backend.services.siyuan.notes": "笔记本", "backend.services.ticktick.headerForm.applyTags": "选择标签", "backend.services.ticktick.name": "TickTick", "backend.services.ticktick.rootGroup": "根目录", "backend.services.ticktick.unauthorizedErrorMessage": "授权失败,请登录网页版 TickTick。", "backend.services.wiznote.form.authentication": "授权", "backend.services.wiznote.form.origin": "Origin", "backend.services.wiznote.name": "为知笔记", "backend.services.wolai.unauthorizedErrorMessage": "授权失败,请登录网页版 Wolai", "backend.services.flowus.unauthorizedErrorMessage": "授权失败,请登录网页版 FlowUs", "backend.services.buildin.unauthorizedErrorMessage": "授权失败,请登录网页版 Buildin.AI", "backend.services.youdao.name": "有道云笔记", "backend.services.youdao.unauthorizedErrorMessage": "授权失败,请登录网页版有道云笔记。", "backend.services.yuque_oauth.name": "语雀(一键授权)", "backend.services.yuque.form.repositoryType": "知识库类型", "backend.services.yuque.form.showAllRepository": "显示全部知识库", "backend.services.yuque.form.showGroupRepository": "显示团队的知识库", "backend.services.yuque.form.showSelfRepository": "显示自己的知识库", "backend.services.yuque.headerForm.slug": "路径", "backend.services.yuque.headerForm.slug_error": "只能输入大小写字母、横线、下划线和点,至少 2 个字符。", "backend.services.yuque.name": "语雀", "background.not_support_message": "暂时无法剪辑此类型的页面。", "component.accountItem.delete": "删除", "component.accountItem.edit": "编辑", "component.imagehostingListItem.delete": "删除", "component.imagehostingListItem.edit": "编辑", "component.imagehostingListItem.noDescription": "没有备注", "component.imageHostingSelectOption.noDescription": "没有备注", "component.powerpackForm.expired": "加强包已过期", "component.powerpackForm.required": "请购买加强包", "contextMenus.selection.save.description": "保存选择的内容", "contextMenus.selection.save.template": "来源:[{TITLE}]({URL})\n\n## 摘录内容 \n{CONTENT}\n## 想法", "contextMenus.selection.save.title": "保存选择的内容", "extension.link.config.autoRunExclude": "禁用自动运行", "extension.link.config.template": "模板", "hooks.useOriginForm.origin.message": "格式错误,示例 https://developer.mozilla.org", "page.complete.close": "关闭 Web Clipper", "page.complete.error": "发生错误", "page.complete.message": "前往 {name} 查看", "page.complete.share": "分享", "page.complete.success": "保存成功", "preference.account.add": "绑定账户", "preference.accountList.add": "添加", "preference.accountList.addAccount": "添加账户", "preference.accountList.confirm": "确认", "preference.accountList.defaultRepository": "默认知识库", "preference.accountList.editAccount": "编辑账户", "preference.accountList.imageHost": "图床", "preference.accountList.login": "授权登录", "preference.accountList.type": "类型", "preference.accountList.verify": "校验", "preference.accountList.verifying": "校验中", "preference.basic.configLanguage.description": "欢迎在 {GitHub} 提交翻译", "preference.basic.configLanguage.title": "语言", "preference.basic.iconColor.auto": "自动", "preference.basic.iconColor.dark": "暗色", "preference.basic.iconColor.description": "图标颜色", "preference.basic.iconColor.light": "亮色", "preference.basic.iconColor.title": "图标颜色", "preference.basic.liveRendering.description": "开启后编辑器使用所见即所得模式", "preference.basic.liveRendering.title": "所见即所得", "preference.basic.update.button": "安装更新", "preference.basic.update.description": "因为审核需要一周,所以 chrome 商店的版本会延迟几个版本。", "preference.basic.update.title": "有更新", "preference.bind.message": "只有绑定账户后才能使用本插件", "preference.extensions.automaticOperationIsProhibited": "自动运行被禁止", "preference.extensions.CancelSetting": "取消设置", "preference.extensions.clipExtensions": "剪藏插件", "preference.extensions.clipExtensions.tooltip": "点击 🌟选择默认插件", "preference.extensions.ConfiguredAsDefaultExtension": "设置成默认扩展", "preference.extensions.contextMenus": "右键菜单", "preference.extensions.form.reset": "恢复默认设置", "preference.extensions.install": "安装", "preference.extensions.no.Description": "没有描述", "preference.extensions.require.powerpack": "请购买加强包", "preference.extensions.require.update": "需要剪藏更新到 {version} 版本", "preference.extensions.runAutomaticOnSaving": "保存时自动运行", "preference.extensions.toolExtensions": "工具插件", "preference.extensions.Uninstall": "卸载", "preference.extensions.update": "更新", "preference.imageHosting.add": "添加图床", "preference.imageHosting.edit": "编辑", "preference.imageHosting.remark": "备注", "preference.imageHosting.type": "类型", "preference.powerpack.activate": "购买加强包解锁更多功能", "preference.powerpack.expiry": "过期时间", "preference.powerpack.failed": "获取加强包信息失败", "preference.powerpack.feature.coffee": "给我买杯咖啡", "preference.powerpack.feature.coffee.description": "让开发者更有维护的动力", "preference.powerpack.feature.ocr": "OCR", "preference.powerpack.feature.ocr.description": "识别图片中的文字", "preference.powerpack.feature.saveToEmail": "保存到邮箱", "preference.powerpack.feature.saveToEmail.description": "保存网页到邮箱中查看", "preference.powerpack.feature.sendToKindle": "保存到 Kindle", "preference.powerpack.feature.sendToKindle.description": "保存网页到 Kindle 中查看", "preference.powerpack.features": "功能", "preference.powerpack.free.trial": "免费试用7天", "preference.powerpack.login.github": "通过 Github 登录", "preference.powerpack.login.google": "通过 Google 登录", "preference.powerpack.logout": "登出", "preference.powerpack.reload": "刷新", "preference.powerpack.upgrade": "购买", "preference.tab.account": "账户", "preference.tab.basic": "基础设置", "preference.tab.changelog": "更新日志", "preference.tab.extensions": "扩展设置", "preference.tab.imageHost": "图床设置", "preference.tab.powerpack": "加强包", "preference.tab.privacy": "隐私协议", "tool.clipExtensions": "剪藏扩展", "tool.repository": "知识库", "tool.save": "保存内容", "tool.saveButton.noRepository": "请选择你要保存的知识库", "tool.title": "笔记标题", "tool.title.required": "请输入笔记标题", "tool.toolExtensions": "工具扩展" } ================================================ FILE: src/common/locales/data/zh-CN.ts ================================================ import { LocaleModel } from '@/common/locales/interface'; import messages from './zh-CN.json'; const model: LocaleModel = { name: '简体中文', locale: 'zh-CN', messages, alias: ['zh'], }; export default model; ================================================ FILE: src/common/locales/data/zh-TW.json ================================================ { "auth.modal.title": "賬戶配置", "backend.error.store": "", "backend.imageHosting.baklib.builtInRemark": "", "backend.imageHosting.baklib.name": "", "backend.imageHosting.github.form.accessToken": "", "backend.imageHosting.github.form.accessToken.errorMessage": "", "backend.imageHosting.github.form.generateNewToken": "", "backend.imageHosting.github.form.repo": "", "backend.imageHosting.github.form.repo.errorMessage": "", "backend.imageHosting.github.form.savePath": "", "backend.imageHosting.github.repo.errorMessage": "", "backend.imageHosting.joplin.builtInRemark": "Joplin 內置圖床", "backend.imageHosting.joplin.name": "Joplin", "backend.imageHosting.leanote.builtInRemark": "", "backend.imageHosting.leanote.name": "", "backend.imageHosting.siyuan.builtInRemark": "", "backend.imageHosting.siyuan.name": "", "backend.imageHosting.yuque_oauth.builtInRemark": "語雀內置圖床。", "backend.imageHosting.yuque_oauth.error_401": "沒有權限,需要刪除當前賬戶重新授權。", "backend.imageHosting.yuque_oauth.error_403": "沒有權限,需要刪除當前賬戶重新授權。", "backend.imageHosting.yuque_oauth.error_429": "請求太頻繁,接口限制每小時最多 100 次。", "backend.imageHosting.yuque_oauth.name": "語雀(一鍵授權)", "backend.imageHosting.yuque.name": "語雀", "backend.not.unavailable": "", "backend.services.baklib.form.authentication": "", "backend.services.baklib.headerForm.channel": "欄目", "backend.services.baklib.headerForm.description": "描述", "backend.services.baklib.headerForm.tags": "標簽", "backend.services.baklib.name": "Baklib", "backend.services.bear.form.confirm": "請確認您安裝了 Bear 客戶端。", "backend.services.confluence.form.authentication": "授權", "backend.services.confluence.form.origin": "Origin", "backend.services.confluence.form.space": "空間", "backend.services.dida365.headerForm.applyTags": "選擇標簽", "backend.services.dida365.name": "滴答清單", "backend.services.dida365.rootGroup": "根目錄", "backend.services.dida365.unauthorizedErrorMessage": "授權失敗,請登錄網頁版滴答清單。", "backend.services.flomo.login": "", "backend.services.github.form.GenerateNewToken": "生成新 Token", "backend.services.github.form.storageLocation": "", "backend.services.github.form.storageLocation.code": "", "backend.services.github.form.storageLocation.code.savePath": "", "backend.services.github.form.storageLocation.code.savePathPlaceHolder": "", "backend.services.github.form.storageLocation.issue": "", "backend.services.github.form.visibility": "可見性", "backend.services.github.form.visibility.all": "全部", "backend.services.github.form.visibility.private": "私有倉庫", "backend.services.github.form.visibility.public": "公開倉庫", "backend.services.github.headerForm.applyLabels": "設置標簽", "backend.services.joplin.filter_tags": "過濾標簽", "backend.services.joplin.filter_unused_tags": "過濾沒有被使用的標簽", "backend.services.joplin.headerForm.tags": "標簽", "backend.services.joplin.name": "Joplin", "backend.services.kindle.form.alert": "你必須告訴 Amazon 允許 {mail} 发送郵件到你的 Kindle.", "backend.services.kindle.name": "发送到 Kindle", "backend.services.leanote.form.email": "", "backend.services.leanote.name": "", "backend.services.mail.form.address.is.required": "請填寫郵件地址", "backend.services.mail.form.buy.powerpack": "購買加強包", "backend.services.mail.form.homepage": "郵箱首頁", "backend.services.mail.form.homepage.is.required": "請填寫首頁地址", "backend.services.mail.form.homepage.of.mail": "郵箱首頁", "backend.services.mail.form.powerpack": "加強包", "backend.services.mail.form.powerpack.is.expired": "加強包已過期", "backend.services.mail.form.powerpack.is.required": "需要購買加強包", "backend.services.mail.form.send.html": "发送 Html", "backend.services.mail.form.send.html.or.markdown": "发送 Html 或 Markdown", "backend.services.mail.form.send.to": "发送給", "backend.services.mail.name": "郵件", "backend.services.notion.unauthorizedErrorMessage": "授權失敗,請登錄網頁版 Notion。", "backend.services.onenote_oauth.name": "OneNote", "backend.services.qcloud.name": "騰訊雲", "backend.services.server_chan.accessToken.message": "請輸入 AccessToken", "backend.services.server_chan.name": "Server醬", "backend.services.siyuan.form.accessToken": "", "backend.services.siyuan.name": "", "backend.services.siyuan.notes": "", "backend.services.ticktick.headerForm.applyTags": "選擇標簽", "backend.services.ticktick.name": "TickTick", "backend.services.ticktick.rootGroup": "根目錄", "backend.services.ticktick.unauthorizedErrorMessage": "授權失敗,請登錄網頁版 TickTick。", "backend.services.wiznote.form.authentication": "授權", "backend.services.wiznote.form.origin": "Origin", "backend.services.wiznote.name": "為知筆記", "backend.services.wolai.unauthorizedErrorMessage": "授權失敗,請登錄網頁版 Wolai", "backend.services.flowus.unauthorizedErrorMessage": "授權失敗,請登錄網頁版 FlowUs", "backend.services.buildin.unauthorizedErrorMessage": "授權失敗,請登錄網頁版 Buildin.AI", "backend.services.youdao.name": "有道雲筆記", "backend.services.youdao.unauthorizedErrorMessage": "授權失敗,請登錄網頁版有道雲筆記。", "backend.services.yuque_oauth.name": "語雀(一鍵授權)", "backend.services.yuque.form.repositoryType": "知識庫類型", "backend.services.yuque.form.showAllRepository": "顯示全部知識庫", "backend.services.yuque.form.showGroupRepository": "顯示團隊的知識庫", "backend.services.yuque.form.showSelfRepository": "顯示自己的知識庫", "backend.services.yuque.headerForm.slug": "路徑", "backend.services.yuque.headerForm.slug_error": "只能輸入大小寫字母、橫線、下劃線和點,至少 2 個字符。", "backend.services.yuque.name": "語雀", "background.not_support_message": "暫時無法剪輯此類型的頁面。", "component.accountItem.delete": "刪除", "component.accountItem.edit": "編輯", "component.imagehostingListItem.delete": "刪除", "component.imagehostingListItem.edit": "編輯", "component.imagehostingListItem.noDescription": "沒有備注", "component.imageHostingSelectOption.noDescription": "沒有備注", "component.powerpackForm.expired": "加強包已過期", "component.powerpackForm.required": "請購買加強包", "contextMenus.selection.save.description": "", "contextMenus.selection.save.template": "", "contextMenus.selection.save.title": "", "extension.link.config.autoRunExclude": "", "extension.link.config.template": "", "hooks.useOriginForm.origin.message": "格式錯誤,示例 https://developer.mozilla.org", "page.complete.close": "關閉 Web Clipper", "page.complete.error": "发生錯誤", "page.complete.message": "前往 {name} 查看", "page.complete.share": "分享", "page.complete.success": "保存成功", "preference.account.add": "綁定賬戶", "preference.accountList.add": "添加", "preference.accountList.addAccount": "添加賬戶", "preference.accountList.confirm": "確認", "preference.accountList.defaultRepository": "默認知識庫", "preference.accountList.editAccount": "編輯賬戶", "preference.accountList.imageHost": "圖床", "preference.accountList.login": "授權登錄", "preference.accountList.type": "類型", "preference.accountList.verify": "校驗", "preference.accountList.verifying": "校驗中", "preference.basic.configLanguage.description": "歡迎在 {GitHub} 提交翻譯", "preference.basic.configLanguage.title": "語言", "preference.basic.iconColor.auto": "", "preference.basic.iconColor.dark": "", "preference.basic.iconColor.description": "", "preference.basic.iconColor.light": "", "preference.basic.iconColor.title": "", "preference.basic.liveRendering.description": "開啟後編輯器使用所見即所得模式", "preference.basic.liveRendering.title": "所見即所得", "preference.basic.update.button": "安裝更新", "preference.basic.update.description": "因為審核需要一周,所以 chrome 商店的版本會延遲幾個版本。", "preference.basic.update.title": "有更新", "preference.bind.message": "只有綁定賬戶後才能使用本插件", "preference.extensions.automaticOperationIsProhibited": "自動運行被禁止", "preference.extensions.CancelSetting": "取消設置", "preference.extensions.clipExtensions": "剪藏插件", "preference.extensions.clipExtensions.tooltip": "點擊 🌟選擇默認插件", "preference.extensions.ConfiguredAsDefaultExtension": "設置成默認擴展", "preference.extensions.contextMenus": "", "preference.extensions.form.reset": "恢复默认设置", "preference.extensions.install": "安裝", "preference.extensions.no.Description": "沒有描述", "preference.extensions.require.powerpack": "請購買加強包", "preference.extensions.require.update": "需要剪藏更新到 {version} 版本", "preference.extensions.runAutomaticOnSaving": "保存時自動運行", "preference.extensions.toolExtensions": "工具插件", "preference.extensions.Uninstall": "卸載", "preference.extensions.update": "更新", "preference.imageHosting.add": "添加圖床", "preference.imageHosting.edit": "編輯", "preference.imageHosting.remark": "備注", "preference.imageHosting.type": "類型", "preference.powerpack.activate": "購買加強包解鎖更多功能", "preference.powerpack.expiry": "過期時間", "preference.powerpack.failed": "獲取加強包信息失敗", "preference.powerpack.feature.coffee": "給我買杯咖啡", "preference.powerpack.feature.coffee.description": "讓開发者更有維護的動力", "preference.powerpack.feature.ocr": "OCR", "preference.powerpack.feature.ocr.description": "識別圖片中的文字", "preference.powerpack.feature.saveToEmail": "保存到郵箱", "preference.powerpack.feature.saveToEmail.description": "保存網頁到郵箱中查看", "preference.powerpack.feature.sendToKindle": "保存到 Kindle", "preference.powerpack.feature.sendToKindle.description": "保存網頁到 Kindle 中查看", "preference.powerpack.features": "功能", "preference.powerpack.free.trial": "免費試用7天", "preference.powerpack.login.github": "通過 Github 登錄", "preference.powerpack.login.google": "通過 Google 登錄", "preference.powerpack.logout": "登出", "preference.powerpack.reload": "刷新", "preference.powerpack.upgrade": "購買", "preference.tab.account": "賬戶", "preference.tab.basic": "基礎設置", "preference.tab.changelog": "更新日志", "preference.tab.extensions": "擴展設置", "preference.tab.imageHost": "圖床設置", "preference.tab.powerpack": "加強包", "preference.tab.privacy": "隱私協議", "tool.clipExtensions": "剪藏擴展", "tool.repository": "知識庫", "tool.save": "保存內容", "tool.saveButton.noRepository": "", "tool.title": "筆記標題", "tool.title.required": "請輸入筆記標題", "tool.toolExtensions": "工具擴展" } ================================================ FILE: src/common/locales/data/zh-TW.ts ================================================ import { LocaleModel } from '@/common/locales/interface'; import messages from './zh-TW.json'; const model: LocaleModel = { name: '繁體中文', locale: 'zh-TW', messages, alias: ['tw'], }; export default model; ================================================ FILE: src/common/locales/index.test.ts ================================================ import { removeEmptyKeys } from './interface'; it('test remove PR_IS_WELCOME', () => { const messages = { a: '1', b: '', c: '', }; expect(removeEmptyKeys(messages, { b: '2' })).toEqual({ a: '1', b: '2', }); }); ================================================ FILE: src/common/locales/index.ts ================================================ import { LOCAL_USER_PREFERENCE_LOCALE_KEY } from '@/common/modelTypes/userPreference'; import { createIntlCache, createIntl, IntlShape, MessageDescriptor } from 'react-intl'; import { LocaleModel, removeEmptyKeys } from './interface'; import { localStorageService } from '@/common/chrome/storage'; const context = require.context('./data', true, /\.[t|j]s$/); export const locales = context.keys().map(key => { const model = context(key).default as LocaleModel; const en = context('./en-US.ts').default as LocaleModel; return { ...model, messages: removeEmptyKeys(model.messages, en.messages), }; }); export const localesMap = locales.reduce((p, l) => { p.set(l.locale, l); return p; }, new Map()); export const getLanguage = () => { const language = navigator.language; for (const { locale, alias } of locales) { if (locale === language || alias.some(o => o === language)) { return locale; } } return language; }; class LocaleService { private intl?: IntlShape; private _locale?: string; async init() { const locale = localStorageService.get(LOCAL_USER_PREFERENCE_LOCALE_KEY, getLanguage()); const messages = (localesMap.get(locale) || localesMap.get('en-US'))!.messages; const cache = createIntlCache(); const intl = createIntl( { locale, messages: messages, }, cache ); this.intl = intl; this._locale = locale; } get locale() { return this._locale ?? getLanguage(); } format(descriptor: MessageDescriptor, values?: Record): string { if (!this.intl) { throw Error('Should init intl before use'); } return this.intl.formatMessage(descriptor, values); } getMessage(key: string): string { return this.intl?.messages[key].toString() ?? ''; } } export default new LocaleService(); ================================================ FILE: src/common/locales/interface.ts ================================================ export interface LocaleModel { name: string; locale: string; alias: string[]; messages: { [key: string]: string; }; } export function removeEmptyKeys( params: LocaleModel['messages'], defaultMessage: LocaleModel['messages'] ): LocaleModel['messages'] { const result: LocaleModel['messages'] = {}; Object.keys(params).forEach(key => { if (params[key] !== '') { result[key] = params[key]; } else { if (defaultMessage[key] !== '') { result[key] = defaultMessage[key]; } } }); return result; } ================================================ FILE: src/common/matchUrl.test.ts ================================================ /* eslint-disable no-loop-func */ import matchUrl from './matchUrl'; describe('test matchUrl', () => { describe('should match all', () => { const cases: { rule: string; true?: string[]; false?: string[] }[] = [ { rule: '*://*/*', true: ['http://www.google.com/', 'https://www.google.com/'], }, { rule: '*://docs.google.com/', true: ['https://docs.google.com/'], false: ['https://docs.google.com.cn/', 'https://sub.docs.google.com/'], }, { rule: '*://*.google.com/', true: ['https://www.google.com/', 'https://a.b.google.com/', 'https://google.com/'], false: ['https://www.google.com.hk/'], }, { rule: '*://www.google.tld/', true: ['https://www.google.com/', 'https://www.google.com.cn/', 'https://www.google.jp/'], false: ['https://www.google.example.com/'], }, { rule: 'https://www.google.com/a', true: [ 'https://www.google.com/a', 'https://www.google.com/a#hash', 'https://www.google.com/a?query', 'https://www.google.com/a?query#hash', ], }, ]; for (const iterator of cases) { it(`test rune ${iterator.rule}`, () => { if (Array.isArray(iterator.true)) { for (const url of iterator.true) { expect(matchUrl(iterator.rule, url)).toBeTruthy(); } } if (Array.isArray(iterator.false)) { for (const url of iterator.false) { expect(matchUrl(iterator.rule, url)).toBeFalsy(); } } }); } }); }); ================================================ FILE: src/common/matchUrl.ts ================================================ import * as tld from 'tldjs'; const RE_MATCH_PARTS = /(.*?):\/\/([^/]*)\/(.*)/; const RE_HTTP_OR_HTTPS = /^https?$/i; function str2RE(str: string): string { return str.replace(/([.?+[\]{}()|^$])/g, '\\$1').replace(/\*/g, '.*?'); } function matchScheme(rule: string, data: string) { if (rule === data) { return true; } if (['*', 'http*'].includes(rule) && RE_HTTP_OR_HTTPS.test(data)) { return 1; } return 0; } const RE_STR_ANY = '(?:|.*?\\.)'; const RE_STR_TLD = '((?:\\.\\w+)+)'; function hostMatcher(rule: string) { let prefix = ''; let base = rule; let suffix = ''; if (rule.startsWith('*.')) { base = base.slice(2); prefix = RE_STR_ANY; } if (rule.endsWith('.tld')) { base = base.slice(0, -4); suffix = RE_STR_TLD; } const re = new RegExp(`^${prefix}${str2RE(base)}${suffix}$`); return (data: string) => { if (rule === '*') { return true; } if (rule === data) { return true; } const matches = data.match(re); if (matches) { const [, tldStr] = matches; if (!tldStr) { return true; } const tldSuffix = tldStr.slice(1); return tld.getPublicSuffix(tldSuffix) === tldSuffix; } return 0; }; } function pathMatcher(rule: string) { const iHash = rule.indexOf('#'); let iQuery = rule.indexOf('?'); let strRe = str2RE(rule); if (iQuery > iHash) iQuery = -1; if (iHash < 0) { if (iQuery < 0) strRe = `^${strRe}(?:[?#]|$)`; else strRe = `^${strRe}(?:#|$)`; } const reRule = new RegExp(strRe); return (data: string) => reRule.test(data); } function matchTester(rule: string) { let test; if (rule === '') { test = () => true; } else { const ruleParts = rule.match(RE_MATCH_PARTS); if (ruleParts) { const matchHost = hostMatcher(ruleParts[2]); const matchPath = pathMatcher(ruleParts[3]); test = (url: string) => { const parts = url.match(RE_MATCH_PARTS); return ( !!ruleParts && !!parts && matchScheme(ruleParts[1], parts[1]) && matchHost(parts[2]) && matchPath(parts[3]) ); }; } else { test = () => false; } } return { test }; } export default (rule: string, url?: string) => { if (!url) { return false; } return matchTester(rule).test(url); }; ================================================ FILE: src/common/modelTypes/account.ts ================================================ export interface AccountPreference { [key: string]: string | undefined; id: string; type: string; name: string; avatar: string; homePage: string; description?: string; defaultRepositoryId?: string; imageHosting?: string; } export interface AccountStore { currentAccountId?: string; defaultAccountId?: string; accounts: AccountPreference[]; } ================================================ FILE: src/common/modelTypes/clipper.ts ================================================ import { ClipperDataType } from '@/common/modelTypes/userPreference'; import { Repository, CompleteStatus, CreateDocumentRequest, } from '@/common/backend/services/interface'; export interface ClipperHeaderForm { [key: string]: string | number; title: string; } export interface ClipperStore { clipperHeaderForm: ClipperHeaderForm; url?: string; currentAccountId: string; repositories: Repository[]; currentImageHostingService?: { type: string }; currentRepository?: Repository; clipperData: { [key: string]: ClipperDataType; }; completeStatus?: CompleteStatus; createDocumentRequest?: CreateDocumentRequest; } ================================================ FILE: src/common/modelTypes/extensions.ts ================================================ import { IExtensionWithId } from '@/extensions/common'; export interface ExtensionStore { extensions: IExtensionWithId[]; defaultExtensionId?: string | null; } export const LOCAL_EXTENSIONS_DISABLED_EXTENSIONS_KEY = 'local.extensions.disabled.extensions'; export const LOCAL_EXTENSIONS_ENABLE_AUTOMATIC_EXTENSIONS_KEY = 'local.extensions.enable.automatic.extensions'; ================================================ FILE: src/common/modelTypes/userPreference.ts ================================================ import { ServiceMeta, ImageHostingServiceMeta } from '@/common/backend'; export interface UserPreferenceStore { locale: string; imageHosting: ImageHosting[]; liveRendering: boolean; iconColor: 'dark' | 'light' | 'auto'; servicesMeta: { [type: string]: ServiceMeta; }; imageHostingServicesMeta: { [type: string]: ImageHostingServiceMeta; }; } /** * 图床配置的数据结构 */ export interface ImageHosting { id: string; type: string; remark?: string; info?: { [key: string]: string; }; } export interface ImageClipperData { dataUrl: string; width: number; height: number; } export type ClipperDataType = string | ImageClipperData; export const LOCAL_USER_PREFERENCE_LOCALE_KEY = 'local.userPreference.locale'; /** * user Access Tiken */ export const LOCAL_ACCESS_TOKEN_LOCALE_KEY = 'local.access.token.locale'; ================================================ FILE: src/common/object.ts ================================================ export function isUndefined(data: any) { // eslint-disable-next-line no-undefined return data === undefined; } ================================================ FILE: src/common/storage/__test__/index.spec.ts ================================================ import { PreferenceStorage, TypedCommonStorageInterface, CommonStorage } from './../interface'; /* eslint-disable max-nested-callbacks */ /* eslint-disable no-undefined */ import update from 'immutability-helper'; import { TypedCommonStorage } from '../typedCommonStorage'; class MockStorage implements CommonStorage { private data: any; constructor() { this.data = {}; } get = (key: string) => { return this.data[key]; }; set = (key: string, item: Object) => { this.data[key] = item; }; } describe('test storage', () => { let storage: TypedCommonStorageInterface; const defaultPreference: PreferenceStorage = { imageHosting: [], defaultPluginId: undefined, showLineNumber: true, liveRendering: true, iconColor: 'auto', }; beforeEach(() => { storage = new TypedCommonStorage(new MockStorage()); }); it('The default value should be correct', async () => { expect(await storage.getPreference()).toEqual(defaultPreference); expect(await storage.getDefaultPluginId()).toEqual(defaultPreference.defaultPluginId); expect(await storage.getShowLineNumber()).toEqual(defaultPreference.showLineNumber); expect(await storage.getLiveRendering()).toEqual(defaultPreference.liveRendering); }); it('setDefaultPluginId should work correctly', async () => { for (const value of ['11', '22', 'data', null]) { await storage.setDefaultPluginId(value); expect(await storage.getDefaultPluginId()).toBe(value); expect(await storage.getPreference()).toEqual( update(defaultPreference, { defaultPluginId: { $set: value, }, }) ); } }); it('setShowLineNumber should work correctly', async () => { for (const value of [true, false, true]) { await storage.setShowLineNumber(value); expect(await storage.getShowLineNumber()).toBe(value); expect(await storage.getPreference()).toEqual( update(defaultPreference, { showLineNumber: { $set: value, }, }) ); } }); it('setLiveRendering should work correctly', async () => { for (const value of [true, false, true]) { await storage.setLiveRendering(value); expect(await storage.getLiveRendering()).toBe(value); expect(await storage.getPreference()).toEqual( update(defaultPreference, { liveRendering: { $set: value, }, }) ); } }); }); ================================================ FILE: src/common/storage/index.ts ================================================ import { TypedCommonStorageInterface, CommonStorage } from './interface'; export * from './interface'; import * as browser from '@web-clipper/chrome-promise'; import { TypedCommonStorage } from './typedCommonStorage'; export class ChromeSyncStorageImpl implements CommonStorage { public async set(key: string, item: Object): Promise { let tempObject: any = {}; tempObject[key] = item; return browser.storage.sync.set(tempObject); } public async get(key: string): Promise { const items = await browser.storage.sync.get(key); return items[key]; } } export default new TypedCommonStorage(new ChromeSyncStorageImpl()) as TypedCommonStorageInterface; ================================================ FILE: src/common/storage/interface.ts ================================================ import { ImageHosting } from 'common/types'; export interface PreferenceStorage { imageHosting: ImageHosting[]; defaultPluginId?: string | null; showLineNumber: boolean; liveRendering: boolean; iconColor: 'dark' | 'light' | 'auto'; } export interface CommonStorage { set(key: string, value: any): void | Promise; get(key: string): Promise; } export interface TypedCommonStorageInterface { getPreference(): Promise; /** --------默认插件--------- */ setDefaultPluginId(id: string | null): Promise; getDefaultPluginId(): Promise; /** --------编辑器显示行号--------- */ setShowLineNumber(value: boolean): Promise; getShowLineNumber(): Promise; /** --------实时渲染--------- */ setLiveRendering(value: boolean): Promise; getLiveRendering(): Promise; setIconColor(value: string): Promise; getIconColor(): Promise; /** --------图床--------- */ addImageHosting(imageHosting: ImageHosting): Promise; getImageHosting(): Promise; deleteImageHostingById(id: string): Promise; editImageHostingById(id: string, value: ImageHosting): Promise; } ================================================ FILE: src/common/storage/typedCommonStorage.ts ================================================ import { ImageHosting } from '@/common/types'; import { TypedCommonStorageInterface, CommonStorage, PreferenceStorage } from './interface'; const keysOfStorage = { accounts: 'accounts', defaultAccountId: 'defaultAccountId', defaultPluginId: 'defaultPluginId', showQuickResponseCode: 'showQuickResponseCode', liveRendering: 'liveRendering', showLineNumber: 'showLineNumber', imageHosting: 'imageHosting', iconColor: 'iconColor', }; export class TypedCommonStorage implements TypedCommonStorageInterface { store: CommonStorage; constructor(store: CommonStorage) { this.store = store; } getPreference = async (): Promise => { const defaultPluginId = await this.getDefaultPluginId(); const showLineNumber = await this.getShowLineNumber(); const liveRendering = await this.getLiveRendering(); const imageHosting = await this.getImageHosting(); const iconColor = await this.getIconColor(); return { defaultPluginId, showLineNumber, liveRendering, imageHosting, iconColor, }; }; setDefaultPluginId = async (value: string | null) => { await this.store.set(keysOfStorage.defaultPluginId, value); }; getDefaultPluginId = async () => { return this.store.get(keysOfStorage.defaultPluginId); }; setShowLineNumber = async (value: boolean) => { await this.store.set(keysOfStorage.showLineNumber, value); }; getShowLineNumber = async () => { const value = await this.store.get(keysOfStorage.showLineNumber); return value !== false; }; setLiveRendering = async (value: boolean) => { await this.store.set(keysOfStorage.liveRendering, value); }; getLiveRendering = async () => { const value = await this.store.get(keysOfStorage.liveRendering); return value !== false; }; setIconColor = async (value: string) => { await this.store.set(keysOfStorage.iconColor, value); }; getIconColor = async () => { const value = await this.store.get<'dark' | 'light' | 'auto'>(keysOfStorage.iconColor); return value ?? 'auto'; }; addImageHosting = async (imageHosting: ImageHosting) => { const imageHostingList = await this.getImageHosting(); if (imageHostingList.some(o => o.id === imageHosting.id)) { throw new Error('Do not allow duplicate image hosting'); } imageHostingList.push(imageHosting); await this.store.set('imageHosting', imageHostingList); return imageHostingList; }; getImageHosting = async () => { const value = await this.store.get('imageHosting'); if (!value) { return []; } return value; }; deleteImageHostingById = async (id: string) => { const imageHostingList = await this.getImageHosting(); const newImageHostingList = imageHostingList.filter(imageHosting => imageHosting.id !== id); await this.store.set(keysOfStorage.imageHosting, newImageHostingList); return newImageHostingList; }; editImageHostingById = async (id: string, value: ImageHosting) => { const imageHostingList = await this.getImageHosting(); const index = imageHostingList.findIndex(imageHosting => imageHosting.id === id); if (index < 0) { throw new Error('图床不存在'); } imageHostingList[index] = value; await this.store.set('imageHosting', imageHostingList); return imageHostingList; }; } ================================================ FILE: src/common/strings.ts ================================================ /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ export const enum Constants { /** * MAX SMI (SMall Integer) as defined in v8. * one bit is lost for boxing/unboxing flag. * one bit is lost for sign flag. * See https://thibaultlaurens.github.io/javascript/2013/04/29/how-the-v8-engine-works/#tagged-values */ MAX_SAFE_SMALL_INTEGER = 1 << 30, /** * MIN SMI (SMall Integer) as defined in v8. * one bit is lost for boxing/unboxing flag. * one bit is lost for sign flag. * See https://thibaultlaurens.github.io/javascript/2013/04/29/how-the-v8-engine-works/#tagged-values */ MIN_SAFE_SMALL_INTEGER = -(1 << 30), /** * Max unsigned integer that fits on 8 bits. */ MAX_UINT_8 = 255, // 2^8 - 1 /** * Max unsigned integer that fits on 16 bits. */ MAX_UINT_16 = 65535, // 2^16 - 1 /** * Max unsigned integer that fits on 32 bits. */ MAX_UINT_32 = 4294967295, // 2^32 - 1 UNICODE_SUPPLEMENTARY_PLANE_BEGIN = 0x010000, } /** * A manual encoding of `str` to UTF8. * Use only in environments which do not offer native conversion methods! */ export function encodeUTF8(str: string): Uint8Array { const strLen = str.length; // See https://en.wikipedia.org/wiki/UTF-8 // first loop to establish needed buffer size let neededSize = 0; let strOffset = 0; while (strOffset < strLen) { const codePoint = getNextCodePoint(str, strLen, strOffset); strOffset += codePoint >= Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN ? 2 : 1; if (codePoint < 0x0080) { neededSize += 1; } else if (codePoint < 0x0800) { neededSize += 2; } else if (codePoint < 0x10000) { neededSize += 3; } else { neededSize += 4; } } // second loop to actually encode const arr = new Uint8Array(neededSize); strOffset = 0; let arrOffset = 0; while (strOffset < strLen) { const codePoint = getNextCodePoint(str, strLen, strOffset); strOffset += codePoint >= Constants.UNICODE_SUPPLEMENTARY_PLANE_BEGIN ? 2 : 1; if (codePoint < 0x0080) { arr[arrOffset++] = codePoint; } else if (codePoint < 0x0800) { arr[arrOffset++] = 0b11000000 | ((codePoint & 0b00000000000000000000011111000000) >>> 6); arr[arrOffset++] = 0b10000000 | ((codePoint & 0b00000000000000000000000000111111) >>> 0); } else if (codePoint < 0x10000) { arr[arrOffset++] = 0b11100000 | ((codePoint & 0b00000000000000001111000000000000) >>> 12); arr[arrOffset++] = 0b10000000 | ((codePoint & 0b00000000000000000000111111000000) >>> 6); arr[arrOffset++] = 0b10000000 | ((codePoint & 0b00000000000000000000000000111111) >>> 0); } else { arr[arrOffset++] = 0b11110000 | ((codePoint & 0b00000000000111000000000000000000) >>> 18); arr[arrOffset++] = 0b10000000 | ((codePoint & 0b00000000000000111111000000000000) >>> 12); arr[arrOffset++] = 0b10000000 | ((codePoint & 0b00000000000000000000111111000000) >>> 6); arr[arrOffset++] = 0b10000000 | ((codePoint & 0b00000000000000000000000000111111) >>> 0); } } return arr; } /** * A manual decoding of a UTF8 string. * Use only in environments which do not offer native conversion methods! */ export function decodeUTF8(buffer: Uint8Array): string { // https://en.wikipedia.org/wiki/UTF-8 const len = buffer.byteLength; const result: string[] = []; let offset = 0; while (offset < len) { const v0 = buffer[offset]; let codePoint: number; if (v0 >= 0b11110000 && offset + 3 < len) { // 4 bytes codePoint = (((buffer[offset++] & 0b00000111) << 18) >>> 0) | (((buffer[offset++] & 0b00111111) << 12) >>> 0) | (((buffer[offset++] & 0b00111111) << 6) >>> 0) | (((buffer[offset++] & 0b00111111) << 0) >>> 0); } else if (v0 >= 0b11100000 && offset + 2 < len) { // 3 bytes codePoint = (((buffer[offset++] & 0b00001111) << 12) >>> 0) | (((buffer[offset++] & 0b00111111) << 6) >>> 0) | (((buffer[offset++] & 0b00111111) << 0) >>> 0); } else if (v0 >= 0b11000000 && offset + 1 < len) { // 2 bytes codePoint = (((buffer[offset++] & 0b00011111) << 6) >>> 0) | (((buffer[offset++] & 0b00111111) << 0) >>> 0); } else { // 1 byte codePoint = buffer[offset++]; } if ((codePoint >= 0 && codePoint <= 0xd7ff) || (codePoint >= 0xe000 && codePoint <= 0xffff)) { // Basic Multilingual Plane result.push(String.fromCharCode(codePoint)); } else if (codePoint >= 0x010000 && codePoint <= 0x10ffff) { // Supplementary Planes const uPrime = codePoint - 0x10000; const w1 = 0xd800 + ((uPrime & 0b11111111110000000000) >>> 10); const w2 = 0xdc00 + ((uPrime & 0b00000000001111111111) >>> 0); result.push(String.fromCharCode(w1)); result.push(String.fromCharCode(w2)); } else { // illegal code point result.push(String.fromCharCode(0xfffd)); } } return result.join(''); } export function getNextCodePoint(str: string, len: number, offset: number): number { const charCode = str.charCodeAt(offset); if (isHighSurrogate(charCode) && offset + 1 < len) { const nextCharCode = str.charCodeAt(offset + 1); if (isLowSurrogate(nextCharCode)) { return computeCodePoint(charCode, nextCharCode); } } return charCode; } /** * See http://en.wikipedia.org/wiki/Surrogate_pair */ export function isHighSurrogate(charCode: number): boolean { return 0xd800 <= charCode && charCode <= 0xdbff; } /** * See http://en.wikipedia.org/wiki/Surrogate_pair */ export function isLowSurrogate(charCode: number): boolean { return 0xdc00 <= charCode && charCode <= 0xdfff; } /** * See http://en.wikipedia.org/wiki/Surrogate_pair */ export function computeCodePoint(highSurrogate: number, lowSurrogate: number): number { return ((highSurrogate - 0xd800) << 10) + (lowSurrogate - 0xdc00) + 0x10000; } ================================================ FILE: src/common/types.ts ================================================ import { AccountStore } from './modelTypes/account'; import { RouteComponentProps } from 'react-router'; import { Dispatch } from 'react'; import { UserPreferenceStore } from '@/common/modelTypes/userPreference'; import { ClipperStore } from '@/common/modelTypes/clipper'; import { DvaLoadingState } from 'dva-loading'; import { ExtensionStore } from './modelTypes/extensions'; export * from '@/common/modelTypes/userPreference'; export * from '@/common/modelTypes/clipper'; export * from '@/common/modelTypes/account'; export type DvaRouterProps = { dispatch: Dispatch; } & RouteComponentProps; interface DvaLoadingState { global: boolean; models: { [type: string]: boolean | undefined }; effects: { [type: string]: boolean | undefined }; } export interface GlobalStore { account: AccountStore; clipper: ClipperStore; userPreference: UserPreferenceStore; loading: DvaLoadingState; extension: ExtensionStore; router: { location: { search: string; pathname: string; }; }; } export interface IResponse { result: T; message: string; } ================================================ FILE: src/common/version/index.test.ts ================================================ import { hasUpdate } from './index'; describe('test version', function() { it('test hasUpdate', function() { expect(hasUpdate('3.0.1', '3.0.0')).toBe(true); expect(hasUpdate('3.1.1', '3.0.1')).toBe(true); expect(hasUpdate('3.0.0', '2.0.0')).toBe(true); expect(hasUpdate('3.0.0', '3.0.0')).toBe(false); expect(hasUpdate('3.0.0', '4.0.0')).toBe(false); }); }); ================================================ FILE: src/common/version/index.ts ================================================ export function hasUpdate(remote: string, local: string): boolean { if (!remote) { return false; } const remoteVersion = remote.split('.').map(version => parseInt(version, 10)); const localVersion = local.split('.').map(version => parseInt(version, 10)); for (let i = 0; i < remoteVersion.length; i++) { if (remoteVersion[i] > localVersion[i]) { return true; } if (remoteVersion[i] < localVersion[i]) { return false; } } return false; } ================================================ FILE: src/components/ExtensionCard/index.less ================================================ :global { .ant-form-item { margin-bottom: 8px; } } ================================================ FILE: src/components/ExtensionCard/index.tsx ================================================ import React from 'react'; import { Button, Card, Modal, Select } from 'antd'; import { FormattedMessage } from 'react-intl'; import { SerializedExtensionInfo } from '@/extensions/common'; import IconFont from '@/components/IconFont'; import { SettingOutlined } from '@ant-design/icons'; import { Form, FormItem, Input as FormInput } from '@formily/antd'; import { createForm, onFormValuesChange } from '@formily/core'; import { createSchemaField } from '@formily/react'; import { toJS } from 'mobx'; import Container from 'typedi'; import { IExtensionContainer, IExtensionService } from '@/service/common/extension'; import useFilterExtensions from '@/common/hooks/useFilterExtensions'; import './index.less'; import localeService from '@/common/locales/index'; interface ExtensionCardProps { manifest: SerializedExtensionInfo['manifest']; actions?: React.ReactNode[]; className?: string; } const ExtensionSelect: React.FC<{ value: string; onChange: any }> = ({ value, onChange }) => { const extensionContainer = Container.get(IExtensionContainer); const [, clipExtensions] = useFilterExtensions(extensionContainer.extensions); return ( ); }; const SchemaField = createSchemaField({ components: { FormItem, textarea: FormInput.TextArea!, clipExtensionsSelect: ExtensionSelect, }, }); const ReachableContext = React.createContext<{ manifest: SerializedExtensionInfo['manifest'] | null; // eslint-disable-next-line no-undefined }>({ manifest: null }); const config = () => { return { width: 800, content: ( <> {({ manifest }) => { const config = manifest!.config; const extensionId: string = manifest!.extensionId as string; const defaultValue = Container.get(IExtensionService).getExtensionConfig(extensionId) || toJS(config?.default); const normalForm = createForm({ validateFirst: true, initialValues: defaultValue as any, effects: () => { onFormValuesChange(form => { if (form.mounted) { Container.get(IExtensionService).setExtensionConfig(extensionId, form.values); } }); }, }); return (
); }}
), }; }; const ExtensionCard: React.FC = ({ manifest, actions, className }) => { const extra: React.ReactNode[] = [manifest.version]; const [modal, contextHolder] = Modal.useModal(); if (manifest.config) { extra.push( { modal.info(config()); }} /> ); } return ( {contextHolder} } title={manifest.name} />} >
{manifest.description || }
); }; export default ExtensionCard; ================================================ FILE: src/components/IconFont.tsx ================================================ import React from 'react'; import { Icon as LegacyIcon } from '@ant-design/compatible'; import { IconProps } from '@ant-design/compatible/es/icon'; import { createFromIconfontCN } from '@ant-design/icons'; import Container from 'typedi'; import { IConfigService } from '@/service/common/config'; import { Observer, useObserver } from 'mobx-react'; const IconFont: React.FC = (props) => { const configService = Container.get(IConfigService); const IconFont = useObserver(() => { return createFromIconfontCN({ scriptUrl: './icon.js' }); }); return ( {() => { if (!configService.remoteIconSet.has(props.type)) { return ; } if (!props.type) { throw new Error('Type is required'); } return ; }} ); }; export default IconFont; ================================================ FILE: src/components/ImageHostingSelect.less ================================================ .imageHostingSelect { :global { .ant-select-selector { height: 72px !important; } } } ================================================ FILE: src/components/ImageHostingSelect.tsx ================================================ import { ImageHostingWithMeta } from '@/common/hooks/useFilterImageHostingServices'; import Select, { SelectProps } from 'antd/lib/select'; import React, { forwardRef } from 'react'; import ImageHostingSelectOption from '@/components/imageHostingSelectOption'; import styles from './ImageHostingSelect.less'; interface ImageHostingSelectProps extends SelectProps { supportedImageHostingServices: ImageHostingWithMeta[]; } /** * TODO * fix any */ export const ImageHostingSelect: React.ForwardRefRenderFunction = ( { supportedImageHostingServices, ...props }, ref ) => ( ); /** * TODO * fix any */ export default forwardRef(ImageHostingSelect); ================================================ FILE: src/components/LinkRender/index.tsx ================================================ import React from 'react'; interface LinkRenderProps { href: string; } const LinkRender: React.FC = props => { return ( {props.children} ); }; export default LinkRender; ================================================ FILE: src/components/RepositorySelect.tsx ================================================ import React, { useMemo, useState, forwardRef, useCallback } from 'react'; import { Select } from 'antd'; import { Repository } from 'common/backend'; import { SelectProps } from 'antd/lib/select'; import { debounce } from 'lodash'; interface RepositoryInGroup { [groupId: string]: { groupId: string; groupName: string; repositories: Repository[]; }; } interface RepositorySelectProps extends SelectProps { repositories: Repository[]; } const RepositorySelect: React.FC = ({ repositories, ...props }, ref) => { const [searchKey, _setSearchKey] = useState(); // eslint-disable-next-line react-hooks/exhaustive-deps const setSearchKey = useCallback(debounce(_setSearchKey, repositories.length > 100 ? 500 : 0), [ _setSearchKey, repositories, ]); const repositoryInGroup = useMemo(() => { const repositoryInGroup: RepositoryInGroup = {}; repositories.forEach(o => { if (searchKey) { if (!o.name.toLocaleLowerCase().includes(searchKey.toLocaleLowerCase())) { return; } } let group = repositoryInGroup[o.groupId]; if (group) { group.repositories.push(o); } else { repositoryInGroup[o.groupId] = { groupId: o.groupId, groupName: o.groupName, repositories: [o], }; } }); return repositoryInGroup; }, [repositories, searchKey]); return ( ); }; export default (forwardRef(RepositorySelect as any) as unknown) as typeof RepositorySelect; ================================================ FILE: src/components/accountItem/index.less ================================================ .card { padding: 10px; border-radius: 10px; text-align: center; line-height: 1.5; font-size: 14px; border: 1px solid #e8e8e8; height: 300px; position: relative; display: flex; flex-direction: column; } .star { position: absolute; top: 10px; right: 10px; font-size: 20px; } .userInfo { flex: 1; .name { margin-bottom: 4px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; font-size: 20px; color: #262626; } .description { color: #8c8c8c; margin-bottom: 8px; height: 63px; overflow: hidden; word-wrap: break-word; } } .operation { display: flex; .editButton { flex: 1; margin-right: 10px; } } ================================================ FILE: src/components/accountItem/index.tsx ================================================ import * as React from 'react'; import { StarOutlined } from '@ant-design/icons'; import { Button } from 'antd'; import styles from './index.less'; import { FormattedMessage } from 'react-intl'; import IconAvatar from '@/components/avatar'; interface PageProps { isDefault: boolean; id: string; name: string; avatar: string; description?: string; onEdit(id: string): void; onDelete(id: string): void; onSetDefaultAccount(id: string): void; } export default class Page extends React.Component { handleEdit = () => { this.props.onEdit(this.props.id); }; handleDelete = () => { this.props.onDelete(this.props.id); }; handleSetDefaultAccount = () => { this.props.onSetDefaultAccount(this.props.id); }; render() { const { name, description, avatar, isDefault } = this.props; let tagStyle; if (isDefault) { tagStyle = { color: 'red' }; } return (
{name}
{description}
); } } ================================================ FILE: src/components/avatar/index.tsx ================================================ import React from 'react'; import { Avatar } from 'antd'; import IconFont from '@/components/IconFont'; interface IconAvatarProps { avatar?: string; icon: string; size: 'large' | 'small' | number; } const IconAvatar: React.FC = ({ avatar, size, icon: _icon }) => { const icon = avatar || _icon; let fontSize; if (typeof size === 'string') { fontSize = { small: 24, large: 40, }[size]; } else { fontSize = size; } if (icon.startsWith('http') || icon.indexOf('/') != -1) { return ; } return ; }; export default IconAvatar; ================================================ FILE: src/components/container/index.less ================================================ @import '~antd/es//style/themes/default.less'; .mainContainer { position: fixed; right: 10px; top: 10px; box-shadow: rgba(0, 0, 0, 0.2) 0px 0px 12px 0px; } .toolContainer { width: 324px; height: auto; background: #fff; padding: 10px; } .closeButton { position: absolute; right: 10px; top: 10px; cursor: pointer; } .centerContainer { display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; } .editorContainer { position: absolute; right: 350px; top: 0; box-shadow: rgba(0, 0, 0, 0.2) 0px 0px 12px 0px; border: 2px solid #dddddd; } .mask { position: fixed; right: 0px; top: 0px; bottom: 0px; left: 0px; background: @modal-mask-bg; } ================================================ FILE: src/components/container/index.tsx ================================================ import React from 'react'; import styles from './index.less'; import { CloseOutlined } from '@ant-design/icons'; const Container: React.FC = ({ children }) => { return
{children}
; }; export interface ToolContainerProps { onClickCloseButton?: () => void; onClickMask?: () => void; } export class ToolContainer extends React.Component { onClickCloseButton = () => { if (this.props.onClickCloseButton) { this.props.onClickCloseButton(); } }; handleClickMask = () => { if (this.props.onClickMask) { this.props.onClickMask(); } }; public render() { return (
{
{this.props.children}
}
); } } export const CenterContainer: React.FC = ({ children }) => { return
{children}
; }; export const EditorContainer: React.FC = ({ children }) => { return
{children}
; }; ================================================ FILE: src/components/imageHostingSelectOption/index.less ================================================ .avatar { img { max-width: 32px; max-height: 32px; height: auto; } } ================================================ FILE: src/components/imageHostingSelectOption/index.tsx ================================================ import * as React from 'react'; import { List, Avatar } from 'antd'; import styles from './index.less'; import { FormattedMessage } from 'react-intl'; import IconFont from '@/components/IconFont'; interface PageProps { icon: string; name: string; remark?: string; id: string; } export default class Page extends React.Component { render() { const { name, remark = ( ), icon, } = this.props; let avatar; if (icon.startsWith('http') || icon.indexOf('/') != -1) { avatar = ; } else { avatar = ; } return ( {name}} description={remark} /> ); } } ================================================ FILE: src/components/imagehostingListItem/index.less ================================================ .avatar { img { max-width: 32px; max-height: 32px; height: auto; } } ================================================ FILE: src/components/imagehostingListItem/index.tsx ================================================ import * as React from 'react'; import { List, Avatar } from 'antd'; import styles from './index.less'; import { FormattedMessage } from 'react-intl'; import IconFont from '@/components/IconFont'; interface PageProps { icon: string; name: string; remark?: string; id: string; onEditAccount: (id: string) => void; onDeleteAccount: (id: string) => void; } export default class Page extends React.Component { handleEditAccount = () => { const { onEditAccount, id } = this.props; if (onEditAccount) { onEditAccount(id); } }; handleDeleteAccount = () => { const { onDeleteAccount, id } = this.props; if (onDeleteAccount) { onDeleteAccount(id); } }; render() { const { name, remark = ( ), icon, } = this.props; let avatar; if (icon.startsWith('http') || icon.indexOf('/') != -1) { avatar = ; } else { avatar = ; } const actions = [ , , ]; return ( {name}} description={remark} /> ); } } ================================================ FILE: src/components/section/__snapshots__/index.test.tsx.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`test Section should render correct 1`] = ` initialize { "0": Node { "attribs": Object {}, "children": Array [ Node { "attribs": Object { "class": "sectionTitle", }, "children": Array [ Node { "data": "test", "next": null, "parent": [Circular], "prev": null, "type": "text", }, ], "name": "h1", "namespace": "http://www.w3.org/1999/xhtml", "next": Node { "attribs": Object {}, "children": Array [ Node { "data": "test", "next": null, "parent": [Circular], "prev": null, "type": "text", }, ], "name": "div", "namespace": "http://www.w3.org/1999/xhtml", "next": null, "parent": [Circular], "prev": [Circular], "type": "tag", "x-attribsNamespace": Object {}, "x-attribsPrefix": Object {}, }, "parent": [Circular], "prev": null, "type": "tag", "x-attribsNamespace": Object { "class": undefined, }, "x-attribsPrefix": Object { "class": undefined, }, }, Node { "attribs": Object {}, "children": Array [ Node { "data": "test", "next": null, "parent": [Circular], "prev": null, "type": "text", }, ], "name": "div", "namespace": "http://www.w3.org/1999/xhtml", "next": null, "parent": [Circular], "prev": Node { "attribs": Object { "class": "sectionTitle", }, "children": Array [ Node { "data": "test", "next": null, "parent": [Circular], "prev": null, "type": "text", }, ], "name": "h1", "namespace": "http://www.w3.org/1999/xhtml", "next": [Circular], "parent": [Circular], "prev": null, "type": "tag", "x-attribsNamespace": Object { "class": undefined, }, "x-attribsPrefix": Object { "class": undefined, }, }, "type": "tag", "x-attribsNamespace": Object {}, "x-attribsPrefix": Object {}, }, ], "name": "div", "namespace": "http://www.w3.org/1999/xhtml", "next": null, "parent": Node { "children": Array [ [Circular], ], "name": "root", "next": null, "parent": null, "prev": null, "type": "root", }, "prev": null, "type": "tag", "x-attribsNamespace": Object {}, "x-attribsPrefix": Object {}, }, "_root": [Circular], "length": 1, "options": Object { "decodeEntities": true, "xml": false, }, } `; ================================================ FILE: src/components/section/index.less ================================================ .sectionTitle { color: rgba(0, 0, 0, 0.45); line-height: 1.5; font-size: 14px; margin: 0 0 8px 0; } ================================================ FILE: src/components/section/index.tsx ================================================ import React from 'react'; import styles from './index.less'; interface Props { title?: string | React.ReactNode; className?: string; } const Section: React.FC = ({ title, children, className }) => { return (
{title &&

{title}

} {children}
); }; export default Section; ================================================ FILE: src/components/share/index.tsx ================================================ import React from 'react'; import IconFont from '@/components/IconFont'; interface ShareProps { content: string; } const Share: React.FC = ({ content: originContent }) => { const content = encodeURIComponent(originContent.slice(0, 200)); const url = encodeURIComponent('https://clipper.website'); const twitterHref = `https://twitter.com/intent/tweet?via=yuanfandi&text=${content}&url=${url}`; const weiboHref = `https://service.weibo.com/share/share.php?url=${url}&title=${content}&display=0`; const doubanHref = `https://shuo.douban.com/!service/share?href=${url}&text=${content}`; return ( ); }; export default Share; ================================================ FILE: src/components/userItem/index.less ================================================ .userItem { display: flex; align-items: center; color: #262626; .userItemInfo { margin-left: 8px; align-self: flex-start; color: #595959; } .description { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #8c8c8c; } } ================================================ FILE: src/components/userItem/index.tsx ================================================ import React from 'react'; import styles from './index.less'; import IconAvatar from '@/components/avatar'; interface UserItemProps { avatar: string; icon: string; name: string; description?: string; } const UserItem: React.FC = props => (
{props.name}
{props.description}
); export default UserItem; ================================================ FILE: src/config.ts ================================================ interface WebClipperConfig { icon: string; iconDark: string; yuqueClientId: string; yuqueCallback: string; yuqueScope: string; oneNoteCallBack: string; oneNoteClientId: string; } export interface RemoteConfig { iconfont: string; chromeWebStoreVersion: string; } let config: WebClipperConfig = { icon: 'icons/icon.png', iconDark: 'icons/icon-dark.png', yuqueClientId: 'D1AwzCeDPLFWGfcGv7ze', yuqueCallback: 'http://webclipper-oauth.yfd.im/yuque_oauth', yuqueScope: 'doc,group,repo,attach_upload', oneNoteClientId: '563571ad-cfcd-442a-aa34-046bad24b1b6', oneNoteCallBack: 'https://webclipper-oauth.yfd.im/onenote_oauth', }; if (process.env.NODE_ENV === 'development') { config = Object.assign({}, config, { icon: 'icons/icon-dev.png', }); } export default config; ================================================ FILE: src/extensions/common.ts ================================================ import TurndownService from 'turndown'; import { IHighlighter } from '@web-clipper/highlight'; import { IAreaSelector } from '@web-clipper/area-selector'; import * as antd from 'antd'; import React from 'react'; import { IContentScriptService } from '@/service/common/contentScript'; import { IContextMenuExtension } from './contextMenus'; import { ISchema } from '@formily/react'; export interface InitContext { accountInfo: { type?: string; }; url?: string; locale: string; pathname: string; currentImageHostingService?: { type: string; }; } export interface ContentScriptContext { $: JQueryStatic; locale: string; turndown: TurndownService; Highlighter: Type; AreaSelector: Type; toggleClipper: () => void; toggleLoading: () => void; Readability: any; document: Document; QRCode: any; } export interface Message { info(content: string): void; } export interface UploadImageRequest { data: string; } export interface ImageHostingService { getId(): string; uploadImage(request: UploadImageRequest): Promise; uploadImageUrl(url: string): Promise; } interface CopyToClipboardOptions { debug?: boolean; message?: string; format?: string; // MIME type } export interface ToolContext { locale: string; result: T; data: Out; message: Message; config?: any; imageService?: ImageHostingService; loadImage: any; captureVisibleTab: any; copyToClipboard: (text: string, options?: CopyToClipboardOptions) => void; createAndDownloadFile: (fileName: string, content: string | Blob) => void; pangu: (content: string) => Promise; antd: typeof antd; React: typeof React; } export interface IExtensionLifeCycle { /** * 插件被加载之前 */ init?(context: InitContext): boolean; /** * 执行插件 */ run?(context: ContentScriptContext): Promise | T; /** * 执行插件后 */ afterRun?(context: ToolContext): Promise | U; /** * 清理环境 */ destroy?(context: ContentScriptContext): void; } export interface IExtensionManifest { readonly extensionId?: string; readonly name: string; readonly version: string; readonly description?: string; readonly icon?: string; readonly matches?: string[]; readonly apiVersion?: string; readonly powerpack?: boolean; readonly keywords?: string[]; readonly automatic?: boolean; readonly config?: { scheme: ISchema; default: { [key: string]: string | string[] }; }; readonly i18nManifest?: { [key: string]: { readonly name?: string; readonly description?: string; readonly icon?: string; readonly keywords?: string[]; }; }; } export enum ExtensionType { Text = 'Text', Image = 'Image', Tool = 'tool', } export interface SerializedExtension { type: ExtensionType; manifest: IExtensionManifest; init?: string; run?: string; afterRun?: string; destroy?: string; } export interface SerializedExtensionWithId extends SerializedExtension { id: string; router: string; embedded: boolean; } export type SerializedExtensionInfo = Pick; interface Type extends Function { new (...args: any[]): T; } export interface IExtension { readonly type: ExtensionType; readonly manifest: IExtensionManifest; readonly extensionLifeCycle: IExtensionLifeCycle; } export interface IExtensionWithId { readonly id: string; readonly router: string; readonly type: ExtensionType; readonly factory?: any; readonly manifest: IExtensionManifest; readonly extensionLifeCycle: IExtensionLifeCycle; } class AbstractExtension { public readonly type: ExtensionType; public readonly manifest: IExtensionManifest; public readonly extensionLifeCycle: IExtensionLifeCycle; constructor( type: ExtensionType, manifest: IExtensionManifest, extensionLifeCycle: IExtensionLifeCycle ) { this.type = type; this.manifest = manifest; this.extensionLifeCycle = extensionLifeCycle; } } export class TextExtension extends AbstractExtension { constructor(manifest: IExtensionManifest, methods: IExtensionLifeCycle) { super(ExtensionType.Text, manifest, methods); } } export class ToolExtension extends AbstractExtension { constructor(manifest: IExtensionManifest, methods: IExtensionLifeCycle) { super(ExtensionType.Tool, manifest, methods); } } export interface IContextMenuContext { contentScriptService: IContentScriptService; initContentScriptService(id: number): Promise; } export interface IContextMenuExtensionFactory { id: string; new (): IContextMenuExtension; } export interface IContextMenusWithId { id: string; contextMenu: IContextMenuExtensionFactory; } export function getLocaleExtensionManifest(manifest: IExtensionManifest, locale: string) { const { i18nManifest, ...rest } = manifest; let localeManifest = {}; if (i18nManifest && typeof i18nManifest === 'object') { localeManifest = i18nManifest[locale]; } return { ...rest, ...localeManifest, }; } ================================================ FILE: src/extensions/contextMenus/saveSelection/saveSelection.ts ================================================ import { ContextMenuExtension, IContextMenuContext } from '../../contextMenus'; import localeService from '@/common/locales'; import { stringify } from 'qs'; class ContextMenu extends ContextMenuExtension { static id = 'contextMenus.selection.save'; constructor() { super({ extensionId: 'contextMenus.selection.save', name: `${localeService.format({ id: 'contextMenus.selection.save.title', })} (Alt+S)`, description: localeService.format({ id: 'contextMenus.selection.save.description', }), config: { scheme: { type: 'object', properties: { template: { type: 'string', title: localeService.format({ id: 'extension.link.config.template', }), 'x-decorator': 'FormItem', 'x-component': 'textarea', 'x-component-props': { autoSize: true }, }, }, }, default: { template: localeService.getMessage('contextMenus.selection.save.template'), }, }, version: '0.0.1', contexts: ['selection'], }); } async run(tab: chrome.tabs.Tab, context: IContextMenuContext): Promise { // await context.initContentScriptService(tab.id!); const content = await context.contentScriptService.getSelectionMarkdown(); const config = (await context.config!) as { template: string }; const note = localeService.format( { id: 'not_exist', defaultMessage: config.template, }, { CONTENT: content, URL: await context.contentScriptService.getPageUrl(), TITLE: tab.title } ); context.contentScriptService.toggle({ pathname: '/editor', query: stringify({ markdown: note }), }); } } export default ContextMenu; ================================================ FILE: src/extensions/contextMenus.ts ================================================ import { IContentScriptService } from '@/service/common/contentScript'; import { IExtensionManifest } from './common'; export interface IContextMenuProperties { id: string; title: string; contexts: string[]; } interface IContextMenuExtensionManifest extends IExtensionManifest { contexts?: string[]; } export interface IContextMenuExtension { readonly manifest: IContextMenuExtensionManifest; run(id: chrome.tabs.Tab, context: IContextMenuContext): Promise; } export interface IContextMenuContext { config: unknown; contentScriptService: IContentScriptService; // initContentScriptService(id: number): Promise; } export abstract class ContextMenuExtension implements IContextMenuExtension { constructor(public manifest: IContextMenuExtensionManifest) {} abstract run(id: chrome.tabs.Tab, context: IContextMenuContext): Promise; } export interface IContextMenuExtensionFactory { id: string; new (): IContextMenuExtension; } export interface IContextMenusWithId { id: string; contextMenu: IContextMenuExtensionFactory; } ================================================ FILE: src/extensions/extensions/bookmark.ts ================================================ import { TextExtension } from '@/extensions/common'; export default new TextExtension( { name: 'Bookmark', version: '0.0.1', description: 'Add bookmark.', icon: 'link', i18nManifest: { 'de-DE': { name: 'Lesezeichen', description: 'Lesezeichen hinzufügen.' }, 'en-US': { name: 'Bookmark', description: 'Add bookmark.' }, 'ja-JP': { name: 'ブックマーク', description: 'ブックマークを追加します。' }, 'ko-KR': { name: '북마크', description: '북마크 추가.' }, 'ru-RU': { name: 'Закладка', description: 'Добавить закладку.' }, 'zh-CN': { name: '书签', description: '添加书签' }, }, }, { run: async (context) => { const { document, locale } = context; switch (locale) { case 'zh-CN': { return `## 链接 \n [${document.URL}](${document.URL}) \n ## 备注:`; } default: return `## Link \n [${document.URL}](${document.URL}) \n ## Comment:`; } }, } ); ================================================ FILE: src/extensions/extensions/extensions/remove.ts ================================================ import { ToolExtension } from '@/extensions/common'; export default new ToolExtension( { name: 'Delete Element', icon: 'delete', version: '0.0.1', description: 'Delete selected page elements.', i18nManifest: { 'de-DE': { name: 'Element löschen', description: 'Ausgewählte Seitenelemente löschen.' }, 'en-US': { name: 'Delete Element', description: 'Delete selected page elements.' }, 'ja-JP': { name: '要素を削除', description: '選択したページ要素を削除します。' }, 'ko-KR': { name: '요소 삭제', description: '선택한 페이지 요소를 삭제합니다.' }, 'ru-RU': { name: 'Удалить элемент', description: 'Удалить выбранные элементы страницы.' }, 'zh-CN': { name: '删除元素', description: '删除选择的页面元素。' }, 'zh-TW': { name: '刪除元素', description: '刪除選擇的頁面元素。' }, }, }, { run: async context => { const { $, Highlighter, toggleClipper } = context; toggleClipper(); const data = await new Highlighter().start(); $(data).remove(); toggleClipper(); }, } ); ================================================ FILE: src/extensions/extensions/extensions/selectTool.ts ================================================ import { ToolExtension } from '@/extensions/common'; export default new ToolExtension( { name: 'Manual selection', icon: 'select', version: '0.0.1', description: 'Manual selection page element.', i18nManifest: { 'de-DE': { name: 'Manuelle Auswahl', description: 'Manuelle Auswahl von Seitenelementen.' }, 'en-US': { name: 'Manual selection', description: 'Manual selection of page elements.' }, 'ja-JP': { name: '手動選択', description: 'ページ要素を手動で選択します。' }, 'ko-KR': { name: '수동 선택', description: '페이지 요소를 수동으로 선택합니다.' }, 'ru-RU': { name: 'Ручной выбор', description: 'Ручной выбор элементов страницы.' }, 'zh-CN': { name: '手动选取', description: '手动选取页面中的元素' }, }, }, { init: ({ pathname }) => { if (pathname === '/') { return false; } return true; }, run: async context => { const { turndown, Highlighter, toggleClipper } = context; toggleClipper(); try { const data = await new Highlighter().start(); return turndown.turndown(data); } catch (error) { throw error; } finally { toggleClipper(); } }, afterRun: context => { const { result, data } = context; return `${data}\n${result}`; }, } ); ================================================ FILE: src/extensions/extensions/extensions/uploadImage.ts ================================================ import { ToolExtension } from '@/extensions/common'; export default new ToolExtension( { name: 'Upload Image', icon: 'sync', version: '0.0.1', automatic: true, description: 'Upload images to image host.', i18nManifest: { 'de-DE': { name: 'Bild hochladen', description: 'Bilder auf den Bildhost hochladen.' }, 'en-US': { name: 'Upload Image', description: 'Upload images to image host.' }, 'ja-JP': { name: '画像をアップロード', description: '画像を画像ホストにアップロードします。' }, 'ko-KR': { name: '이미지 업로드', description: '이미지를 이미지 호스트에 업로드합니다.' }, 'ru-RU': { name: 'Загрузить изображение', description: 'Загрузить изображения на хост изображений.' }, 'zh-CN': { name: '上传图片', description: '把文章内图片上传到图床' }, }, }, { init: ({ pathname, currentImageHostingService }) => pathname.startsWith('/plugins') && !!currentImageHostingService, afterRun: async context => { const { data, imageService, message } = context; let foo = data; const result = data.match(/!\[.*?\]\(http(.*?)\)/g); let successCount = 0; let failedCount = 0; if (result) { const images: string[] = result .map(o => { const temp = /!\[.*?\]\((http.*?)\)/.exec(o); if (temp) { return temp[1]; } return ''; }) .filter(o => o && !o.startsWith('https://cdn-pri.nlark.com')); for (let image of images) { try { const url = await imageService!.uploadImageUrl(image); foo = foo.replace(image, url); successCount++; } catch (_error) { failedCount++; } } } message.info(`${successCount} success,${failedCount} failed.`); return foo; }, } ); ================================================ FILE: src/extensions/extensions/fullPage.ts ================================================ import { TextExtension } from '@/extensions/common'; export default new TextExtension( { name: 'Full Page', version: '0.0.1', description: 'Save Full Page and turn ro Markdown.', icon: 'copy', i18nManifest: { 'de-DE': { name: 'Vollständige Seite', description: 'Speichern Sie die gesamte Seite und konvertieren Sie sie in Markdown.' }, 'en-US': { name: 'Full Page', description: 'Save Full Page and turn to Markdown.' }, 'ja-JP': { name: '全ページ', description: '全ページを保存し、Markdownに変換します。' }, 'ko-KR': { name: '전체 페이지', description: '전체 페이지를 저장하고 Markdown으로 변환합니다.' }, 'ru-RU': { name: 'Полная страница', description: 'Сохранить полную страницу и преобразовать в Markdown.' }, 'zh-CN': { name: '整个页面', description: '把整个页面元素转换为 Markdown' }, } }, { run: async context => { const { turndown, $ } = context; const $body = $('html').clone(); $body.find('script').remove(); $body.find('style').remove(); $body.removeClass(); return turndown.turndown($body.html()); }, } ); ================================================ FILE: src/extensions/extensions/qrcode.ts ================================================ import { TextExtension } from '@/extensions/common'; export default new TextExtension( { name: 'QR code', icon: 'qrcode', version: '0.0.1', description: 'Convert the URL of the current page to a QR code.', i18nManifest: { 'de-DE': { name: 'QR-Code', description: 'Konvertieren Sie die URL der aktuellen Seite in einen QR-Code.' }, 'en-US': { name: 'QR code', description: 'Convert the URL of the current page to a QR code.' }, 'ja-JP': { name: 'QRコード', description: '現在のページのURLをQRコードに変換します。' }, 'ko-KR': { name: 'QR 코드', description: '현재 페이지의 URL을 QR 코드로 변환합니다.' }, 'ru-RU': { name: 'QR код', description: 'Преобразовать URL текущей страницы в QR-код.' }, 'zh-CN': { name: '二维码', description: '显示当前链接为二维码' }, } }, { init: ({ currentImageHostingService }) => !!currentImageHostingService, run: async context => { const { QRCode, document } = context; const dataUrl = await QRCode.toDataURL(document.URL); return dataUrl; }, afterRun: async context => { const { result: dataUrl } = context; return `![](${dataUrl})\n`; }, } ); ================================================ FILE: src/extensions/extensions/readability.ts ================================================ import { TextExtension } from '@/extensions/common'; export default new TextExtension( { name: 'Readability', icon: 'copy', version: '0.0.1', description: 'Intelligent extraction of webpage main content.', i18nManifest: { 'de-DE': { name: 'Lesbarkeit', description: 'Intelligente Extraktion des Hauptinhalts der Webseite.' }, 'en-US': { name: 'Readability', description: 'Intelligent extraction of webpage main content.' }, 'ja-JP': { name: '読みやすさ', description: 'ウェブページの主要な内容をインテリジェントに抽出します。' }, 'ko-KR': { name: '가독성', description: '웹 페이지의 주요 내용을 지능적으로 추출합니다.' }, 'ru-RU': { name: 'Читаемость', description: 'Интеллектуальная извлечение основного содержимого веб-страницы.' }, 'zh-CN': { name: '智能提取', description: '智能提取当前页面元素' }, } }, { run: async context => { const { turndown, document, Readability, $ } = context; let documentClone = document.cloneNode(true); $(documentClone) .find('#skPlayer') .remove(); let article = new Readability(documentClone, { keepClasses: true, }).parse(); return turndown.turndown(article.content); }, } ); ================================================ FILE: src/extensions/extensions/screenshot.ts ================================================ import { TextExtension } from '@/extensions/common'; import { SelectAreaPosition } from '@web-clipper/area-selector'; export default new TextExtension( { name: 'Screenshots', icon: 'picture', version: '0.0.1', i18nManifest: { 'de-DE': { name: 'Screenshots', description: 'Speichern Sie den aktuellen Inhalt als Bild.' }, 'en-US': { name: 'Screenshots', description: 'Save current clipping content as an image.' }, 'ja-JP': { name: 'スクリーンショット', description: '現在のクリップ内容を画像として保存します。' }, 'ko-KR': { name: '스크린샷', description: '현재 클립 내용을 이미지로 저장합니다.' }, 'ru-RU': { name: 'Скриншоты', description: 'Сохранить текущее содержимое как изображение.' }, 'zh-CN': { name: '截图', description: '将当前剪藏内容保存为图片' }, // 保留简体中文 } }, { init: ({ currentImageHostingService }) => !!currentImageHostingService, run: async context => { const { AreaSelector, toggleClipper, toggleLoading } = context; toggleClipper(); const response = await new AreaSelector().start(); toggleLoading(); return response; }, afterRun: async context => { const { result, loadImage, captureVisibleTab, imageService } = context; const base64Capture = await captureVisibleTab(); const img = await loadImage(base64Capture); let canvas: HTMLCanvasElement = document.createElement('canvas'); let ctx = canvas.getContext('2d'); let sx; let sy; let sheight; let swidth; let { rightBottom: { clientX: rightBottomX, clientY: rightBottomY }, leftTop: { clientX: leftTopX, clientY: leftTopY }, } = result; if (rightBottomX === leftTopX && rightBottomY === leftTopY) { sx = 0; sy = 0; swidth = img.width; sheight = img.height; } else { const dpi = window.devicePixelRatio; sx = leftTopX * dpi; sy = leftTopY * dpi; swidth = (rightBottomX - leftTopX) * dpi; sheight = (rightBottomY - leftTopY) * dpi; } canvas.height = sheight; canvas.width = swidth; ctx!.drawImage(img, sx, sy, swidth, sheight, 0, 0, swidth, sheight); const url = await imageService!.uploadImage({ data: canvas.toDataURL(), }); return `![](${url})\n\n`; }, destroy: async context => { const { toggleClipper, toggleLoading } = context; toggleLoading(); toggleClipper(); }, } ); ================================================ FILE: src/extensions/extensions/select.ts ================================================ import { TextExtension } from '@/extensions/common'; export default new TextExtension( { name: 'Manual selection', icon: 'select', version: '0.0.1', description: 'Manual selection page element.', i18nManifest: { 'de-DE': { name: 'Manuelle Auswahl', description: 'Manuelle Auswahl von Seitenelementen.' }, 'en-US': { name: 'Manual selection', description: 'Manual selection of page elements.' }, 'ja-JP': { name: '手動選択', description: 'ページ要素を手動で選択します。' }, 'ko-KR': { name: '수동 선택', description: '페이지 요소를 수동으로 선택합니다.' }, 'ru-RU': { name: 'Ручной выбор', description: 'Ручной выбор элементов страницы.' }, 'zh-CN': { name: '手动选取', description: '手动选取页面元素' }, } }, { run: async context => { const { turndown, Highlighter, toggleClipper, $ } = context; toggleClipper(); try { const data = await new Highlighter().start(); let container = document.createElement('div'); container.appendChild( $(data) .clone() .get(0) ); return turndown.turndown(container); } catch (error) { throw error; } finally { toggleClipper(); } }, } ); ================================================ FILE: src/extensions/extensions/web-clipper/clear.ts ================================================ import { ToolExtension } from '@/extensions/common'; export default new ToolExtension( { name: 'Clear', icon: 'close-circle', version: '0.0.1', description: 'Clear Content', apiVersion: '1.12.0', i18nManifest: { 'de-DE': { name: 'Löschen', description: 'Inhalt löschen.' }, 'en-US': { name: 'Clear', description: 'Clear Content' }, 'ja-JP': { name: 'クリア', description: '内容をクリアします。' }, 'ko-KR': { name: '지우기', description: '내용 지우기' }, 'ru-RU': { name: 'Очистить', description: 'Очистить содержимое' }, 'zh-CN': { name: '清空', description: '清空内容' }, } }, { init: ({ pathname }) => { return pathname.startsWith('/plugin'); }, afterRun: () => { return ''; }, } ); ================================================ FILE: src/extensions/extensions/web-clipper/copyToClipboard.ts ================================================ import { ToolExtension } from '@/extensions/common'; export default new ToolExtension( { name: 'Copy To Clipboard', icon: 'copy', version: '0.0.1', description: 'Copy To Clipboard', i18nManifest: { 'de-DE': { name: 'In die Zwischenablage kopieren', description: 'In die Zwischenablage kopieren.' }, 'en-US': { name: 'Copy To Clipboard', description: 'Copy To Clipboard' }, 'ja-JP': { name: 'クリップボードにコピー', description: 'クリップボードにコピーします。' }, 'ko-KR': { name: '클립보드에 복사', description: '클립보드에 복사합니다.' }, 'ru-RU': { name: 'Копировать в буфер обмена', description: 'Копировать в буфер обмена' }, 'zh-CN': { name: '复制', description: '复制到剪贴板' }, } }, { afterRun: ({ copyToClipboard, data }) => { copyToClipboard(data); return data; }, } ); ================================================ FILE: src/extensions/extensions/web-clipper/download.ts ================================================ import { ToolExtension } from '@/extensions/common'; export default new ToolExtension( { name: 'Save as Markdown', icon: 'file-markdown', version: '0.0.2', description: 'Save as Markdown and Download.', apiVersion: '1.12.0', i18nManifest: { 'de-DE': { name: 'Als Markdown speichern', description: 'Als Markdown speichern und herunterladen.' }, 'en-US': { name: 'Save as Markdown', description: 'Save as Markdown and Download.' }, 'ja-JP': { name: 'Markdownとして保存', description: 'Markdownとして保存し、ダウンロードします。' }, 'ko-KR': { name: 'Markdown으로 저장', description: 'Markdown으로 저장하고 다운로드합니다.' }, 'ru-RU': { name: 'Сохранить как Markdown', description: 'Сохранить как Markdown и скачать.' }, 'zh-CN': { name: '保存为 Markdown', description: '保存为 Markdown 并下载' }, }, }, { init: ({ pathname }) => { return pathname.startsWith('/plugin'); }, run: ({ document }) => { return document.title; }, afterRun: ({ createAndDownloadFile, data, result }) => { createAndDownloadFile(`${result || 'content'}.md`, data); return data; }, } ); ================================================ FILE: src/extensions/extensions/web-clipper/link.tsx ================================================ import { ToolExtension } from '@/extensions/common'; import localeService from '@/common/locales'; export default class Link extends ToolExtension { constructor() { super( { extensionId: 'link', name: 'Link', icon: 'link', version: '0.0.2', automatic: true, description: 'Add link at the end of the document.', config: { scheme: { type: 'object', properties: { template: { type: 'string', title: localeService.format({ id: 'extension.link.config.template', }), 'x-decorator': 'FormItem', 'x-component': 'textarea', 'x-component-props': { autoSize: true }, }, autoRunExclude: { type: 'array', title: localeService.format({ id: 'extension.link.config.autoRunExclude', }), 'x-decorator': 'FormItem', 'x-component': 'clipExtensionsSelect', }, }, }, default: { template: '[{TITLE}]({URL}) \n\n {DOCUMENT}', autoRunExclude: [], }, }, i18nManifest: { 'de-DE': { name: 'Link', description: 'Fügen Sie am Ende des Dokuments einen Link hinzu.' }, 'en-US': { name: 'Link', description: 'Add link at the end of the document.' }, 'ja-JP': { name: 'リンク', description: 'ドキュメントの最後にリンクを追加します。' }, 'ko-KR': { name: '링크', description: '문서 끝에 링크를 추가합니다.' }, 'ru-RU': { name: 'Ссылка', description: 'Добавить ссылку в конце документа.' }, 'zh-CN': { name: '添加模版', description: '根据插件设置中的模板,添加内容,默认添加页面链接' }, } }, { run: async context => { return { TITLE: context.document.title, URL: context.document.URL, }; }, afterRun: async context => { const config: { template: string } = context.config!; return localeService.format( { id: 'plugin.link', defaultMessage: config.template }, { DOCUMENT: context.data, TITLE: context.result.TITLE, URL: context.result.URL } ); }, } ); } } ================================================ FILE: src/extensions/extensions/web-clipper/pangu.ts ================================================ import { ToolExtension } from '@/extensions/common'; import { SelectAreaPosition } from '@web-clipper/area-selector'; export default new ToolExtension( { name: 'Pangu', icon: 'pangu', version: '0.0.2', automatic: true, apiVersion: '1.13.0', description: 'Paranoid text spacing in JavaScript', powerpack: false, i18nManifest: { 'de-DE': { name: 'Pangu', description: 'Fügen Sie Leerzeichen zwischen chinesischen und englischen Zeichen ein.' }, 'en-US': { name: 'Pangu', description: 'Paranoid text spacing in JavaScript' }, 'ja-JP': { name: 'Pangu', description: 'すべての中国語と半角英数字、記号の間に空白を挿入します。' }, 'ko-KR': { name: 'Pangu', description: '모든 한자와 반각 영어, 숫자, 기호 사이에 공백을 삽입합니다.' }, 'ru-RU': { name: 'Pangu', description: 'Вставка пробелов между китайскими и английскими символами.' }, 'zh-CN': { name: 'Pangu', description: '所有的中文字和半形的英文、数字、符号之间插入空白。' }, }, }, { afterRun: async context => { const { pangu, data } = context; return pangu(data); }, } ); ================================================ FILE: src/extensions/index.ts ================================================ import { IExtensionWithId, ToolExtension, TextExtension } from './common'; import { IContextMenuExtensionFactory } from './contextMenus'; const context = require.context('./extensions', true, /\.(ts|tsx)$/); const contextMenusContext = require.context('./contextMenus', true, /\.(ts|tsx)$/); export const contextMenus = contextMenusContext.keys().map(key => { const ContextMenuExtensionFactory: IContextMenuExtensionFactory = contextMenusContext(key) .default; return { id: ContextMenuExtensionFactory.id, contextMenu: ContextMenuExtensionFactory, }; }); export const extensions: IExtensionWithId[] = context.keys().map(key => { const id = key.slice(2, key.length - 3); const extension = context(key).default; if (extension instanceof ToolExtension || extension instanceof TextExtension) { return { ...context(key).default, id, router: `/plugins/${id}`, }; } return { factory: extension, id, router: `/plugins/${id}`, }; }); ================================================ FILE: src/hooks/useOriginForm.tsx ================================================ import React from 'react'; import { FormattedMessage } from 'react-intl'; import useOriginPermission from '@/common/hooks/useOriginPermission'; import { FormComponentProps } from '@ant-design/compatible/lib/form'; interface UseOriginFormProps extends FormComponentProps { initStatus: boolean; originKey?: string; } const useOriginForm = ({ initStatus, form, originKey }: UseOriginFormProps) => { const key = originKey || 'origin'; const [verified, requestOriginPermission] = useOriginPermission(initStatus); const handleAuthentication = () => { form.validateFields([key], async (err, value) => { if (err) { return; } requestOriginPermission(value[key]); }); }; const formRules = [ { required: true, message: ( ), }, { validator(_r: any, value: string, callback: Function) { if (!value) { return callback(); } try { const _url = new URL(value); if (_url.origin !== value) { form.setFieldsValue({ [key]: _url.toString(), }); callback(); } callback(); } catch (_error) { return callback( ); } }, }, ]; return { verified, handleAuthentication, formRules }; }; export default useOriginForm; ================================================ FILE: src/index.html ================================================ webpack App
================================================ FILE: src/main/background.worker.ts ================================================ import 'reflect-metadata'; import 'regenerator-runtime/runtime'; // services import { IContentScriptService } from '@/service/common/contentScript'; import { ICookieService } from '@/service/common/cookie'; import { IChannelServer } from '@/service/common/ipc'; import { IPermissionsService } from '@/service/common/permissions'; import { ITabService } from '@/service/common/tab'; import { IWebRequestService } from '@/service/common/webRequest'; import { ContentScriptChannelClient } from '@/service/contentScript/common/contentScriptIPC'; import '@/service/cookie/background/cookieService'; import { CookieChannel } from '@/service/cookie/common/cookieIpc'; import { BackgroundIPCServer } from '@/service/ipc/browser/background-main/ipcService'; import { PopupContentScriptIPCClient } from '@/service/ipc/browser/popup/ipcClient'; import '@/service/permissions/chrome/permissionsService'; import { PermissionsChannel } from '@/service/permissions/common/permissionsIpc'; import '@/service/tab/browser/background/tabService'; import { TabChannel } from '@/service/tab/common/tabIpc'; import '@/service/webRequest/chrome/background/tabService'; import { WebRequestChannel } from '@/service/webRequest/common/webRequestIPC'; import Container from 'typedi'; import { WorkerServiceChannel } from '@/service/worker/common/workserServiceIPC'; import '@/service/worker/worker/workerService'; import { IWorkerService } from '@/service/worker/common'; import '@/service/extension/browser/extensionContainer'; import '@/service/extension/browser/extensionService'; import { ILocalStorageService } from '@/service/common/storage'; // import { syncStorageService, localStorageService } from '@/common/chrome/storage'; Container.set(ILocalStorageService, localStorageService); Container.set(ISyncStorageService, syncStorageService); import { ISyncStorageService } from '@/service/common/storage'; // import localeService from '@/common/locales'; import { ILocaleService } from '@/service/common/locale'; import { IExtensionContainer, IExtensionService } from '@/service/common/extension'; import { getResourcePath } from '@/common/getResource'; Container.set(ILocaleService, localeService); function main() { const backgroundIPCServer: IChannelServer = new BackgroundIPCServer(); backgroundIPCServer.registerChannel('tab', new TabChannel(Container.get(ITabService))); backgroundIPCServer.registerChannel( 'worker', new WorkerServiceChannel(Container.get(IWorkerService)) ); const contentScriptIPCClient = new PopupContentScriptIPCClient(Container.get(ITabService)); const contentScriptChannel = contentScriptIPCClient.getChannel('contentScript'); Container.set(IContentScriptService, new ContentScriptChannelClient(contentScriptChannel)); const contentScriptService = Container.get(IContentScriptService); chrome.action.onClicked.addListener((tab) => { if (!tab || !tab.id) { return; } contentScriptService .checkStatus() .then(() => { contentScriptService.toggle(); }) .catch((e) => { chrome.tabs.create({ url: `${chrome.runtime.getURL(getResourcePath('error.html'))}?message=${e.message}`, }); }); }); backgroundIPCServer.registerChannel( 'permissions', new PermissionsChannel(Container.get(IPermissionsService)) ); backgroundIPCServer.registerChannel( 'webRequest', new WebRequestChannel(Container.get(IWebRequestService)) ); backgroundIPCServer.registerChannel('cookies', new CookieChannel(Container.get(ICookieService))); chrome.contextMenus.onClicked.addListener(async (_info, tab) => { const extensionContainer = Container.get(IExtensionContainer); const extensionService = Container.get(IExtensionService); const contentScriptService = Container.get(IContentScriptService); await extensionContainer.init(); await extensionService.init(); const contextMenus = extensionContainer.contextMenus; const currentContextMenus = contextMenus.filter( (p) => !extensionService.DisabledExtensionIds.includes(p.id) ); let config: unknown; const Menu = currentContextMenus.find((p) => p.id === _info.menuItemId)!; if (!Menu) { return; } const instance = new Menu.contextMenu(); if (instance.manifest.extensionId) { config = extensionService.getExtensionConfig(instance.manifest.extensionId!) || instance.manifest.config?.default; } instance.run(tab!, { config, contentScriptService, }); }); chrome.commands.onCommand.addListener(async (e) => { if (e === 'save-selection') { const extensionService = Container.get(IExtensionService); const extensionContainer = Container.get(IExtensionContainer); const contextMenus = extensionContainer.contextMenus; const currentContextMenus = contextMenus.filter( // eslint-disable-next-line max-nested-callbacks (p) => !extensionService.DisabledExtensionIds.includes(p.id) ); for (const iterator of currentContextMenus) { const Factory = iterator.contextMenu; const instance = new Factory(); if (iterator.id === 'contextMenus.selection.save') { let config: unknown; if (instance.manifest.extensionId) { config = extensionService.getExtensionConfig(instance.manifest.extensionId!) || instance.manifest.config?.default; } instance.run((await Container.get(ITabService).getCurrent()) as any, { config, contentScriptService, }); } } } }); } try { main(); } catch (error) { console.log((error as Error).message); console.error(error); } ================================================ FILE: src/main/contentScript.main.ts ================================================ import 'reflect-metadata'; import 'regenerator-runtime/runtime'; // import { localStorageService, syncStorageService } from '@/common/chrome/storage'; import { ILocalStorageService, ISyncStorageService } from '@/service/common/storage'; import localeService from '@/common/locales'; import { IContentScriptService } from '@/service/common/contentScript'; import { IChannelServer } from '@/service/common/ipc'; import { ILocaleService } from '@/service/common/locale'; import '@/service/contentScript/browser/contentScript/contentScript'; import { ContentScriptChannel } from '@/service/contentScript/common/contentScriptIPC'; import '@/service/extension/browser/extensionContainer'; import { ContentScriptIPCServer } from '@/service/ipc/browser/contentScript/contentScriptIPCServer'; import { PopupIpcClient } from '@/service/ipc/browser/popup/ipcClient'; import { IWorkerService } from '@/service/worker/common'; import { WorkerServiceChannelClient } from '@/service/worker/common/workserServiceIPC'; import Container from 'typedi'; Container.set(ILocalStorageService, localStorageService); Container.set(ISyncStorageService, syncStorageService); Container.set(ILocaleService, localeService); // import { IPreferenceService } from '@/service/common/preference'; import '@/service/preference/browser/preferenceService'; localeService.init(); const backgroundIPCServer: IChannelServer = new ContentScriptIPCServer(); backgroundIPCServer.registerChannel( 'contentScript', new ContentScriptChannel(Container.get(IContentScriptService)) ); (async () => { await Container.get(ISyncStorageService).init(); updateColor(); Container.get(ISyncStorageService).onDidChangeStorage(() => { updateColor(); }); updateMenu(); })(); const ipcClient = new PopupIpcClient(); const workerChannel = ipcClient.getChannel('worker'); Container.set(IWorkerService, new WorkerServiceChannelClient(workerChannel)); async function updateColor() { const preferenceService = Container.get(IPreferenceService); await preferenceService.init(); Container.set(IWorkerService, new WorkerServiceChannelClient(workerChannel)); const workerService = Container.get(IWorkerService); let iconColor = preferenceService.userPreference.iconColor; if (iconColor === 'auto') { const media = window.matchMedia('(prefers-color-scheme: dark)'); iconColor = media.matches ? 'light' : 'dark'; } workerService.changeIcon(iconColor); } async function updateMenu() { const workerService = Container.get(IWorkerService); workerService.initContextMenu(); } ================================================ FILE: src/main/tool.main.chrome.ts ================================================ import 'regenerator-runtime/runtime'; import 'reflect-metadata'; import { ILocaleService } from '@/service/common/locale'; import Container from 'typedi'; import { IWebRequestService } from '@/service/common/webRequest'; import { WebRequestChannelClient } from '@/service/webRequest/common/webRequestIPC'; import { IContentScriptService } from '@/service/common/contentScript'; import { ContentScriptChannelClient } from '@/service/contentScript/common/contentScriptIPC'; import { ITabService } from '@/service/common/tab'; import { PopupIpcClient, PopupContentScriptIPCClient } from '@/service/ipc/browser/popup/ipcClient'; import '@/service/request/tool/basic'; import '@/service/config/browser/configService'; import localeService from '@/common/locales'; Container.set(ILocaleService, localeService); import '@/service/extension/browser/extensionService'; import '@/service/extension/browser/extensionContainer'; import '@/service/permissions/chrome/permissionsService'; import { TabChannelClient } from '@/service/tab/common/tabIpc'; import app from '@/pages/app'; import { CookieChannelClient } from '@/service/cookie/common/cookieIpc'; import { ICookieService } from '@/service/common/cookie'; const ipcClient = new PopupIpcClient(); const tabChanel = ipcClient.getChannel('tab'); Container.set(ITabService, new TabChannelClient(tabChanel)); const contentScriptIPCClient = new PopupContentScriptIPCClient(Container.get(ITabService)); const contentScriptChannel = contentScriptIPCClient.getChannel('contentScript'); Container.set(IContentScriptService, new ContentScriptChannelClient(contentScriptChannel)); const webRequestChannel = ipcClient.getChannel('webRequest'); Container.set(IWebRequestService, new WebRequestChannelClient(webRequestChannel)); const cookieChannel = ipcClient.getChannel('cookies'); Container.set(ICookieService, new CookieChannelClient(cookieChannel)); app(); ================================================ FILE: src/models/account.ts ================================================ import update from 'immutability-helper'; import { DvaModelBuilder, removeActionNamespace } from 'dva-model-creator'; import { GlobalStore, AccountPreference } from '@/common/types'; import { syncStorageService } from '@/common/chrome/storage'; import { initAccounts, asyncAddAccount, asyncDeleteAccount, asyncUpdateDefaultAccountId, asyncUpdateAccount, } from '@/actions/account'; import { asyncChangeAccount } from '@/actions/clipper'; import { message } from 'antd'; import { getServices } from '@/common/backend'; const initState: GlobalStore['account'] = { accounts: [], }; const model = new DvaModelBuilder(initState, 'account'); model .subscript(async function loadAccounts({ dispatch }) { syncStorageService.onDidChangeStorage(key => { if (key === 'accounts') { dispatch(removeActionNamespace(initAccounts.started())); } if (key === 'defaultAccountId') { const defaultAccountId = syncStorageService.get('defaultAccountId'); dispatch( removeActionNamespace(asyncUpdateDefaultAccountId.started({ id: defaultAccountId })) ); } }); }) .takeEvery(initAccounts.started, function*(_, { call, put }) { let accountsString = yield call(syncStorageService.get, 'accounts', '[]'); const defaultAccountId: string = yield call(syncStorageService.get, 'defaultAccountId'); if (typeof accountsString !== 'string') { accountsString = JSON.stringify(accountsString); } let accounts = JSON.parse(accountsString); accounts = accounts.filter(account => getServices().some(o => o.type === account.type)); yield put(initAccounts.done({ result: { accounts, defaultAccountId } })); }) .case(initAccounts.done, (s, { result: { accounts, defaultAccountId } }) => ({ ...s, accounts, defaultAccountId, })); model.takeEvery(asyncAddAccount.started, function*(payload, { select, call }) { const selector = ({ account: { accounts } }: GlobalStore) => { return { accounts }; }; const { accounts }: ReturnType = yield select(selector); const { info, imageHosting, defaultRepositoryId, type, userInfo, callback, id } = payload; const userPreference: AccountPreference = { ...userInfo, ...info, imageHosting, defaultRepositoryId, type, id, }; if (accounts.some(o => o.id === userPreference.id)) { message.error('Do not allow duplicate accounts'); return; } const newAccounts = update(accounts, { $push: [userPreference], }); if (newAccounts.length === 1) { yield call(syncStorageService.set, 'defaultAccountId', userPreference.id); } callback(); yield call(syncStorageService.set, 'accounts', JSON.stringify(newAccounts)); }); model.takeEvery(asyncDeleteAccount.started, function*({ id }, { select, call }) { const accounts: AccountPreference[] = yield select((g: GlobalStore) => g.account.accounts); const defaultAccountId: string = yield select((g: GlobalStore) => g.account.defaultAccountId); const newAccounts = accounts.filter(o => o.id !== id); if (defaultAccountId === id) { if (newAccounts.length > 0) { yield call(syncStorageService.set, 'defaultAccountId', newAccounts[0].id); } else { yield call(syncStorageService.delete, 'defaultAccountId'); } } yield call(syncStorageService.set, 'accounts', JSON.stringify(newAccounts)); }); model .takeEvery(asyncUpdateDefaultAccountId.started, function*({ id }, { call, put }) { yield call(syncStorageService.set, 'defaultAccountId', id); yield put(asyncUpdateDefaultAccountId.done({ params: { id } })); }) .case(asyncUpdateDefaultAccountId.done, (s, { params: { id: defaultAccountId } }) => ({ ...s, defaultAccountId, })); model.takeEvery(asyncUpdateAccount, function*(payload, { select, put, call }) { const selector = ({ account: { accounts, defaultAccountId } }: GlobalStore) => ({ accounts, defaultAccountId, }); const { accounts, defaultAccountId }: ReturnType = yield select(selector); const { id, account: { info, defaultRepositoryId, imageHosting }, userInfo, newId, callback, } = payload; const accountIndex = accounts.findIndex(o => o.id === id); if (accountIndex < 0) { message.error('Account Not Exist'); callback(); return; } const result = update(accounts, { [accountIndex]: { $merge: { id: newId, defaultRepositoryId, imageHosting, ...userInfo, ...info, }, }, }); yield call(syncStorageService.set, 'accounts', JSON.stringify(result)); const currentAccountId: string = yield select((g: GlobalStore) => g.clipper.currentAccountId); if (id === defaultAccountId) { yield put.resolve(asyncUpdateDefaultAccountId.started({ id: newId })); } yield put.resolve(initAccounts.started); callback(); if (id === currentAccountId) { yield put.resolve(asyncChangeAccount.started({ id: newId })); } }); export default model.build(); ================================================ FILE: src/models/clipper.tsx ================================================ import { Container } from 'typedi'; import React from 'react'; import { IPermissionsService } from '@/service/common/permissions'; import { BUILT_IN_IMAGE_HOSTING_ID } from '@/common/backend/imageHosting/interface'; import { updateClipperHeader } from './../actions/clipper'; import { asyncRunExtension } from './../actions/userPreference'; import { CompleteStatus } from 'common/backend/interface'; import { CreateDocumentRequest, UnauthorizedError } from '@/common/backend/services/interface'; import { GlobalStore, ClipperStore } from '@/common/types'; import { DvaModelBuilder, removeActionNamespace } from 'dva-model-creator'; import update from 'immutability-helper'; import { selectRepository, initTabInfo, asyncCreateDocument, asyncChangeAccount, changeData, watchActionChannel, } from 'pageActions/clipper'; import backend, { documentServiceFactory, imageHostingServiceFactory } from 'common/backend'; import { unpackAccountPreference } from '@/services/account/common'; import { notification, Button } from 'antd'; import { routerRedux } from 'dva'; import { asyncUpdateAccount } from '@/actions/account'; import { channel } from 'redux-saga'; import { IExtensionService, IExtensionContainer } from '@/service/common/extension'; import { ExtensionType } from '@/extensions/common'; const defaultState: ClipperStore = { clipperHeaderForm: { title: '', }, currentAccountId: '', repositories: [], clipperData: {}, }; const actionChannel = channel(); const model = new DvaModelBuilder(defaultState, 'clipper') .subscript(function startWatchActionChannel({ dispatch }) { dispatch(removeActionNamespace(watchActionChannel())); }) .takeEvery(watchActionChannel, function*(_, { put, take }) { while (true) { //@ts-ignore const action = yield take(actionChannel); yield put(action); } }) .takeEvery(asyncChangeAccount.started, function*(payload, { call, select, put }) { const selector = ({ userPreference: { imageHosting, servicesMeta }, account: { accounts }, }: GlobalStore) => { return { accounts, imageHosting, servicesMeta, }; }; const selectState: ReturnType = yield select(selector); const { accounts, imageHosting } = selectState; let currentAccount = accounts.find(o => o.id === payload.id); if (!currentAccount) { return; } const { id, account, account: { type, info }, userInfo, } = unpackAccountPreference(currentAccount); const documentService = documentServiceFactory(type, info); const permissionsService = Container.get(IPermissionsService); if (selectState.servicesMeta[type]?.permission) { //@ts-ignore const hasPermissions = yield call( permissionsService.contains, selectState.servicesMeta[type]?.permission! ); if (!hasPermissions) { const key = `open${Date.now()}`; const close = () => { permissionsService.request(selectState.servicesMeta[type]?.permission!).then(re => { if (re) { actionChannel.put(asyncChangeAccount.started({ id })); } }); }; notification.error({ key, placement: 'topRight', duration: 0, message: 'No Permission', btn: ( ), onClose: () => close, }); return; } } let repositories = []; try { repositories = yield call(documentService.getRepositories); } catch (error) { if (error instanceof UnauthorizedError) { if (documentService.refreshToken) { const newInfo = yield call(documentService.refreshToken, info); yield put( asyncUpdateAccount({ id, account: { ...account, info: newInfo, }, userInfo, newId: id, callback: () => { actionChannel.put(asyncChangeAccount.started({ id })); }, }) ); return; } throw new Error('Filed to load Repositories,Unauthorized.'); } else { throw error; } } backend.setDocumentService(documentService); let currentImageHostingService: ClipperStore['currentImageHostingService']; if (account.imageHosting) { if (account.imageHosting === BUILT_IN_IMAGE_HOSTING_ID) { currentImageHostingService = { type: type, }; const imageHostingService = imageHostingServiceFactory(type, info); backend.setImageHostingService(imageHostingService); } else { const imageHostingIndex = imageHosting.findIndex(o => o.id === account.imageHosting); if (imageHostingIndex !== -1) { const accountImageHosting = imageHosting[imageHostingIndex]; const imageHostingService = imageHostingServiceFactory( accountImageHosting.type, accountImageHosting.info ); backend.setImageHostingService(imageHostingService); currentImageHostingService = { type: accountImageHosting.type, }; } } } yield put( asyncChangeAccount.done({ params: payload, result: { repositories, currentImageHostingService, }, }) ); }) .takeLatest(asyncCreateDocument.started, function*({ pathname }, { put, call, select }) { const selector = ({ clipper: { currentRepository, clipperHeaderForm, repositories, currentAccountId }, account: { accounts }, }: GlobalStore) => { const currentAccount = accounts.find(({ id }) => id === currentAccountId); let repositoryId; if ( currentAccount && repositories.some(({ id }) => id === currentAccount.defaultRepositoryId) ) { repositoryId = currentAccount.defaultRepositoryId; } if (currentRepository) { repositoryId = currentRepository.id; } const extensions = Container.get(IExtensionContainer).extensions; const extension = extensions.find(o => o.router === pathname); const enabledAutomaticExtensionIds = Container.get(IExtensionService) .EnabledAutomaticExtensionIds; const automaticExtensions = extensions.filter( o => o.type === ExtensionType.Tool && o.manifest.automatic && enabledAutomaticExtensionIds.some(id => id === o.id) ); return { repositoryId, extensions, clipperHeaderForm, extension, repositories, automaticExtensions, }; }; const { repositoryId, clipperHeaderForm, extension, automaticExtensions, }: ReturnType = yield select(selector); if (!repositoryId) { yield put( asyncCreateDocument.failed({ params: { pathname }, error: null, }) ); throw new Error('Must select repository.'); } if (!extension) { // DEBT if (pathname !== '/editor') { return; } } for (const iterator of automaticExtensions) { // DEBT if (iterator.id === 'web-clipper/link.' && pathname === '/editor') { continue; } yield put.resolve(asyncRunExtension.started({ pathname, extension: iterator })); } const { data, url } = yield select((g: GlobalStore) => { return { url: g.clipper.url, data: g.clipper.clipperData[pathname], }; }); let createDocumentRequest: CreateDocumentRequest | null = null; createDocumentRequest = { repositoryId, content: data as string, url, ...clipperHeaderForm, }; if (!createDocumentRequest) { return; } const response: CompleteStatus = yield call( backend.getDocumentService()!.createDocument, createDocumentRequest ); yield put( asyncCreateDocument.done({ params: { pathname }, result: { result: response, request: createDocumentRequest, }, }) ); yield put(routerRedux.push('/complete')); }) .case( asyncChangeAccount.done, (state, { params: { id }, result: { repositories, currentImageHostingService } }) => { return update(state, { currentAccountId: { $set: id, }, repositories: { $set: repositories, }, currentRepository: { // eslint-disable-next-line no-undefined $set: undefined, }, currentImageHostingService: { $set: currentImageHostingService, }, }); } ) .case(selectRepository, (state, { repositoryId }) => { const currentRepository = state.repositories.find(o => o.id === repositoryId); const updateContext = backend.getImageHostingService()?.updateContext; if (currentRepository && updateContext) { updateContext({ currentRepository }); } return { ...state, currentRepository, }; }) .case(initTabInfo, (state, { title, url }) => ({ ...state, clipperHeaderForm: { ...state.clipperHeaderForm, title, }, url, })) .case(asyncCreateDocument.started, state => ({ ...state, })) .case( asyncCreateDocument.done, (state, { result: { result: completeStatus, request: createDocumentRequest } }) => ({ ...state, completeStatus, createDocumentRequest, }) ) .case(updateClipperHeader, (state, clipperHeaderForm) => ({ ...state, clipperHeaderForm, })) .case(changeData, (state, { data, pathName }) => { return update(state, { clipperData: { [pathName]: { $set: data, }, }, }); }); export default model.build(); ================================================ FILE: src/models/userPreference.ts ================================================ import { IContentScriptService } from '@/service/common/contentScript'; import { ITabService } from '@/service/common/tab'; import { Container } from 'typedi'; import React from 'react'; import { getLanguage } from './../common/locales'; import localeService from '@/common/locales'; import { LOCAL_USER_PREFERENCE_LOCALE_KEY } from './../common/modelTypes/userPreference'; import storage from 'common/storage'; import * as antd from 'antd'; import { GlobalStore } from '@/common/types'; import update from 'immutability-helper'; import { asyncSetEditorLiveRendering, initUserPreference, asyncDeleteImageHosting, asyncAddImageHosting, asyncEditImageHosting, asyncRunExtension, setLocale, asyncSetLocaleToStorage, initServices, asyncSetIconColor, } from 'pageActions/userPreference'; import { initTabInfo, changeData, asyncChangeAccount } from 'pageActions/clipper'; import { DvaModelBuilder, removeActionNamespace } from 'dva-model-creator'; import { UserPreferenceStore } from 'common/types'; import { getServices, getImageHostingServices, imageHostingServiceFactory } from 'common/backend'; import { ToolContext } from '@/extensions/common'; import backend from 'common/backend/index'; import { loadImage } from 'common/blob'; import { routerRedux } from 'dva'; import { localStorageService, syncStorageService } from '@/common/chrome/storage'; import { initAccounts } from '@/actions/account'; import copyToClipboard from 'copy-to-clipboard'; import remark from 'remark'; import remakPangu from '@web-clipper/remark-pangu'; import { IExtensionService } from '@/service/common/extension'; const { message } = antd; const defaultState: UserPreferenceStore = { locale: getLanguage(), imageHosting: [], servicesMeta: {}, imageHostingServicesMeta: {}, liveRendering: true, iconColor: 'auto', }; const builder = new DvaModelBuilder(defaultState, 'userPreference') .case(asyncSetIconColor.done, (state, { result: { value: iconColor } }) => ({ ...state, iconColor, })) .case(asyncSetEditorLiveRendering.done, (state, { result: { value: liveRendering } }) => ({ ...state, liveRendering, })) .case(initUserPreference, (state, payload) => ({ ...state, ...payload, })) .case(asyncDeleteImageHosting.done, (state, { result }) => update(state, { imageHosting: { $set: result, }, }) ) .case(asyncAddImageHosting.done, (state, { result }) => update(state, { imageHosting: { $set: result, }, }) ) .case(asyncEditImageHosting.done, (state, { result }) => update(state, { imageHosting: { $set: result, }, }) ); builder .takeEvery(asyncSetIconColor.started, function*({ value }, { call, put }) { yield call(storage.setIconColor, value); yield put( asyncSetIconColor.done({ params: { value, }, result: { value: value, }, }) ); }) .takeEvery(asyncSetEditorLiveRendering.started, function*({ value }, { call, put }) { yield call(storage.setLiveRendering, !value); yield put( asyncSetEditorLiveRendering.done({ params: { value, }, result: { value: !value, }, }) ); }) .takeEvery(asyncEditImageHosting.started, function*(payload, { call, put }) { const { id, value, closeModal } = payload; try { //@ts-ignore const imageHostingList = yield call(storage.editImageHostingById, id, { ...value, id, }); yield put( asyncEditImageHosting.done({ params: payload, result: imageHostingList, }) ); closeModal(); } catch (error) { message.error((error as Error).message); } }) .takeEvery(asyncDeleteImageHosting.started, function*(payload, { call, put }) { const imageHostingList: PromiseType> = yield call(storage.deleteImageHostingById, payload.id); yield put( asyncDeleteImageHosting.done({ params: payload, result: imageHostingList, }) ); }) .takeEvery(asyncAddImageHosting.started, function*(payload, { call, put }) { const { info, type, closeModal, remark } = payload; const imageHostingService: ReturnType = yield call( imageHostingServiceFactory, type, info ); if (!imageHostingService) { message.error('不支持'); return; } const id = imageHostingService.getId(); const imageHosting = { id, type, info, remark, }; try { const imageHostingList: PromiseType> = yield call( storage.addImageHosting, imageHosting ); yield put( asyncAddImageHosting.done({ params: payload, result: imageHostingList, }) ); closeModal(); } catch (error) { message.error((error as Error).message); } }) .takeEvery(asyncRunExtension.started, function*({ extension, pathname }, { call, put, select }) { const contentScriptService = Container.get(IContentScriptService); let result; const { extensionLifeCycle: { run, afterRun, destroy }, id, manifest, } = extension; const tabService = Container.get(ITabService); const extensionService = Container.get(IExtensionService); let config: any; if (manifest.extensionId) { config = extensionService.getExtensionConfig(manifest.extensionId) || manifest.config?.default; if (Array.isArray(config?.autoRunExclude) && config?.autoRunExclude.length > 0) { const autoRunExclude: string[] = config?.autoRunExclude; // TODO if (autoRunExclude.some(p => `/plugins/${p}` === pathname)) { return; } } } if (run) { //@ts-ignore result = yield call(contentScriptService.runScript, id, 'run'); } const state: GlobalStore = yield select(state => state); const data = state.clipper.clipperData[pathname]; function createAndDownloadFile(fileName: string, content: string | Blob) { let aTag = document.createElement('a'); let blob: Blob; if (typeof content === 'string') { blob = new Blob([content]); } else { blob = content; } aTag.download = fileName; aTag.href = URL.createObjectURL(blob); aTag.click(); URL.revokeObjectURL(aTag.href); } async function pangu(document: string): Promise { const result = await remark() .use(remakPangu) .process(document); return result.contents as string; } if (afterRun) { try { const context: ToolContext = { locale: state.userPreference.locale, result, data, message, imageService: backend.getImageHostingService(), loadImage: loadImage, captureVisibleTab: tabService.captureVisibleTab, copyToClipboard, createAndDownloadFile, antd, React, pangu, config, }; //@ts-ignore result = yield call(afterRun, context); } catch (error) { message.error((error as Error).message); } } if (destroy) { contentScriptService.runScript(id, 'destroy'); } yield put( changeData({ data: result, pathName: pathname, }) ); }); builder.subscript(async function initStore({ dispatch, history }) { await dispatch(initAccounts.started()); const result = await storage.getPreference(); const tabService = Container.get(ITabService); const tabInfo = await tabService.getCurrent(); if (tabInfo.title && tabInfo.url) { dispatch(initTabInfo({ title: tabInfo.title, url: tabInfo.url })); } dispatch(removeActionNamespace(initUserPreference(result))); if (history.location.pathname !== '/' && history.location.pathname !== '/editor') { return; } if (result.defaultPluginId) { dispatch(routerRedux.push(`/plugins/${result.defaultPluginId}`)); } const defaultAccountId = syncStorageService.get('defaultAccountId'); if (defaultAccountId) { dispatch(asyncChangeAccount.started({ id: defaultAccountId })); } }); builder .takeEvery(asyncSetLocaleToStorage, function*(locale, { call }) { yield call(localStorageService.set, LOCAL_USER_PREFERENCE_LOCALE_KEY, locale); }) .subscript(async function initLocal({ dispatch }) { const locale = localStorageService.get(LOCAL_USER_PREFERENCE_LOCALE_KEY, navigator.language); dispatch(removeActionNamespace(setLocale(locale))); localStorageService.onDidChangeStorage(key => { if (key === LOCAL_USER_PREFERENCE_LOCALE_KEY) { dispatch( removeActionNamespace( setLocale(localStorageService.get(LOCAL_USER_PREFERENCE_LOCALE_KEY, navigator.language)) ) ); } }); }) .case(setLocale, (state, locale) => ({ ...state, locale })); builder .subscript(async function xx({ dispatch }) { const servicesMeta = getServices().reduce((previousValue, meta) => { previousValue[meta.type] = meta; return previousValue; }, {} as UserPreferenceStore['servicesMeta']); const imageHostingServicesMeta = getImageHostingServices().reduce((previousValue, meta) => { previousValue[meta.type] = meta; return previousValue; }, {} as UserPreferenceStore['imageHostingServicesMeta']); dispatch( removeActionNamespace( initServices({ imageHostingServicesMeta, servicesMeta, }) ) ); localStorageService.onDidChangeStorage(async key => { if (key === LOCAL_USER_PREFERENCE_LOCALE_KEY) { await localeService.init(); const servicesMeta = getServices().reduce((previousValue, meta) => { previousValue[meta.type] = meta; return previousValue; }, {} as UserPreferenceStore['servicesMeta']); const imageHostingServicesMeta = getImageHostingServices().reduce((previousValue, meta) => { previousValue[meta.type] = meta; return previousValue; }, {} as UserPreferenceStore['imageHostingServicesMeta']); dispatch( removeActionNamespace( initServices({ imageHostingServicesMeta, servicesMeta, }) ) ); } }); }) .case(initServices, (state, { imageHostingServicesMeta, servicesMeta }) => { return { ...state, imageHostingServicesMeta, servicesMeta, }; }); export default builder.build(); ================================================ FILE: src/pages/app.less ================================================ body { background: none; font-family: Chinese Quote, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Helvetica Neue, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; } ================================================ FILE: src/pages/app.tsx ================================================ import 'antd/dist/antd.less'; import Container from 'typedi'; import React from 'react'; import dva, { router } from 'dva'; import { createHashHistory } from 'history'; import preference from '@/pages/preference'; import Complete from '@/pages/complete/complete'; import PluginPage from '@/pages/plugin/Page'; import Tool from '@/pages/tool'; import clipper from '@/models/clipper'; import userPreference from '@/models/userPreference'; import createLoading from 'dva-loading'; import LocalWrapper from './locale'; import { localStorageService, syncStorageService } from '@/common/chrome/storage'; import localeService from '@/common/locales'; import AuthPage from '@/pages/auth'; import account from '@/models/account'; import { message } from 'antd'; import { IConfigService } from '@/service/common/config'; import { ILocalStorageService, ISyncStorageService } from '@/service/common/storage'; import './app.less'; Container.set(ILocalStorageService, localStorageService); Container.set(ISyncStorageService, syncStorageService); import '@/service/preference/browser/preferenceService'; import { IPreferenceService } from '@/service/common/preference'; import '@/services/environment/common/environmentService'; const { Route, Switch, Router, withRouter } = router; function withTool(WrappedComponent: any): any { return () => { const ToolWith = withRouter(Tool as any); const WrappedComponentWith = withRouter(WrappedComponent); return ( ); }; } export default async () => { await syncStorageService.init(); await localStorageService.init(); await localeService.init(); Container.get(IConfigService).load(); await Container.get(IPreferenceService).init(); const app = dva({ namespacePrefixWarning: false, history: createHashHistory(), onError: (e) => { (e as any).preventDefault(); message.destroy(); message.error(e.message); message.error(e.stack); }, }); app.use(createLoading()); app.router((router) => { return ( ); }); app.model(account); app.model(clipper); app.model(userPreference); app.start('#app'); }; ================================================ FILE: src/pages/auth.tsx ================================================ import React, { useEffect, useMemo } from 'react'; import { connect } from 'dva'; import { parse } from 'qs'; import { DvaRouterProps, GlobalStore } from '@/common/types'; import { Form } from '@ant-design/compatible'; import '@ant-design/compatible/assets/index.less'; import { Modal, Select } from 'antd'; import { FormattedMessage } from 'react-intl'; import { FormComponentProps } from '@ant-design/compatible/lib/form'; import useVerifiedAccount from '@/common/hooks/useVerifiedAccount'; import ImageHostingSelect from '@/components/ImageHostingSelect'; import useFilterImageHostingServices, { ImageHostingWithMeta, } from '@/common/hooks/useFilterImageHostingServices'; import { asyncAddAccount } from '@/actions/account'; import { isEqual } from 'lodash'; import RepositorySelect from '@/components/RepositorySelect'; import { BUILT_IN_IMAGE_HOSTING_ID } from '@/common/backend/imageHosting/interface'; import Container from 'typedi'; import { ITabService } from '@/service/common/tab'; interface PageQuery { access_token: string; type: string; } const mapStateToProps = ({ userPreference: { servicesMeta, imageHosting, imageHostingServicesMeta }, }: GlobalStore) => { return { servicesMeta, imageHosting, imageHostingServicesMeta, }; }; type PageStateProps = ReturnType; type PageProps = PageStateProps & DvaRouterProps & FormComponentProps; function useDeepCompareMemoize(value: T) { const ref = React.useRef(); if (!isEqual(value, ref.current)) { ref.current = value; } return ref.current; } const Page: React.FC = props => { const query: PageQuery = parse(props.location.search.slice(1)) as any; const tabService = Container.get(ITabService); const { form: { getFieldDecorator }, form, imageHosting, imageHostingServicesMeta, } = props; const { type, verifyAccount, accountStatus: { repositories, verified, userInfo, id }, serviceForm, verifying, okText, } = useVerifiedAccount({ form: props.form, services: props.servicesMeta, initAccount: query, }); const imageHostingWithBuiltIn = useMemo(() => { const res = [...imageHosting]; const meta = imageHostingServicesMeta[type]; if (meta?.builtIn) { res.push({ type, info: {}, id: BUILT_IN_IMAGE_HOSTING_ID, remark: meta.builtInRemark, }); } return res; }, [imageHosting, imageHostingServicesMeta, type]); const supportedImageHostingServices: ImageHostingWithMeta[] = useFilterImageHostingServices({ backendServiceType: type, imageHostingServices: imageHostingWithBuiltIn, imageHostingServicesMap: imageHostingServicesMeta, }); const memoizeQuery = useDeepCompareMemoize(query); useEffect(() => { verifyAccount(memoizeQuery); }, [verifyAccount, memoizeQuery]); return ( } onOk={() => { form.validateFields((error, values) => { if (error) { return; } const { defaultRepositoryId, imageHosting, ...info } = values; props.dispatch( asyncAddAccount.started({ id: id!, type, defaultRepositoryId, imageHosting, info, userInfo: userInfo!, callback: tabService.closeCurrent, }) ); }); }} >
} > {getFieldDecorator('type', { initialValue: query.type, })( )} {serviceForm} } > {getFieldDecorator('defaultRepositoryId')( )} } > {getFieldDecorator('imageHosting')( )}
); }; export default connect(mapStateToProps)(Form.create()(Page)); ================================================ FILE: src/pages/complete/complete.less ================================================ .jump { margin-top: 16px; } .icons { font-size: 100px; } ================================================ FILE: src/pages/complete/complete.tsx ================================================ import * as React from 'react'; import { useSelector } from 'dva'; import { ToolContainer } from 'components/container'; import styles from './complete.less'; import { Button } from 'antd'; import { GlobalStore } from '@/common/types'; import Section from 'components/section'; import { FormattedMessage } from 'react-intl'; import Share from '@/components/share'; import Container from 'typedi'; import { IContentScriptService } from '@/service/common/contentScript'; const Page: React.FC = () => { const { servicesMeta, currentAccount, completeStatus, createDocumentRequest } = useSelector( ({ clipper: { completeStatus, currentAccountId, createDocumentRequest }, userPreference: { servicesMeta }, account: { accounts }, }: GlobalStore) => { const currentAccount = accounts.find(o => o.id === currentAccountId); return { servicesMeta, currentAccount, completeStatus, createDocumentRequest, }; } ); function closeTool() { Container.get(IContentScriptService).remove(); } const renderError = ( ); if (!currentAccount) { return renderError; } const currentService = servicesMeta[currentAccount.type]; if (!currentService) { return renderError; } const { name, complete: Complete } = currentService; return (
}> {completeStatus?.href ? ( ) : ( )}
{Complete && }
}>
); }; export default Page; ================================================ FILE: src/pages/locale.tsx ================================================ import React from 'react'; import { IntlProvider } from 'react-intl'; import { ConfigProvider } from 'antd'; import { connect } from 'dva'; import { localesMap } from '@/common/locales'; import { localeProvider } from '@/common/locales/antd'; import { GlobalStore } from '@/common/types'; const mapStateToProps = ({ userPreference: { locale } }: GlobalStore) => { return { locale, }; }; type PageStateProps = ReturnType; const LocalWrapper: React.FC = ({ children, locale }) => { const language = locale; const model = (localesMap.get(language) || localesMap.get('en-US'))!; return ( { if (!e || !e.parentNode) { return document.body; } return e.parentNode as HTMLElement; }} > {children} ); }; export default connect(mapStateToProps)(LocalWrapper); ================================================ FILE: src/pages/plugin/Page.tsx ================================================ import React from 'react'; import { connect, router } from 'dva'; import { ExtensionType } from '@/extensions/common'; import TextEditor from './TextEditor'; import { DvaRouterProps } from '@/common/types'; import { useObserver } from 'mobx-react'; import Container from 'typedi'; import { IExtensionContainer } from '@/service/common/extension'; const { Redirect } = router; const ClipperPluginPage: React.FC = props => { const { history: { location: { pathname, search }, }, } = props; const extensions = useObserver(() => Container.get(IExtensionContainer).extensions); if (pathname === '/editor') { return ; } const extension = extensions.find(o => o.router === pathname); if (!extension) { return ; } if (extension.type === ExtensionType.Text) { return ; } return ; }; export default connect()(ClipperPluginPage); ================================================ FILE: src/pages/plugin/TextEditor.tsx ================================================ import React from 'react'; import { bindActionCreators, Dispatch } from 'redux'; import { connect } from 'dva'; import { changeData } from 'pageActions/clipper'; import { asyncRunExtension } from 'pageActions/userPreference'; import * as HyperMD from 'hypermd'; import { EditorContainer } from 'components/container'; import { isUndefined } from 'common/object'; import { GlobalStore } from 'common/types'; import { IExtensionWithId } from '@/extensions/common'; import { parse } from 'qs'; const useActions = { asyncRunExtension: asyncRunExtension.started, changeData, }; const mapStateToProps = ({ clipper: { clipperData }, userPreference: { liveRendering }, }: GlobalStore) => { return { liveRendering, clipperData, }; }; type PageOwnProps = { pathname: string; search?: string; extension: IExtensionWithId | null; }; type PageProps = ReturnType & typeof useActions & PageOwnProps; const editorId = 'DiamondYuan_Love_LJ'; class ClipperPluginPage extends React.Component { private myCodeMirror: any; constructor(props: any) { super(props); this.state = { markdown: '', }; } checkExtension = () => { const { extension, clipperData, pathname, search } = this.props; const data = clipperData[pathname]; if (isUndefined(data) && extension) { this.props.asyncRunExtension({ pathname, extension, }); } if (isUndefined(data) && search) { const content = parse(search.slice(1)); this.props.changeData({ data: content.markdown || '', pathName: this.props.pathname, }); this.setState({ markdown: (content.markdown as string) || '', }); return content.markdown || ''; } if (search && !isUndefined(data)) { const content = parse(search.slice(1)); if (content.markdown !== this.state.markdown) { this.setState({ markdown: (content.markdown as string) || '', }); this.props.changeData({ data: (content.markdown as string) || '', pathName: this.props.pathname, }); } } return data || ''; }; componentDidUpdate = () => { const data = this.checkExtension(); if (this.myCodeMirror) { const value = this.myCodeMirror.getValue(); if (data !== value) { try { const that = this; setTimeout(() => { that.myCodeMirror.setValue(data); that.myCodeMirror.focus(); that.myCodeMirror.setCursor(that.myCodeMirror.lineCount(), 0); }, 10); } catch (_error) {} } } }; componentDidMount = () => { const data = this.checkExtension(); let myTextarea = document.getElementById(editorId) as HTMLTextAreaElement; this.myCodeMirror = HyperMD.fromTextArea(myTextarea, { lineNumbers: false, hmdModeLoader: false, }); if (this.myCodeMirror) { const value = this.myCodeMirror.getValue(); if (data !== value) { this.myCodeMirror.setValue(data); } } this.myCodeMirror.on('change', (editor: any) => { this.props.changeData({ data: editor.getValue(), pathName: this.props.pathname, }); }); this.myCodeMirror.setSize(800, 621); if (this.props.liveRendering) { HyperMD.switchToHyperMD(this.myCodeMirror); } else { HyperMD.switchToNormal(this.myCodeMirror); } }; render() { return (