diff --git a/.editorconfig b/.editorconfig index 5760be583..2e5422c44 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,3 +10,4 @@ insert_final_newline = true [*.md] trim_trailing_whitespace = false +indent_size = 4 diff --git a/.eslintrc.js b/.eslintrc.js index b28971797..32689c53b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,11 +19,15 @@ module.exports = { '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/ban-types': 'off', '@typescript-eslint/no-extra-non-null-assertion': 'off', + '@typescript-eslint/no-unused-vars': 'warn', 'standard/no-callback-literal': 'off', 'no-console': 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 'comma-dangle': 'off', - 'no-unused-expressions': 'off' + 'multiline-ternary': 'off', + 'node/no-callback-literal': 'off', + 'no-unused-expressions': 'off', + 'vue/multi-word-component-names': 'off', }, overrides: [ { diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..cdc690c26 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,33 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG]:" +labels: bug +assignees: '' + +--- + +**Describe the bug** + + +**To Reproduce** + + +**Expected behavior** + + +**Screenshots** + + +**Desktop (please complete the following information):** + - Version [e.g. v3.23.0] + - OS: [e.g. macOS] + - Browser [e.g. chrome, safari] + +**Additional context** + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..d88d81698 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,28 @@ +--- +name: Feature Request +about: Suggest a new feature or improvement +title: "[Feature]:Feature Name" +labels: 'Feature' +assignees: '' + +--- + +**Feature Description** +Briefly describe the feature and its purpose. + +**Background** +Why is this feature needed? Provide some context or business scenario that helps understand the need for this feature. + +**Expected Behavior** +Describe how the feature should behave once implemented + +**Proposed Solution (Optional)** +If you have a solution or suggestion, briefly explain it here + +**Additional Information (Optional)** +Any dependencies or potential impact on other features + +**Priority (Optional)** +- [ ] High +- [ ] Medium +- [ ] Low diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1658d23d6..3210263f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,39 +8,50 @@ jobs: build: strategy: matrix: - os: [macos-latest, ubuntu-latest, windows-latest] include: - - os: macos-latest - platform: 'mac' - - os: windows-latest - platform: 'win' - - os: ubuntu-latest - platform: 'linux' + - os: macos-13 + platform: 'mac' + - os: windows-2019 + platform: 'win' + - os: ubuntu-22.04 + platform: 'linux' runs-on: ${{ matrix.os }} env: - npm_config_disturl: https://atom.io/download/electron - npm_config_target: 11.4.5 - npm_config_runtime: "electron" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: + - name: Set git config + run: | + git config --global core.autocrlf false + - name: Checkout - uses: actions/checkout@v2.3.3 + uses: actions/checkout@v2 + with: + submodules: recursive + + - name: Setup Python environment + uses: actions/setup-python@v4 + with: + python-version: '3.11.5' - name: Setup Node.js environment uses: actions/setup-node@v2.1.2 with: - node-version: 14.x + node-version: 20.x - name: Install run: | yarn install + yarn electron-rebuild node scripts/download-pandoc.js + node scripts/download-plantuml.js - name: Set env if: matrix.platform == 'mac' run: | echo "APPLEID=${{ secrets.APPLEID }}" >> $GITHUB_ENV echo "APPLEIDPASS=${{ secrets.APPLEIDPASS }}" >> $GITHUB_ENV + echo "TEAMID=${{ secrets.TEAMID }}" >> $GITHUB_ENV echo "CSC_LINK=${{ secrets.CSC_LINK }}" >> $GITHUB_ENV echo "CSC_KEY_PASSWORD=${{ secrets.CSC_KEY_PASSWORD }}" >> $GITHUB_ENV @@ -48,25 +59,83 @@ jobs: run: | yarn build - - name: Electron-Win-Linux - if: matrix.platform != 'mac' + - name: Electron-Win + if: matrix.platform == 'win' run: | yarn run electron-builder --${{ matrix.platform }} -p never | sed 's/identityName=.*$//' + if (!(Test-Path out/win-unpacked/resources/app.asar.unpacked/node_modules/node-pty/build/Release/pty.node)) { throw 'node-pty not exist' } - name: Electron-Mac if: matrix.platform == 'mac' run: | + sh ./scripts/download-ripgrep.sh yarn run electron-builder --${{ matrix.platform }} --x64 -p never | sed 's/identityName=.*$//' - cp ./files/*pty.node ./node_modules/node-pty/build/Release/pty.node + find ./out -regex '.*app.asar.unpacked/node_modules/node-pty/build/Release/pty.node$' | grep pty.node + mv out/latest-mac.yml out/latest-mac-x64.yml + yarn electron-rebuild --arch=arm64 sed -i '' 's/out\/mac\/Yank Note.app/out\/mac-arm64\/Yank Note.app/' electron-builder.json yarn run electron-builder --${{ matrix.platform }} --arm64 -p never | sed 's/identityName=.*$//' + mv out/latest-mac.yml out/latest-mac-arm64.yml + cat out/latest-mac-arm64.yml out/latest-mac-x64.yml | sed '9,13d' > out/latest-mac.yml + + - name: Electron-Linux + if: matrix.platform == 'linux' + run: | + yarn run electron-builder --${{ matrix.platform }} -p never | sed 's/identityName=.*$//' + find ./out -regex '.*app.asar.unpacked/node_modules/node-pty/build/Release/pty.node$' | grep pty.node + + - name: Electron-Linux-Arm64 + if: matrix.platform == 'linux' && matrix.os != 'ubuntu-18.04' + run: | + export npm_config_arch=arm64 + node "./node_modules/@vscode/ripgrep/lib/postinstall.js" --force + node scripts/download-pandoc.js --force-arm64 + yarn electron-rebuild --arch=arm64 + yarn run electron-builder --${{ matrix.platform }} --arm64 -p never | sed 's/identityName=.*$//' + + - name: Rename Artifact for Ubuntu-18.04 + if: matrix.os == 'ubuntu-18.04' + run: | + mv out/*.deb out/`basename -s .deb out/*.deb`-ubuntu-18.04.deb + rm out/*.AppImage + + - name: Setup Python environment + uses: actions/setup-python@v4 + with: + python-version: '3.9' + + - name: Install coscmd + if: matrix.platform != 'win' && contains(github.ref_name, '-next-') != true + env: + COS_SECRET_ID: ${{ secrets.COS_SECRET_ID }} + COS_SECRET_KEY: ${{ secrets.COS_SECRET_KEY }} + COS_BUCKET: ${{ secrets.COS_BUCKET }} + run: | + pip install coscmd + coscmd config -a $COS_SECRET_ID -s $COS_SECRET_KEY -b $COS_BUCKET -e cos.accelerate.myqcloud.com + + - name: Install coscmd for Windows + if: matrix.platform == 'win' && contains(github.ref_name, '-next-') != true + shell: cmd + env: + COS_SECRET_ID: ${{ secrets.COS_SECRET_ID }} + COS_SECRET_KEY: ${{ secrets.COS_SECRET_KEY }} + COS_BUCKET: ${{ secrets.COS_BUCKET }} + run: | + pip install --upgrade --no-cache-dir coscmd + coscmd config -a "%COS_SECRET_ID%" -s "%COS_SECRET_KEY%" -b "%COS_BUCKET%" -e cos.accelerate.myqcloud.com + + - name: Upload to COS + if: contains(github.ref_name, '-next-') != true + run: | + coscmd upload -r out / --include out/Yank-Note*.*,out/latest*.yml - name: GH Release - uses: softprops/action-gh-release@v0.1.5 + uses: softprops/action-gh-release@v2.0.5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - prerelease: false + prerelease: true files: | out/Yank-Note*.* out/latest*.yml diff --git a/.gitignore b/.gitignore index 3d41d871d..1891234cd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,8 @@ nohup.out *.log node_modules dist -vs +src/renderer/public/vs +src/renderer/public/embed out bin # .yarnrc @@ -11,3 +12,5 @@ b.bat *.pfx .DS_Store .env +docs +.idea/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..e69de29bb diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 000000000..28c6892a3 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn run commitlint --edit "$1" diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 000000000..0f04c9356 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,5 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn run lint +yarn run test diff --git a/.node-version b/.node-version new file mode 100644 index 000000000..fb3e6603b --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +v18.20.2 diff --git a/.typedoc.json b/.typedoc.json new file mode 100644 index 000000000..d06337412 --- /dev/null +++ b/.typedoc.json @@ -0,0 +1,16 @@ +{ + "out": "docs", + "name": "Yank Note Api", + "includeVersion": true, + "entryPointStrategy": "expand", + "readme": "none", + "entryPoints": [ + "src/share/i18n", + "src/renderer/core", + "src/renderer/context", + "src/renderer/support", + "src/renderer/services", + "src/renderer/utils", + "src/renderer/types.ts" + ] +} diff --git a/LICENSE b/LICENSE index f288702d2..0ad25db4b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies @@ -7,17 +7,15 @@ Preamble - The GNU General Public License is a free, copyleft license for -software and other kinds of works. + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to +our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. +software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you @@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. The precise terms and conditions for copying, distribution and modification follow. @@ -72,7 +60,7 @@ modification follow. 0. Definitions. - "This License" refers to version 3 of the GNU General Public License. + "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. @@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. - 13. Use with the GNU Affero General Public License. + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single +under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General +Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published +GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's +versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. @@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. + GNU Affero General Public License for more details. - You should have received a copy of the GNU General Public License + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see +For more information on this, and how to apply and follow the GNU AGPL, see . - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/README.md b/README.md index 88824ffa2..e3de84179 100644 --- a/README.md +++ b/README.md @@ -1,486 +1,94 @@ # Yank Note -> 一款面向程序员的 Markdown 笔记应用 **[在线体验>>>](https://yn-phi.vercel.app/)** -[![Download](./help/mas_en.svg)](https://apps.apple.com/cn/app/yank-note/id1551528618) +A **highly extensible** Markdown editor, designed for productivity. **[Download](https://github.com/purocean/yn/releases)** | **[Try it Online >>>](https://demo.yank-note.com/)** -[toc]{level: [2]} - -![截图](./help/0.png) - -## 特色 -+ **使用方便**:使用 Monaco 编辑器(VSCode 编辑器内核)编辑,针对 Markdown 文件添加了快捷键和补全规则 -+ **兼容性强**:资源保存本地,Markdown 文件可简单处理离线工作;拓展功能尽量用 Markdown 原有的语法实现 -+ **高拓展性**:可在文档中嵌入小工具、可运行的代码块、表格、Plantuml 图形、Drawio 图形等、可自定义插件拓展编辑器功能 -+ **支持加密**:用来保存账号等隐私文件,文件可单独设置密码 - -## 注意事项 -+ Yank Note 是一款**针对程序员**的 Markdown 编辑器,目标应用场景为在本机写文章、日志、笔记,编写小工具。 -+ 为了更高的拓展性和方便性,Yank Note 牺牲了安全防护(命令执行,任意文件读写)。如果要用它打开外来 Markdown 文件,**请务必仔细甄别文件内容是值得信任的**。 -+ 如果要改造为对外的 Web 服务,请运行在隔离可控的环境下,注意应用安全。 -+ 加密文件的加密解密操作均在前端完成,请**务必牢记自己的密码**。一旦密码丢失,就只能暴力破解了。 - -## Yank-Note V3 开发计划 -V3 核心目标是重构代码,提升应用健壮性、可拓展性、Markdown 渲染性能 - -[V3 项目看板](https://github.com/purocean/yn/projects/5) - -+ [x] 使用 Vite 构建 -+ [x] 优化 Markdown 渲染性能,支持 Vue 组件方式拓展功能 -+ [x] 重构 Electron 代码 -+ [x] 重构业务逻辑,和组件解耦 -+ [ ] 重构快捷键处理层,支持自定义快捷键 -+ [ ] 其他 V2 未完成的功能 -+ [ ] 完善自定义插件文档 - -## 特色功能 -部分功能需要相关快捷键配合,可参考*特色功能说明* - -+ 同步滚动:编辑区和预览区同步滚动,预览区可独立滚动 -+ 目录大纲:预览区目录大纲快速跳转 -+ 文件加密:以 `.c.md` 结尾的文件视为加密文件 -+ 自动保存:文件编辑后自动保存,未保存文件橙色标题栏提醒(加密文档不自动保存) -+ 编辑优化:列表自动补全 -+ 粘贴图片:可快速粘贴剪切板里面的图片,可作为文件或 Base64 形式插入 -+ 嵌入附件:可以添加附件到文档,点击在系统中打开 -+ 代码运行:支持运行 JavaScript、PHP、nodejs、Python、bash 代码 -+ 待办列表:支持显示文档中的待办进度,点击可快速切换待办状态 -+ 快速打开:可使用快捷键打开文件切换面板,以便快捷打开文件,标记的文件,全文搜索文件内容 -+ 内置终端:支持在编辑器打开终端,快速切换当前工作目录 -+ 公式解析:支持输入 katex 公式代码 -+ 样式风格:Markdown 使用 GitHub 风格样式和特性 -+ 数据仓库:可定义多个数据位置以便文档分类 -+ 外链转换:将外链或 BASE64 图片转换为本地图片 -+ HTML 解析:可以直接在文档里面使用 HTML 代码,也可以使用快捷键粘贴复制 HTML 为 Markdown -+ docx 导出:后端使用 pandoc 做转换器 -+ TOC 支持:生成 TOC 在需要生成目录的地方写入 `[toc]{type: "ol", level: [1,2,3]}` 即可 -+ 编辑表格单元格:双击表格单元格即可快速编辑 -+ 复制标题链接:复制标题链接路径到剪切板,便于插入到其他文件 -+ 嵌入小工具:文档支持内嵌 HTML 小工具 -+ 嵌入 Plantuml 图形:需要安装 Java,graphviz -+ 嵌入 drawio 图形:文档支持内嵌 drawio 图形 -+ 嵌入 ECharts 图形:在文档中嵌入 Echarts 图形 -+ 嵌入 Mermaid 图形:在文档中嵌入 Mermaid 图形 -+ 嵌入 Luckysheet 表格:在文档中嵌入 Luckysheet 表格 -+ 嵌套列表转脑图展示:可将嵌套列表用脑图的方式展示 -+ 元素属性书写:可自定义元素的任意属性 -+ 表格解析增强:表格支持表格标题多行文本,列表等特性 -+ 文档交叉链接跳转:支持在文档中引入其他文档,互相跳转 -+ 脚注功能:支持在文档中书写脚注 -+ 自定义插件:支持编写 JavaScript 插件拓展编辑器功能。插件放置在 `主目录/plugins` 中,文档待完善 - -## 界面截图 -![截图](./help/3.png) -![截图](./help/5.png) -![截图](./help/1.png) -![截图](./help/2.png) -![截图](./help/4.gif) - -## 更新日志 -[最新发布](https://github.com/purocean/yn/releases) - -### [v3.3.3](https://github.com/purocean/yn/releases/tag/v3.3.3) 2021-07-13 -1. 嵌入文档表格增加统计栏 -2. 修复表格保存校验问题 -3. 修复标签颜色不正确问题 - -### [v3.3.2](https://github.com/purocean/yn/releases/tag/v3.3.2) 2021-07-13 -1. 增加浅色主题 -2. 增加 Luckysheet 表格嵌入 -3. 优化应用窗口使用体验 - -### [v3.2.2](https://github.com/purocean/yn/releases/tag/v3.2.2) 2021-07-09 -1. 优化文件切换体验,降低闪烁 -2. HTML 小工具增加 ctx -3. 修复在终端中运行代码快捷键不正确 -4. 修复本文档调整锚点不工作问题 - -### [v3.2.1](https://github.com/purocean/yn/releases/tag/v3.2.1) 2021-07-08 -1. 运行代码功能支持运行浏览器 JS 代码 -2. 修正 Windows 更新报错问题 -3. 修复编辑器菜单“终端运行”菜单行为 -4. 调整标题仓库名展示位置 - -### [v3.2.0](https://github.com/purocean/yn/releases/tag/v3.2.0) 2021-07-08 -1. 编辑器增加右键菜单 -2. 增加 Markdown 语法补全 -3. 修复 Mermaid 图形编辑不能及时更新问题 -4. 插件可拓展 Monaco Editor 功能 -5. 重构编辑器相关代码 - -### [v3.1.2](https://github.com/purocean/yn/releases/tag/v3.1.2) 2021-07-06 -1. 插件 ctx 新增 api 接口 -2. 调整运行代码样式 - -### [v3.1.1](https://github.com/purocean/yn/releases/tag/v3.1.1) 2021-07-05 -1. 增加图片预览功能 - -### [v3.1.0](https://github.com/purocean/yn/releases/tag/v3.1.0) 2021-07-05 -1. 增加转换文档的提示 -2. 调整标题栏文件保存状态展示 -3. 重构业务逻辑,和组件解耦 - -### [v3.0.3](https://github.com/purocean/yn/releases/tag/v3.0.3) 2021-06-30 -1. 优化添加仓库交互 - -### [v3.0.2](https://github.com/purocean/yn/releases/tag/v3.0.2) 2021-06-28 -1. 调整标题保存状态 -2. 修复可能不能打开终端问题 - -### [v3.0.1](https://github.com/purocean/yn/releases/tag/v3.0.1) 2021-06-27 -1. 修复 Electron Scheme 模式下可能上传文件不成功问题 - -### [v3.0.0](https://github.com/purocean/yn/releases/tag/v3.0.0) 2021-06-27 -1. 大幅优化 Markdown 渲染性能,编辑更流畅 -2. 修复部分遗留问题,增强 Katex 公式渲染,文件相对路径解析 -3. 新增工具菜单 -4. 修复 Ubuntu 上不展示应用图标问题 - -
-展开查看更多版本记录 - -### [v2.9.10](https://github.com/purocean/yn/releases/tag/v2.9.10) 2021-06-16 -1. 增加双击编辑表格单元格功能 - -### [v2.9.9](https://github.com/purocean/yn/releases/tag/v2.9.9) 2021-06-10 -1. 修复 Scheme 模式下终端不能使用问题 - -### [v2.9.8](https://github.com/purocean/yn/releases/tag/v2.9.8) 2021-06-10 -1. 应用中打开页面增加 Scheme 模式 - -### [v2.9.7](https://github.com/purocean/yn/releases/tag/v2.9.7) 2021-06-09 -1. 修复在终端中打开路径错误问题 - -### [v2.9.6](https://github.com/purocean/yn/releases/tag/v2.9.6) 2021-06-07 -1. 修正 macOS 更新升级问题 - -### [v2.9.5](https://github.com/purocean/yn/releases/tag/v2.9.5) 2021-06-07 -1. 新增窗口应用菜单 -2. 增加添加仓库提示,弃用默认仓库 - -### [v2.9.4](https://github.com/purocean/yn/releases/tag/v2.9.4) 2021-06-06 -1. 优化 macOS 上标题栏使用体验 -2. 更换 macOS 应用图标 - -### [v2.9.3](https://github.com/purocean/yn/releases/tag/v2.9.3) 2021-06-04 -1. 关闭全部标签时候,忽略固定的标签 -2. 修正某些情况下标签排序不正确问题 - -### [v2.9.2](https://github.com/purocean/yn/releases/tag/v2.9.2) 2021-06-03 -1. 新增固定标签页功能 - -### [v2.9.1](https://github.com/purocean/yn/releases/tag/v2.9.1) 2021-06-02 -1. 新增脑图保留上次使用布局 -2. 修正 macOS 更新升级错误问题 - -### [v2.9.0](https://github.com/purocean/yn/releases/tag/v2.9.0) 2021-05-29 -1. 新增设置面板,更方便添加仓库 -2. 微调部分控件的颜色和动画速度 - -### [v2.8.3](https://github.com/purocean/yn/releases/tag/v2.8.3) 2021-05-29 -1. 修正长时间运行后静态文件不能访问问题 -2. 修正应用选中文字颜色不正确问题 -3. 应用增加编辑菜单,以支持 macOS 上的复制粘贴快捷键 -4. 微调滚动条样式 - -### [v2.8.2](https://github.com/purocean/yn/releases/tag/v2.8.2) 2021-05-09 -1. 修正快捷键判断问题 -2. 升级 Electron 版本到 11.4.5 - -### [v2.8.1](https://github.com/purocean/yn/releases/tag/v2.8.1) 2021-04-28 -1. 修正目录树菜单不正确问题 -2. 修正状态栏菜单无子菜单不能点击问题 - -### [v2.8.0](https://github.com/purocean/yn/releases/tag/v2.8.0) 2021-04-27 -1. 增加自定义插件功能 -2. 微调窗口管理逻辑 - -### [v2.7.2](https://github.com/purocean/yn/releases/tag/v2.7.2) 2021-04-09 -1. 优化 macOS 上的窗口体验 - -### [v2.7.1](https://github.com/purocean/yn/releases/tag/v2.7.1) 2021-04-08 -1. 升级 Electron 到 11.4.2 -2. 增加 Mac arm64 打包 - -### [v2.6.1](https://github.com/purocean/yn/releases/tag/v2.6.1) 2021-03-04 -1. 修正一点界面问题 -2. 调整 macOS 升级逻辑 - -### [v2.6.0](https://github.com/purocean/yn/releases/tag/v2.6.0) 2021-03-04 -1. 内部功能插件化,增强拓展性 -2. 微调界面样式 -3. 修复复制代码快捷键不正确问题 - -### [v2.5.5](https://github.com/purocean/yn/releases/tag/v2.5.5) 2021-02-03 -1. 调整预览文字选择颜色 - -### [v2.5.4](https://github.com/purocean/yn/releases/tag/v2.5.4) 2021-01-31 -1. 调整 macOS 上应用边框样式 -2. macOS 打包增加签名公证 -3. 调整打包流程 -4. 替换 plantuml 库 - -### [v2.5.1](https://github.com/purocean/yn/releases/tag/v2.5.1) 2021-01-17 -1. 支持 macOS -2. 调整部分快捷键 - -### [v2.4.11](https://github.com/purocean/yn/releases/tag/v2.4.11) 2020-12-21 -1. 修复不能导出 docx 问题 -2. 修复大纲目录高度不正确 - -### [v2.4.10](https://github.com/purocean/yn/releases/tag/v2.4.10) 2020-12-16 -1. 优化脑图使用体验 - -### [v2.4.9](https://github.com/purocean/yn/releases/tag/v2.4.9) 2020-12-15 -1. 增加大纲列表脑图展示功能 - -### [v2.4.7](https://github.com/purocean/yn/releases/tag/v2.4.7) 2020-12-02 -1. 修复编辑表格跨列单元格问题 - -### [v2.4.6](https://github.com/purocean/yn/releases/tag/v2.4.6) 2020-11-26 -1. 增加编辑单元格内容功能 - -### [v2.4.5](https://github.com/purocean/yn/releases/tag/v2.4.5) 2020-11-26 -1. 移除代码表格的悬停样式 - -### [v2.4.4](https://github.com/purocean/yn/releases/tag/v2.4.4) 2020-11-25 -1. 更改 TOC 标号样式 - -### [v2.4.3](https://github.com/purocean/yn/releases/tag/v2.4.3) 2020-11-25 -1. 表格新增悬停样式:行号,突出当前行 - -### [v2.4.2](https://github.com/purocean/yn/releases/tag/v2.4.2) 2020-11-20 -1. 新增同步渲染按钮 -2. 调整打印样式 - -### [v2.4.1](https://github.com/purocean/yn/releases/tag/v2.4.1) 2020-10-27 -1. 在 Electron 环境中开启缩放页面功能 +[![Download](./help/mas_en.svg?.inline)](https://apps.apple.com/cn/app/yank-note/id1551528618) [Not recommended](https://github.com/purocean/yn/issues/65#issuecomment-1065799677) -### [v2.4.0](https://github.com/purocean/yn/releases/tag/v2.4.0) 2020-10-26 -1. Vue 框架升级到 3.0 -2. 升级 Electron 版本 -3. 升级前端依赖,更好的支持 Mermaid 图形 +English | [中文说明](./README_ZH-CN.md) | [Русский](./README_RU.md) -### [v2.3.8](https://github.com/purocean/yn/releases/tag/v2.3.8) 2020-09-01 -1. 增加开机自动启动功能 - -### [v2.3.7](https://github.com/purocean/yn/releases/tag/v2.3.7) 2020-08-03 -1. 优化预览鼠标事件响应 - -### [v2.3.6](https://github.com/purocean/yn/releases/tag/v2.3.6) 2020-06-30 -1. 升级 Electron 到 9.0.5 - -### [v2.3.5](https://github.com/purocean/yn/releases/tag/v2.3.5) 2020-06-29 -1. 增加脚注功能 - -### [v2.3.4](https://github.com/purocean/yn/releases/tag/v2.3.4) 2020-06-28 -1. 优化图片相对链接解析 -2. 优化转换外链图片为本地图片功能 - -### [v2.3.3](https://github.com/purocean/yn/releases/tag/v2.3.3) 2020-06-11 -1. 修正标题过长导致大纲目录样式异常 - -### [v2.3.2](https://github.com/purocean/yn/releases/tag/v2.3.2) 2020-04-27 -1. 调整启动命令行参数 - -### [v2.3.1](https://github.com/purocean/yn/releases/tag/v2.3.1) 2020-04-27 -1. 增加配置监听端口命令行参数 `--port=8080` - -### [v2.3.0](https://github.com/purocean/yn/releases/tag/v2.3.0) 2020-04-27 -1. 增加启动命令行参数 - -### [v2.2.11](https://github.com/purocean/yn/releases/tag/v2.2.11) 2020-04-20 -1. Drawio 文件渲染增加翻页按钮 - -### [v2.2.10](https://github.com/purocean/yn/releases/tag/v2.2.10) 2020-04-07 -1. 新增粘贴图片为 Base64 形式快捷键 `Ctrl + B + V` -2. 更改粘贴富文本为 Markdown 快捷键为 `Ctrl + M + V` - -### [v2.2.9](https://github.com/purocean/yn/releases/tag/v2.2.9) 2020-03-17 -1. 修复公式解析问题 - -### [v2.2.8](https://github.com/purocean/yn/releases/tag/v2.2.8) 2020-03-13 -1. 增加切换编辑器标签快捷键 `Ctrl + Alt + Left/Right` - -### [v2.2.7](https://github.com/purocean/yn/releases/tag/v2.2.7) 2020-01-19 -1. 调整渲染的表格宽度 - -### [v2.2.6](https://github.com/purocean/yn/releases/tag/v2.2.6) 2020-01-16 -1. 修复插入文档名称问题 - -### [v2.2.5](https://github.com/purocean/yn/releases/tag/v2.2.5) 2020-01-14 -1. 修复 frontend yarn.lock 问题 - -### [v2.2.4](https://github.com/purocean/yn/releases/tag/v2.2.4) 2020-01-14 -1. 修复 frontend yarn.lock 问题 - -### [v2.2.3](https://github.com/purocean/yn/releases/tag/v2.2.3) 2020-01-13 -1. 增加复制行内代码功能 - -### [v2.2.2](https://github.com/purocean/yn/releases/tag/v2.2.2) 2019-12-27 -1. 修复快速打开面板小问题 - -### [v2.2.1](https://github.com/purocean/yn/releases/tag/v2.2.1) 2019-12-26 -1. 修复跳转中文路径处理 -1. 优化插入文档文件链接 - -### [v2.2.0](https://github.com/purocean/yn/releases/tag/v2.2.0) 2019-12-25 -1. 增加文档之间跳转功能 -1. 增加复制文档标题链接功能 -1. 调整文档插入选择面板 -1. 修复高分辨率下目录树箭头消失问题 - -### [v2.1.1](https://github.com/purocean/yn/releases/tag/v2.1.1) 2019-12-24 -1. 增加在当前目录创建文件菜单 -1. 限制快捷跳转列表数量以提高性能 -1. 标题栏最大化窗口后移除尺寸调节 - -### [v2.1.0](https://github.com/purocean/yn/releases/tag/v2.1.0) 2019-11-29 -1. 增加多标签同时打开多个文件 - -### [v2.0.2](https://github.com/purocean/yn/releases/tag/v2.0.2) 2019-11-21 -1. 修复相对链接解析 -1. 图片增加背景色便于透明图片的阅读 - -### [v2.0.1](https://github.com/purocean/yn/releases/tag/v2.0.1) 2019-11-20 -1. 增加 2.0 计划 -1. Electron 打包 -1. 增加 HTML 小工具渲染 -1. 增加特色功能说明和示例 -1. 目录树自动定位文件 -1. 目录树增加右键菜单 -1. 目录树和集成终端增加拖动调整尺寸功能 -1. 使用自定义 UI 控件代替浏览器阻塞性弹出框,优化界面样式,提升交互体验 -1. 默认仓库数据和配置改为在 `/yank-note` 下保存 -1. 重构前端代码便于拓展 -1. 前端重构文件接口 - -### [v1.23.0](https://github.com/purocean/yn/releases/tag/v1.23.0) 2019-07-09 -1. 增加转换所有外链图片到本地功能 `Ctrl + Alt + L` - -### [v1.22.0](https://github.com/purocean/yn/releases/tag/v1.22.0) 2019-05-20 -1. 增加粘贴 html 富文本功能 `Ctrl + B + V` -1. 增加插入文档快捷键 `Ctrl + Alt + I` -1. 修复 vue cli 3 打包错误 -1. 修复图片链接转义 -1. 搜索排除 node_modules -1. 上传文件目录优化 - -### [v1.21.0](https://github.com/purocean/yn/releases/tag/v1.21.0) 2019-05-03 -1. 调整抓取图片到本地的逻辑 -1. 优化目录树样式 -1. 目录树排除 node_modules -1. eslint 规则调整 - -### [v1.20.0](https://github.com/purocean/yn/releases/tag/v1.20.0) 2019-04-18 -1. 无功能变化,前端使用 vue cli 3 - -### [v1.19.0](https://github.com/purocean/yn/releases/tag/v1.19.0) 2019-04-15 -1. 增加终端打开目录功能 `Ctrl + Alt + 单击目录` -1. 增加刷新目录树功能 `Ctrl + Alt + 单击目录` - -### [v1.18.2](https://github.com/purocean/yn/releases/tag/v1.18.2) 2019-03-21 -1. 保存加密文件密码不一致时增加提示 -1. 修复样式问题 - -### [v1.18.1](https://github.com/purocean/yn/releases/tag/v1.18.1) 2019-03-01 -1. 修复目录样式 -1. 修复代码块样式 - -### [v1.18.0](https://github.com/purocean/yn/releases/tag/v1.18.0) 2019-02-28 -1. 代码块增加行号显示 -1. 支持统一文档锚点跳转 -1. 移除 `Mermaid` 支持 -1. 优化打印样式 -1. 优化行内代码样式 - -### [v1.17.0](https://github.com/purocean/yn/releases/tag/v1.17.0) 2019-02-20 -1. 支持 `ECharts` 图形 -1. `Ctrl + Alt + R` 在内置终端中运行选中代码 - -### [v1.16.2](https://github.com/purocean/yn/releases/tag/v1.16.2) 2019-02-18 -1. 文件树增加操作说明 -1. 新增/重命名文件后打开新文件 - -### [v1.16.1](https://github.com/purocean/yn/releases/tag/v1.16.1) 2019-02-17 -1. 修复打印样式 - -### [v1.16.0](https://github.com/purocean/yn/releases/tag/v1.16.0) 2019-02-16 -1. 增加 Readme 展示 -1. 处理终端退出逻辑 - -### [v1.15.1](https://github.com/purocean/yn/releases/tag/v1.15.1) 2019-02-14 -1. 更新 UI -1. 内置终端增加 windows 适配 - -### [v1.15.0](https://github.com/purocean/yn/releases/tag/v1.15.0) 2019-02-13 -1. 增加内置终端 -1. 运行代码支持在内置终端运行 - -### [v1.14.0](https://github.com/purocean/yn/releases/tag/v1.14.0) 2019-01-16 -1. 上传附件增加日期 -1. 快速跳转改用模糊搜索并高亮匹配项 - -### [v1.13.1](https://github.com/purocean/yn/releases/tag/v1.13.1) 2019-01-14 -1. 修复 hr 标签样式 - -### [v1.13.0](https://github.com/purocean/yn/releases/tag/v1.13.0) 2019-01-05 -1. 增加 toc -1. 增加返回顶部按钮 - -### [v1.12.0](https://github.com/purocean/yn/releases/tag/v1.12.0) 2019-01-03 -1. 增加连接行快捷键 `Ctrl + J` -1. 增加转换大小写快捷键 `Ctrl + K, Ctrl + U` `Ctrl + K, Ctrl + L` - -### [v1.11.0](https://github.com/purocean/yn/releases/tag/v1.11.0) 2019-01-02 -1. 切换编辑器自动换行:`Alt + W` 或点击状态栏 `切换换行` 按钮 - -### [v1.10.0](https://github.com/purocean/yn/releases/tag/v1.10.0) 2018-12-24 -1. 文件列表自然排序 -1. 文件目录增加子项目数量显示 - -### [v1.9.0](https://github.com/purocean/yn/releases/tag/v1.9.0) 2018-11-12 -1. 增加切换文档预览功能 - -### [v1.8.0](https://github.com/purocean/yn/releases/tag/v1.8.0) 2018-08-29 -1. 增加在系统中打开文件/目录功能 `Ctrl + 双击文件/目录` - -### [v1.6](https://github.com/purocean/yn/releases/tag/v1.6) 2018-08-22 -1. 修复部分样式不和谐 -1. 修复打开新文件编辑器滚动位置不正确 -1. 增加将外链或 BASE64 图片转换为本地图片功能 -1. 优化代码高亮在暗色主题下的展示 -1. 渲染链接默认在新标签打开 - -### [v1.5.2](https://github.com/purocean/yn/releases/tag/v1.5.2) 2018-08-13 -1. 优化输入数字列表体验 -1. 增加直接插入回车和Tab的快捷键 -1. 确保文件最后有空行 -1. 文件跳转按照最近打开文件排序 - -### [v1.5.1](https://github.com/purocean/yn/releases/tag/v1.5.1) 2018-08-06 -1. 修复打开上一次文件bug - -### [v1.5](https://github.com/purocean/yn/releases/tag/v1.5) 2018-08-06 -1. 增加状态栏 -1. 添加多仓库支持 - -### [v1.4](https://github.com/purocean/yn/releases/tag/v1.4) 2018-08-02 -1. 增加全文搜索功能 -1. 修复公式定位问题 - -### [v1.3](https://github.com/purocean/yn/releases/tag/v1.3) 2018-08-02 -1. 增加待办记录时间 -1. 增加 bat 脚本运行 -1. 优化使用体验 - -### [v1.2](https://github.com/purocean/yn/releases/tag/v1.2) 2018-07-30 -1. 增加待办进度条展示 - -### [v1.1](https://github.com/purocean/yn/releases/tag/v1.1) 2018-07-29 -1. 修复若干问题 -1. 增加附件插入 -1. 调整为暗色主题 -1. 图片新标签预览 -1. 增加文件筛选面板 Ctrl + p +[toc]{level: [2]} -
+![Screenshot](./help/1.png) + +## Highlights + +- **Easy to use:** Use *Monaco* kernel, optimize for Markdown editing, and have the same editing experience as VSCode. +- **Powerful:** Support version control; Applets, runnable code blocks, tables, PlantUML, Drawio, macro replacements, etc., can be embedded in the document; support for AI Copilot. +- **High compatibility:** Data is saved as local Markdown files, and the extension functions are implemented in the original syntax of Markdown as far as possible. +- **Plug-in extension:** Support users to write their own plug-ins to expand the functionality of the editor. +- **Encryption supported:** Use encryption to save private files such as account number, and the password can be set separately for each file. + +## Attention + +- For more extendable, Yank Note sacrifices security protection (command execution, arbitrary file reading and writing). If you want to use it to open a foreign Markdown file, ⚠️**be sure to carefully identify whether the content of the file is trustworthy**⚠️. +- The encryption and decryption of encrypted files are both completed at the front end. Please **be sure to remember your password**. Once the password is lost, it can only be cracked violently. + +## Characteristic functions + +For more information on how to use the following functions, please see [characteristic functions description](./help/FEATURES.md) + +- **Sync scrolling:** the editing area and the preview area scroll synchronously, and the preview area can be scrolled independently +- **Outline:** quickly jump to the corresponding location of the document through the directory outline in the preview area +- **Version Control:** Support backtracking document history versions +- **Encryption:** files ending with `.c.md` are treated as encrypted files +- **Auto-save:** automatically save files after editing, with orange title bar reminder for unsaved files (encrypted documents are not automatically saved) +- **Editing:** automatic completion of list +- **Paste images:** you can quickly paste pictures from the clipboard and insert them as files or Base64 +- **Embed attachments:** you can add attachments to the document and click to open them in the operating system. +- **Code running:** support to run JavaScript, PHP, nodejs, Python, bash code +- **To-do list:** support to display the to-do progress in the document. Click to quickly switch the to-do status. +- **Quickly Open:** you can use shortcut key to open the file switch panel to quickly open files, tagged files, and full-text search for file contents. +- **Integrated terminal:** support to open the terminal in the editor to quickly switch the current working directory +- **LaTeX:** support LaTeX expression +- **Style:** Markdown uses GitHub styles and features +- **Repository:** multiple data locations can be defined for document classification +- **External link conversion:** convert external link or Base64 pictures into local pictures +- HTML resolving:you can use HTML code directly in the document, or use shortcut keys to copy and paste HTML to Markdown +- **Multiple formats export:** the backend uses pandoc as converter +- **TOC:** write `[toc]{type:** "ol", level:** [1,2,3]}` to generate TOC where you need to generate a directory +- **Edit table cell:** double-click a table cell to quickly edit +- **Copy title link:** copy title link path to the clipboard for easy insertion into other files +- **Embedded Applets:** document supports embedded HTML Applets +- **Embed PlantUML graphics:** document supports embedded plantUML graphics +- **Embed drawio graphics:** document supports embedded drawio graphics +- **Embed ECharts graphics:** document supports embedded Echarts graphics +- **Embed Mermaid graphics:** document supports embedded Mermaid graphics +- **Embed Luckysheet tables:** document supports embedded Luckysheet tables +- **Mind map:** nested list can be displayed in the form of a mind map +- **Element attribute writing:** any attribute of an element can be customized +- **Table enhancement:** support table title with multiple lines of text, list and other features +- **Document link:** support to link other documents in the document and jump to each other +- **Footnote:** support writing footnotes in the document +- **Custom container:** support custom containers similar to VuePress default themes +- **Macro replacement:** support for embedded JavaScript expressions to dynamically replace document content +- **Image hosting service:** support [PicGo](https://picgo.github.io/PicGo-Doc/) image hosting service +- **OpenAI:** support for [OpenAI](https://openai.com) auto completion +- **Custom plug-ins:** support writing JavaScript plug-ins to expand editor functionality. The plug-in is placed in the `home directory/plugins`. Refer to [plug-in Development Guide](./help/PLUGIN.md) + +## Screenshots + +![Screenshot](./help/6.png) +![Screenshot](./help/7.png) +![Screenshot](./help/2.png) +![Screenshot](./help/3.png) +![Screenshot](./help/4.png) +![Screenshot](./help/5.png) + +## Changelogs + +### [v3.82.1](https://github.com/purocean/yn/releases/tag/v3.82.1) 2025-03-30 + +[Windows](https://github.com/purocean/yn/releases/download/v3.82.1/Yank-Note-win-x64-3.82.1.exe) | [macOS arm64](https://github.com/purocean/yn/releases/download/v3.82.1/Yank-Note-mac-arm64-3.82.1.dmg) | [macOS x64](https://github.com/purocean/yn/releases/download/v3.82.1/Yank-Note-mac-x64-3.82.1.dmg) | [Linux AppImage](https://github.com/purocean/yn/releases/download/v3.82.1/Yank-Note-linux-x86_64-3.82.1.AppImage) | [Linux deb](https://github.com/purocean/yn/releases/download/v3.82.1/Yank-Note-linux-amd64-3.82.1.deb) + +1. feat: Added font configuration for the preview area +2. feat: Restrict Markdown syntax suggestions in the editor from displaying within code fences +3. fix: Fixed the issue where exported HTML headings were not highlighted correctly in some cases +4. feat(plugin): Added the `ctx.editor.getLineLanguageId` method to retrieve the language ID of a specific line + +[More release notes](https://github.com/purocean/yn/releases) + +## Supports + +Wechat Group + + diff --git a/README_RU.md b/README_RU.md new file mode 100644 index 000000000..bb219e0ba --- /dev/null +++ b/README_RU.md @@ -0,0 +1,82 @@ +# Yank Note + +**Гибкий, расширяемый** редактор Markdown, создан для повышения производительности. **[Скачать](https://github.com/purocean/yn/releases)** | **[Попробовать онлайн >>>](https://demo.yank-note.com/)** + + +[![Скачать](./help/mas_en.svg?.inline)](https://apps.apple.com/cn/app/yank-note/id1551528618) [Не рекомендуется](https://github.com/purocean/yn/issues/65#issuecomment-1065799677) + +[English](./README.md) | [中文说明](./README_ZH-CN.md) | Русский + +[toc]{level: [2]} + +![Screenshot](./help/1.png) + +## Особенности + +- **Простота использования:** Используется ядро *Monaco*, оптимизированное для редактирования в формате Markdown, и возможности редактирования такие же, как у VSCode. +- **Мощный:** Поддержка контроля версий; в документ можно встраивать апплеты, запускаемые блоки кода, таблицы, PlantUML, Drawio, макрозамены и т.д.; поддержка автодополнения AI Copilot. +- **Высокая совместимость:** Данные сохраняются в виде локальных файлов Markdown, а функции расширения реализованы в оригинальном синтаксисе Markdown, насколько это возможно. +- **Расширение плагинов:** Поддержка пользователей в написании собственных плагинов для расширения функциональности редактора. +- **Поддержка шифрования:** Используйте шифрование для сохранения приватных файлов, таких как номер счета, а пароль может быть установлен отдельно для каждого файла. + +## Внимание + +- Ради большей расширяемости Yank Note жертвует защитой безопасности (выполнение команд, произвольное чтение и запись файлов). Если вы хотите использовать его для открытия постороннего файла Markdown, ⚠️**убедитесь, что содержимое файла заслуживает доверия**⚠️. +- Шифрование и дешифрование зашифрованных файлов выполняется на клиентской стороне. Пожалуйста, **убедитесь, что вы помните свой пароль**. Если пароль утерян, его можно только взломать. + +## Основнве функции + +Более подробную информацию об использовании следующих функций см. в разделе [описание основных функций](./help/FEATURES.md) + +- **Синхронная прокрутка:** область редактирования и область предварительного просмотра прокручиваются синхронно, а область предварительного просмотра можно прокручивать независимо +- **Конспект:** быстрый переход к соответствующему месту документа через конспект в области предварительного просмотра +- **Управление версиями:** поддержка истории документа +- **Шифрование:** файлы, заканчивающиеся на `.c.md`, обрабатываются как зашифрованные +- **Автосохранение:** автоматическое сохранение файлов после редактирования с напоминанием в строке заголовка для несохраненных файлов (зашифрованные документы не сохраняются автоматически) +- **Редактирование:** автоматическое заполнение списка +- **Вставка изображений:** вы можете быстро вставлять изображения из буфера обмена и вставлять их как файлы или Base64 +- **Вложения:** вы можете добавлять вложения в документ и открывать их в операционной системе щелчком мыши +- **Выполнение кода:** поддержка запуска JavaScript, PHP, nodejs, Python, кода bash +- **Список задач:** поддержка отображения хода выполнения задач в документе. Щелкните, чтобы быстро переключить статус задачи +- **Быстрое открытие:** вы можете использовать сочетание клавиш для открытия панели переключения файлов, чтобы быстро открывать файлы, помеченные файлы и выполнять полнотекстовый поиск по содержимому файлов +- **Интегрированный терминал:** поддержка открытия терминала в редакторе для быстрого переключения текущего рабочего каталога +- **LaTeX:** поддержка выражений LaTeX +- **Стили:** Markdown использует стили и функции GitHub +- **Репозиторий:** можно определить несколько расположений данных для организации документов +- **Преобразование внешних ссылок:** преобразование внешних ссылок или изображений Base64 в локальные изображения +- **Разрешение HTML:** вы можете использовать HTML-код непосредственно в документе или использовать сочетания клавиш для копирования и вставки HTML в Markdown +- **Экспорт в несколько форматов:** backend использует pandoc в качестве конвертера +- **TOC:** напишите `[toc]{type:** "ol", level:** [1,2,3]}` для генерации TOC (Table Of Content, оглавление), где вам нужно сгенерировать его +- **Изменить ячейку таблицы:** дважды щелкните ячейку таблицы для быстрого редактирования +- **Копирование ссылки на заголовок:** копирование пути ссылки на заголовок в буфер обмена для легкой вставки в другие файлы +- **Встроенные апплеты:** документ поддерживает встроенные HTML-аплеты +- **Встроенная графика PlantUML:** документ поддерживает встроенную графику plantUML +- **Встроенная графика drawio:** документ поддерживает встроенную графику drawio +- **Встроенная графика ECharts:** документ поддерживает встроенную графику Echarts +- **Встроенная графика Mermaid:** документ поддерживает встроенную графику Mermaid +- **Встроенные таблицы Luckysheet:** документ поддерживает встроенные таблицы Luckysheet +- **Mind map:** вложенный список может быть отображен в виде интеллект-карты +- **Запись атрибутов элемента:** любой атрибут элемента может быть настроен +- **Улучшение таблиц:** поддержка заголовка таблицы с несколькими строками текста, списка и других функций +- **Ссылка на документ:** поддержка ссылок на другие документы в документе и перехода друг к другу +- **Сноска:** поддержка написания сносок в документе +- **Пользовательский контейнер:** поддержка пользовательских контейнеров, аналогичных VuePress по умолчанию темы +- **Макросы:** поддержка встроенных выражений JavaScript для динамической замены содержимого документа +- **Служба хостинга изображений:** поддержка службы хостинга изображений [PicGo](https://picgo.github.io/PicGo-Doc/) +- **OpenAI:** поддержка автодополнения [OpenAI](https://openai.com) +- **Пользовательские плагины:** поддержка написания плагинов JavaScript для расширения функциональности редактора. Плагин находится в `home directory/plugins`. См. [руководство по разработке плагинов](./help/PLUGIN.md) + +## Скриншоты + +![Screenshot](./help/6.png) +![Screenshot](./help/7.png) +![Screenshot](./help/2.png) +![Screenshot](./help/3.png) +![Screenshot](./help/4.png) +![Screenshot](./help/5.png) + +## Поддержка + +Wechat Group + + diff --git a/README_ZH-CN.md b/README_ZH-CN.md new file mode 100644 index 000000000..bba86379d --- /dev/null +++ b/README_ZH-CN.md @@ -0,0 +1,94 @@ +# Yank Note + +一款**强大可扩展**的 Markdown 编辑器,为生产力而生。**[全平台下载](https://github.com/purocean/yn/releases)** | **[在线体验>>>](https://demo.yank-note.com/)** + +[![Download](./help/mas_en.svg?.inline)](https://apps.apple.com/cn/app/yank-note/id1551528618) [Mac App Store 版本说明](https://github.com/purocean/yn/issues/65#issuecomment-1065799677) + +[English](./README.md) | 中文说明 | [Русский](./README_RU.md) + +[toc]{level: [2]} + +![截图](./help/1_ZH-CN.png) + +## 特色 + +- **使用方便**:使用 Monaco 内核,专为 Markdown 优化,拥有和 VSCode 一样的编辑体验。 +- **功能强大**:支持历史版本回溯;可在文档中嵌入小工具、可运行的代码块、表格、PlantUML 图形、Drawio 图形、宏替换等;支持 AI Copilot。 +- **兼容性强**:数据保存为本地 Markdown 文件;拓展功能尽量用 Markdown 原有的语法实现。 +- **插件拓展**:支持用户编写自己的插件来拓展编辑器的功能。 +- **支持加密**:用来保存账号等隐私文件,文件可单独设置密码。 + +## 注意事项 + +- 为了更高的拓展性和方便性,Yank Note 牺牲了安全防护(命令执行,任意文件读写)。如果要用它打开外来 Markdown 文件,⚠️**请务必仔细甄别文件内容是值得信任的**⚠️。 +- 加密文件的加密解密操作均在前端完成,请**务必牢记自己的密码**。一旦密码丢失,就只能暴力破解了。 + +## 特色功能 + +以下功能具体使用可参考[特色功能说明](./help/FEATURES_ZH-CN.md) + +- **同步滚动:** 编辑区和预览区同步滚动,预览区可独立滚动 +- **目录大纲:** 预览区目录大纲快速跳转 +- **版本管理:** 支持回溯文档历史版本 +- **文件加密:** 以 `.c.md` 结尾的文件视为加密文件 +- **自动保存:** 文件编辑后自动保存,未保存文件橙色标题栏提醒(加密文档不自动保存) +- **编辑优化:** 列表自动补全 +- **粘贴图片:** 可快速粘贴剪切板里面的图片,可作为文件或 Base64 形式插入 +- **嵌入附件:** 可以添加附件到文档,点击在系统中打开 +- **代码运行:** 支持运行 JavaScript、PHP、nodejs、Python、bash 代码 +- **待办列表:** 支持显示文档中的待办进度,点击可快速切换待办状态 +- **快速打开:** 可使用快捷键打开文件切换面板,以便快捷打开文件,标记的文件,全文搜索文件内容 +- **内置终端:** 支持在编辑器打开终端,快速切换当前工作目录 +- **公式解析:** 支持输入 LaTeX 公式代码 +- **样式风格:** Markdown 使用 GitHub 风格样式和特性 +- **数据仓库:** 可定义多个数据位置以便文档分类 +- **外链转换:** 将外链或 BASE64 图片转换为本地图片 +- **HTML 解析:** 可以直接在文档里面使用 HTML 代码,也可以使用快捷键粘贴复制 HTML 为 Markdown +- **docx 导出:** 后端使用 pandoc 做转换器 +- **TOC 支持:** 生成 TOC 在需要生成目录的地方写入 `[toc]{type: "ol", level: [1,2,3]}` 即可 +- **编辑表格单元格:** 双击表格单元格即可快速编辑 +- **复制标题链接:** 复制标题链接路径到剪切板,便于插入到其他文件 +- **嵌入小工具:** 文档支持内嵌 HTML 小工具 +- **嵌入 PlantUML 图形:** 在文档内内嵌 PlantUML 图形 +- **嵌入 drawio 图形:** 在文档中内嵌 drawio 图形 +- **嵌入 ECharts 图形:** 在文档中嵌入 Echarts 图形 +- **嵌入 Mermaid 图形:** 在文档中嵌入 Mermaid 图形 +- **嵌入 Luckysheet 表格:** 在文档中嵌入 Luckysheet 表格 +- **嵌套列表转脑图展示:** 可将嵌套列表用脑图的方式展示 +- **元素属性书写:** 可自定义元素的任意属性 +- **表格解析增强:** 表格支持表格标题多行文本,列表等特性 +- **文档交叉链接跳转:** 支持在文档中链接其他文档,互相跳转 +- **脚注功能:** 支持在文档中书写脚注 +- **容器块:** 支持类似 VuePress 默认主题的自定义容器 +- **宏替换:** 支持内嵌 JavaScript 表达式动态替换文档内容 +- **图床:** 支持 [PicGo](https://picgo.github.io/PicGo-Doc/) 图床 +- **OpenAI:** 支持接入 [OpenAI](https://openai.com) 自动补全 +- **自定义插件:** 支持编写 JavaScript 插件拓展编辑器功能。插件放置在 `主目录/plugins` 中。参考[插件开发指南](./help/PLUGIN_ZH-CN.md) + +## 截图 + +![截图](./help/6_ZH-CN.png) +![截图](./help/7_ZH-CN.png) +![截图](./help/2_ZH-CN.png) +![截图](./help/3_ZH-CN.png) +![截图](./help/4_ZH-CN.png) +![截图](./help/5_ZH-CN.png) + +## 更新日志 + +### [v3.82.1](https://github.com/purocean/yn/releases/tag/v3.82.1) 2025-03-30 + +[Windows](https://github.com/purocean/yn/releases/download/v3.82.1/Yank-Note-win-x64-3.82.1.exe) | [macOS arm64](https://github.com/purocean/yn/releases/download/v3.82.1/Yank-Note-mac-arm64-3.82.1.dmg) | [macOS x64](https://github.com/purocean/yn/releases/download/v3.82.1/Yank-Note-mac-x64-3.82.1.dmg) | [Linux AppImage](https://github.com/purocean/yn/releases/download/v3.82.1/Yank-Note-linux-x86_64-3.82.1.AppImage) | [Linux deb](https://github.com/purocean/yn/releases/download/v3.82.1/Yank-Note-linux-amd64-3.82.1.deb) + +1. feat: 新增预览区字体配置 +2. feat: 限制编辑器的 Markdown 语法建议在代码围栏中不显示 +3. fix: 修复某些情况下导出的 HTML 标题高亮不正确问题 +4. feat(plugin): 增加 `ctx.editor.getLineLanguageId` 方法获取某行的语言ID + +[更多发布说明](https://github.com/purocean/yn/releases) + +## 支持 + +加我微信进交流群(备注 Yank Note) + + diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 000000000..84dcb122a --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], +}; diff --git a/electron-builder.json b/electron-builder.json index b6fa77f8d..3683fb08f 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -7,13 +7,15 @@ "bin/**/*", "help/**/*", "dist/main/resources/**/*", - "node_modules/plantuml-pipe/**/*", - "node_modules/node-pty/**/*" + "node_modules/fsevents/**/*", + "node_modules/node-pty/**/*", + "node_modules/@vscode/ripgrep/**/*" ], "files": [ "./bin/**", "./help/**", - "./dist/**" + "./dist/**", + "!node_modules/plantuml-pipe/vendor/*" ], "artifactName": "Yank-Note-${os}-${arch}-${version}.${ext}", "directories": { @@ -33,7 +35,8 @@ "maintainer": "purocean ", "icon": "./build/icon.icns", "target": [ - "deb" + "deb", + "AppImage" ] }, "mac": { @@ -43,7 +46,7 @@ "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist", "binaries": [ - "out/mac/Yank Note.app/Contents/Resources/app.asar.unpacked/bin/darwin-pandoc-2.7.3" + "out/mac/Yank Note.app/Contents/Resources/app.asar.unpacked/bin/darwin-pandoc-2.14.2" ], "target": [ "dmg", @@ -53,5 +56,13 @@ "afterSign": "scripts/notarize.js", "nsis": { "perMachine": false - } + }, + "fileAssociations": [ + { + "ext": "md", + "name": "Markdown File", + "description": "Markdown File", + "role": "Editor" + } + ] } diff --git a/files/m1-11.4.2-pty.node b/files/m1-11.4.2-pty.node deleted file mode 100755 index edfc225f3..000000000 Binary files a/files/m1-11.4.2-pty.node and /dev/null differ diff --git a/help/.gitignore b/help/.gitignore index b43bf86b5..af17a53dd 100644 --- a/help/.gitignore +++ b/help/.gitignore @@ -1 +1 @@ -README.md +README*.md diff --git a/help/0.png b/help/0.png deleted file mode 100644 index 74cfd42c1..000000000 Binary files a/help/0.png and /dev/null differ diff --git a/help/1.png b/help/1.png index d06bb5b43..7e8b2e51b 100644 Binary files a/help/1.png and b/help/1.png differ diff --git a/help/1_ZH-CN.png b/help/1_ZH-CN.png new file mode 100644 index 000000000..11dbe1586 Binary files /dev/null and b/help/1_ZH-CN.png differ diff --git a/help/2.png b/help/2.png index 982ec51f6..190a80503 100644 Binary files a/help/2.png and b/help/2.png differ diff --git a/help/2_ZH-CN.png b/help/2_ZH-CN.png new file mode 100644 index 000000000..8cc52995d Binary files /dev/null and b/help/2_ZH-CN.png differ diff --git a/help/3.png b/help/3.png index b4d5c09c0..a54cb8eaf 100644 Binary files a/help/3.png and b/help/3.png differ diff --git a/help/3_ZH-CN.png b/help/3_ZH-CN.png new file mode 100644 index 000000000..e192d0fd5 Binary files /dev/null and b/help/3_ZH-CN.png differ diff --git a/help/4.gif b/help/4.gif deleted file mode 100644 index ea049ce0d..000000000 Binary files a/help/4.gif and /dev/null differ diff --git a/help/4.png b/help/4.png new file mode 100644 index 000000000..1bcad6af1 Binary files /dev/null and b/help/4.png differ diff --git a/help/4_ZH-CN.png b/help/4_ZH-CN.png new file mode 100644 index 000000000..1261b078c Binary files /dev/null and b/help/4_ZH-CN.png differ diff --git a/help/5.png b/help/5.png index 52f14ef7d..10629de4f 100644 Binary files a/help/5.png and b/help/5.png differ diff --git a/help/5_ZH-CN.png b/help/5_ZH-CN.png new file mode 100644 index 000000000..27d5ab301 Binary files /dev/null and b/help/5_ZH-CN.png differ diff --git a/help/6.png b/help/6.png new file mode 100644 index 000000000..157a1a382 Binary files /dev/null and b/help/6.png differ diff --git a/help/6_ZH-CN.png b/help/6_ZH-CN.png new file mode 100644 index 000000000..97d89d0cc Binary files /dev/null and b/help/6_ZH-CN.png differ diff --git a/help/7.png b/help/7.png new file mode 100644 index 000000000..ea88cbba3 Binary files /dev/null and b/help/7.png differ diff --git a/help/7_ZH-CN.png b/help/7_ZH-CN.png new file mode 100644 index 000000000..0cd8f87f8 Binary files /dev/null and b/help/7_ZH-CN.png differ diff --git a/help/DEVELOP.md b/help/DEVELOP.md new file mode 100644 index 000000000..e403dce0d --- /dev/null +++ b/help/DEVELOP.md @@ -0,0 +1,39 @@ +# Development environment setup guide + +English | [中文说明](./DEVELOP_ZH-CN.md) + +## Development environment setup + +### install node +### install n +n is the version control software of node. You can switch the version of node at will, because yn needs a version above 12.0 to be used +n to switch + +View the current node version: +`node –v` + +install n +`npm install -g n` + +Upgrade to the specified version/latest version (this step may take some time) Before upgrading, you can execute n ls (check the upgradeable version)`n v16.0.0` + +Or you can tell the manager to install the latest stable version +`n stable | n latest` + +### 安装依赖 +`yarn install` + +Note that sometimes all dependencies cannot be successfully installed, so you need to install them manually one by one +At this time only need `npm install package@version` as `npm install mine@2.5.2` + +The node-pty module cannot be simply installed successfully, it can be operated as described below + +### install electron-rebuild +`npm install --save-dev electron-rebuild` +### rebuild pty +`npm run rebuild-pty` + +### start dev +`npm run dev` + + diff --git a/help/DEVELOP_ZH-CN.md b/help/DEVELOP_ZH-CN.md new file mode 100644 index 000000000..1cf00bff6 --- /dev/null +++ b/help/DEVELOP_ZH-CN.md @@ -0,0 +1,56 @@ +# 开发环境搭建指南 + +[English](./DEVELOP.md) | 中文 + +## 开发环境搭建 + +### 安装node +### 安装n +n是node的版本控制软件可以随意切换node版本,因为yn需要12.0以上的版本可以用 +n进行切换 + +查看当前node版本: +`node –v` + +安装n模块 +`npm install -g n` + +升级到指定版本/最新版本(该步骤可能需要花费一些时间)升级之前,可以执行n ls (查看可升级的版本) +`n v16.0.0` + +或者你也可以告诉管理器,安装最新的稳定版本 +`n stable | n latest` + +### 安装依赖 +`yarn install` + +注意,有时并不能成功的安装所以的依赖,所以需要一个一个手动安装 +这时只需要 `npm install package@version` 如 `npm install mine@2.5.2` +有时候electron和node-pty模块不能简单的安装成功,可以像下面介绍的那样进行操作 +### 安装electron +推荐采用淘宝源进行下载 + +设置源 +`npm config set electron_mirror http://npm.taobao.org/mirrors/electron/` + +设置下载的electron版本 +`npm config set electron_custom_dir "16.0.0"` + +这里的16.0.0 是指当前node对应的版本具体的版本可以在 +http://npm.taobao.org/mirrors/electron + +指定版本安装 +npm install electron@16.0.0 + +### 安装electron-rebuild +`npm install --save-dev electron-rebuild` +### 重新编译pty +`npm run rebuild-pty` + +### 启动调试 +`npm run dev` + + + + + diff --git a/help/FEATURES.md b/help/FEATURES.md index 1e53fac19..8f6432f4a 100644 --- a/help/FEATURES.md +++ b/help/FEATURES.md @@ -1,55 +1,149 @@ -# Yank-Note 特色功能使用说明 +--- +headingNumber: true +enableMacro: true +customVar: Hello +define: + --APP_NAME--: Yank Note + --APP_VERSION--: '[= $ctx.version =]' +--- -## TOC 生成 -需要生成目录的地方写入 `[toc]{type: "ul", level: [1,2,3]}` -可以控制目录样式 `ul` 或 `ol` 和级别 +# Yank-Note Features Instructions -[toc]{type: "ol", level: [2,3]} +English | [中文](./FEATURES_ZH-CN.md) -## 系统配置 -1. 用户数据目录存放在 `/yank-note` 下面 -1. 配置文件 `` +[toc]{type: "ol", level: [2]} -## 多仓库 -1. 系统默认有一个 `main` 仓库,默认位于 `/yank-note/main` -1. 在配置文件 `` 中可以配置多个仓库,或者自定义 `main` 仓库路径 +## Data Storage -## 文件管理 -右键目录树可看到文件相关操作选项。 -进行删除文件/目录操作后,文件并没有真正删除,还可以从 `/yank-note/trash` 目录下面恢复 +`` is the directory of the current user. Example: -## 待办切换 -在预览界面打勾试试 +1. Windows: `C:\Users\` +1. Linux: `/home/` +1. Mac: `/Users/` + +The application generated data is stored in `/yank-note` dir, click the "Open Main Dir" menu in the tray to view them. + +Directory description + +1. configuration file `/yank-note/config.json` +1. export docx reference doc `/yank-note/pandoc-reference.docx` +1. document versions `/yank-note/histories` + > [!TIP] + > If you accidentally lost your document content, you can check this folder and try to recovery it. + + > [!CAUTION] + > For performance reasons, documents with more than *102400* characters will not store history. Therefore, please be careful when embedding Base64 images in documents. + +1. plug-ins `/yank-note/plugins` +1. themes `/yank-note/themes` +1. extensions `/yank-note/extensions` +1. other user data `/yank-note/data` + +## TOC Generation + +Use `[toc]{type: "ul", level: [1,2,3]}` to generate the TOC of document. + +## TODO Switch + +Click the TODO item in preview to try + [x] ~~2021-06-06 10:27~~ TEST1 + [x] ~~2021-06-06 10:27~~ TEST2 + [x] ~~2021-06-06 10:27~~ TEST3 -## 加密文档 -1. 以 `.c.md` 结尾的文档视为加密文档,可以用来保存机密的信息。 -2. 加密和解密过程均在前端完成。 -3. 请务必保管好文档密码,密码一旦丢失就只能自己暴力破解了。 +## Encrypted Document + +1. Documents ending with `.c.md` are regarded as encrypted documents and can be used to store confidential information. +2. The encryption and decryption processes are both completed at the frontend. +3. Be sure to keep the password of the encrypted document properly. Once the password is lost, it can only be cracked violently. + +## Markdown Enhance + +Type '/' in the editor to get prompts + ++ Mark: ==marked== ++ Sup: 29^th^ ++ Sub: H~2~0 ++ Footnote: footnote[^1] syntax[^2] ++ Emoji: :) :joy: ++ Abbr: + *[HTML]: Hyper Text Markup Language + *[W3C]: World Wide Web Consortium + The HTML specification is maintained by the W3C. ++ Wiki Link: Supports using `[[filename#anchor|display text]]` or `[[filename:line,column|display text]]` syntax to link documents, such as [[README#Highlights|Features]] [[README:3,4]] + +## Github Alerts + +This feature is implemented using [markdown-it-github-alerts](https://github.com/antfu/markdown-it-github-alerts), providing support for [GitHub-style alert prompts](https://github.com/orgs/community/discussions/16925). + +> [!NOTE] Note +> Highlights information that users should take into account, even when skimming. + +> [!TIP] +> Optional information to help a user be more successful. + +> [!IMPORTANT] +> Crucial information necessary for users to succeed. + +> [!WARNING] +> Critical content demanding immediate user attention due to potential risks. + +> [!CAUTION] +> Negative potential consequences of an action. + +### Element Attribute + +This feature is implemented using [markdown-it-attributes](https://github.com/purocean/markdown-it-attributes). + +- Red Text, white background, border, align text center {.bgw .text-center .with-border style="color:red"} +- Display As **Block Element**{.block} +- Escape\{style="color:red"} + +**Some built-in style classes:** + +| Class Name | Description | +| -- | -- | +| `avoid-page-break` | Avoid page breaks inside the element when printing/exporting PDF | +| `new-page` | Page break before the element when printing/exporting PDF | +| `skip-print` | Skip the element when printing/exporting PDF | +| `skip-export` | Skip the element when exporting/copying HTML | +| `inline` | The current element is displayed as an inline element | +| `block` | The current element is displayed as a block element | +| `reduce-brightness` | Reduce the brightness of the element when using dark theme | +| `bgw` | Set current element background to white | +| `copy-inner-text` | Mark "Ctrl/Cmd + left click" to copy element text | +| `wrap-code` | Applied to a code block to make it wrap | +| `text-left` | Align text left for the current element | +| `text-center` | Align text center for the current element | +| `text-right` | Align text right for the current element | +| `with-border` | Add borders to the current element | -## 脚注功能 -支持使用脚注[^1]语法[^2] +### Image Enhancement -## 嵌套列表脑图形式展示 -只需要在列表根节点加上 `{.mindmap}` 即可。 +![](mas_en.svg?.inline) -+ 中心节点{.mindmap} - + 状态可见原则 - + 环境贴切原则 - + 用户可控原则 - + 一致性原则 - + 防错原则 - + 易取原则 - + 灵活高效原则 - + 优美且简约原则 - + 容错原则 - + 人性化帮助原则 +1. If the paragraph has one image element only, the picture will be rendered as a block element and centered by default. If you want to display the image as an inline element, you can add `.inline` after the image link parameter, example is above. +1. If you want to add a white background to the image to optimize the display effect (for some transparent images), you can add `.bgw` after the image link parameter, such as: ![](mas_en.svg?.bgw) +1. You can use [markdown-it-imsize](https://github.com/tatsy/markdown-it-imsize) to set the image size. For example, this is an image with a width of 16px: ![](logo-small.png?.inline =16x) -脑图使用 [kityminder-core](https://github.com/fex-team/kityminder-core) 实现。 +## Mind Map -## Mermaid 图形解析 +Just need to add `{.mindmap}` to the end of root node of the list. + ++ Central node{.mindmap} + + [1] State Visibility + + [2] Environmental Appropriate + + [3] User Controllable + + [4] Consistency + + [5] Error Proofing + + [6] Accessibility + + [7] Agility and Efficiency + + [8] Grace and Simplicity + + [9] Fault Tolerance + + [10] Friendly Help + +Mind map is implemented using [kityminder-core](https://github.com/fex-team/kityminder-core). + +## Mermaid ```mermaid graph LR @@ -111,90 +205,170 @@ journey Sit down: 3: Me ``` -## Plantuml 图形解析 -系统需要有 Java 环境,并安装有 graphviz -示例如下 +## PlantUML + +You can *configure* the use of local endpoint or PlantUML online endpoint to generate graph in Settings. + +> [!IMPORTANT] +> If you are use local endpoint, the system needs to have a Java environment with Graphviz installed. +If it prompts an error `Cannot find Graphviz`, +Please refer to [Test your GraphViz installation](https://plantuml.com/graphviz-dot) + +The example is as follows @startuml a -> b @enduml -## 表格增强 -此功能使用 [markdown-it-multimd-table](https://github.com/RedBug312/markdown-it-multimd-table) 实现 -支持在表格中使用多行文本和列表。支持表格说明渲染 +## Table Enhancement + +This feature is implemented using [markdown-it-multimd-table](https://github.com/RedBug312/markdown-it-multimd-table). +Support the use of multiple lines of text and lists in tables. Support table description rendering. -另外您可以使用:`Ctrl/Cmd + 单击单元格` 快捷编辑表格单元格内容 +You can double-click or right-click on a cell to quickly edit its content. Here are the related shortcuts: + +- `DBLClick`: Edit cell +- `Escape`: Exit editing +- `Enter`: Confirm editing and move to the next row +- `Shift + Enter`: Confirm editing and move to the previous row +- `Cmd/Ctrl + Shift + Enter`: Confirm editing and insert a new row below +- `Tab`: Confirm editing and move to the next column +- `Shift + Tab`: Confirm editing and move to the previous column First header | Second header -------------|--------------- List: | More \ - [ ] over | data \ - several | ------ | ----- Test | Test -[测试表格] +[Test Table] + +First header | Second header +:-----------:|:--------------: +AAAAAAAAAAAA | BBBBBBBBBBBBBB +AAAAAAAAAAAA | BBBBBBBBBBBBBB +AAAAAAAAAAAA | BBBBBBBBBBBBBB +AAAAAAAAAAAA | BBBBBBBBBBBBBB +AAAAAAAAAAAA | BBBBBBBBBBBBBB +Test | Test +[Small Table] +{.small} + +| h1 | h2 | h3 | +| -- | -- | -- | +| x1 | x2 | x3 {rowspan=2 style="color:red"} | +| x4 {colspan=2} | +[Merge Cells] + +## LaTeX -## Katex 公式解析 -此功能由 [markdown-it-katex](https://github.com/waylonflinn/markdown-it-katex) 插件提供 +This feature is provided by [KaTeX](https://github.com/KaTeX/KaTeX). -$$\begin{array}{c} +$$ +\begin{array}{c} \nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} & = \frac{4\pi}{c}\vec{\mathbf{j}} \nabla \cdot \vec{\mathbf{E}} & = 4 \pi \rho \\ \nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} & = \vec{\mathbf{0}} \\ \nabla \cdot \vec{\mathbf{B}} & = 0 -\end{array}$$ +\end{array} +$$ equation | description ----------|------------ $\nabla \cdot \vec{\mathbf{B}} = 0$ | divergence of $\vec{\mathbf{B}}$ is zero $\nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} = \vec{\mathbf{0}}$ | curl of $\vec{\mathbf{E}}$ is proportional to the rate of change of $\vec{\mathbf{B}}$ -$\nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} = \frac{4\pi}{c}\vec{\mathbf{j}} \nabla \cdot \vec{\mathbf{E}} = 4 \pi \rho$ | _wha?_ +$\nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} = \frac{4\pi}{c}\vec{\mathbf{j}} \nabla \cdot \vec{\mathbf{E}} = 4 \pi \rho$ | _what?_ -## 运行代码 -支持运行 `JavaScript` `PHP` `nodejs` `Python` `bash` `bat` 代码。 -此功能执行外部命令实现,所以需要安装相应环境。 +## Code Running + +Support to run `JavaScript` `PHP` `nodejs` `Python` `bash` `bat` code. +This function is implemented by executing external commands, so the corresponding environment needs to be installed. + +The first line of the code block needs to contain the string `--run--`, an example is as follows + +The JS code runs in a Web Worker by default. If you need to run it in the main thread, you can add the `--no-worker--` parameter after the code block. -代码块第一行需要包含以 `--run--` 字符串,示例如下 ```js // --run-- await new Promise(r => setTimeout(r, 500)) +console.log('HELLOWORLD') +``` + +The code running in the main thread supports accessing the editor API using the `ctx` object, as shown in the following example. + +```js +// --run-- --no-worker-- +await new Promise(r => setTimeout(r, 500)) ctx.ui.useToast().show("info", "HELLOWORLD!") -console.log('HELLOWORD') +console.log('HELLOWORLD') +``` + +If HTML output is required, the `--output-html--` parameter can be added after the code block. + +```js +// --run-- --output-html-- +console.log(`output HTML`) ``` ```node // --run-- -console.log('HELLOWORD') +console.log('HELLOWORLD') ``` ```php // --run-- -echo 'HELLOWORD!'; +echo 'HELLOWORLD!'; ``` ```python # --run-- -print('HELLOWORD') +print('HELLOWORLD') ``` -```bash +```shell # --run-- date ``` ```bat REM --run-- -@echo HELLOWORD +@echo HELLOWORLD ``` -## 集成终端 -1. 使用 `Alt/Option + T` 或者点击状态栏 **切换终端** 菜单唤起集成终端 -1. 支持在编辑器中选中一段代码后按下 `Shift + Alt/Option + R` 直接在终端中运行命令。免去复制粘贴。 -1. 切换内置终端工作目录到当前目录 `右键目录` +```c +// --run-- gcc $tmpFile.c -o $tmpFile.out && $tmpFile.out -## 小工具 -支持在文档中嵌入 HTML 小工具。 -HTMl代码块第一行需要包含以 `--applet--` 字符串,其余字符串作为小工具标题,示例如下 +#include + +int main () { + printf("Hello, World!"); + return 0; +} +``` + +```java +// --run-- java $tmpFile.java + +class HelloWorld { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} +``` + +## Integrated Terminal + +1. Use `Alt/Option + T` or click the status bar **Switch terminal** menu to call up the integrated terminal +1. Support to select a piece of code in the editor and press `Shift + Alt/Option + R` to run the command directly in the terminal. No need to copy and paste. +1. Switch the working directory of the terminal to the current directory: `right-click directory` + +## Applets + +Support to embed HTML applets in documents. + +The first line of the HTML code block needs to contain the string `--applet--`, and the remaining strings are used as the title of the applet. + +The example is as follows: ```html @@ -207,13 +381,13 @@ function run (type) { switch (type) { case 'md5': - output.value = CryptoJS.MD5(input.value).toString().toLowerCase() + output.value = ctx.lib.cryptojs.MD5(input.value).toString().toLowerCase() break case 'sha1': - output.value = CryptoJS.SHA1(input.value).toString().toLowerCase() + output.value = ctx.lib.cryptojs.SHA1(input.value).toString().toLowerCase() break case 'sha256': - output.value = CryptoJS.SHA256(input.value).toString().toLowerCase() + output.value = ctx.lib.cryptojs.SHA256(input.value).toString().toLowerCase() break } output.focus() @@ -222,24 +396,33 @@ function run (type) {
- 输入 + Input - - + +
``` +If there is no title, there will be no outer border decoration + +```html + + +``` + +You can also *install and activate the Vue Repl extension* to write applet using Vue SFC. + +## ECharts -## ECharts 图形 -Js 代码块第一行包含以 `--echarts--` 字符串会被解析成 ECharts 图形,示例如下 +The string containing `--echarts--` in the first line of the Js code block will be resolved into ECharts graphics, the example is as follows ```js // --echarts-- -function (chart) { -chart.setOption({ + +const option = { // backgroundColor: '#2c343c', title: { @@ -247,7 +430,7 @@ chart.setOption({ left: 'center', top: 20, textStyle: { - color: '#ccc' + color: '#888' } }, @@ -266,29 +449,29 @@ chart.setOption({ }, series : [ { - name:'访问来源', + name:'referer', type:'pie', radius : '55%', center: ['50%', '50%'], data:[ - {value:335, name:'直接访问'}, - {value:310, name:'邮件营销'}, - {value:274, name:'联盟广告'}, - {value:235, name:'视频广告'}, - {value:400, name:'搜索引擎'} + {value:335, name:'Direct visit'}, + {value:310, name:'Email marketing'}, + {value:274, name:'Affiliate advertising'}, + {value:235, name:'Video advertisement'}, + {value:400, name:'Search engine'} ].sort(function (a, b) { return a.value - b.value; }), roseType: 'radius', label: { normal: { textStyle: { - color: 'rgba(255, 255, 255, 0.3)' + color: '#888' } } }, labelLine: { normal: { lineStyle: { - color: 'rgba(255, 255, 255, 0.3)' + color: '#888' }, smooth: 0.2, length: 10, @@ -310,92 +493,271 @@ chart.setOption({ } } ] -}) } -``` -## 嵌入 Draw.io 图形 -### 嵌入 xml -xml 代码块 第一行注释需要有 `--drawio--` 文字 -```xml - -7V3bd6M2E/9r/Lg5gEDgx8R2uj1nk+Y0e9qvj8SWbb5i5ALOpX99JW5GI9mxveKWTV5iSzDAzPzmphEeocnm9ZfY367v6IKEI8tYvI7QdGRZpuFh9o+PvOUjHvLygVUcLIqD9gOPwb+kPLMY3QULkggHppSGabAVB+c0isg8Fcb8OKYv4mFLGopX3forIg08zv1QHv0zWKTr4iksvB//SoLVuryyicf5zJM//3sV011UXG9koWX2l09v/JJW8aDJ2l/Ql9oQmo3QJKY0zT9tXick5Lwt2Zafd3tgtrrvmETpKSdY+QnPfrgj5R3jkJ16s+V3l74VHMH/7Pgt3Wz8eBVEI3TNZo0tk/dN9lR88EtKt/mEXU6k5DX94ofBqjhjzu6KxLW5BZnT2E8DWhzA+EbiMIhIdkx5UfZpVfzPbi1JYxqtytGHmM5JkrCzzfKAp3jEn/sWnsiGxHNrE1s4tj5A5HzGhGSZ5jNeMQOf7PEtScmGHfCY7hhmjtxVE1d/iAnTmFwMyktbwkWtZxKnAQPLdS7Z6SZYLPjcTSHqaSVnyg5dhpmCLwOmeOhmSaO0wLtpFd9v/U0QckvxlYTPhJPm7E83IT+IfczwRBbFt4xCcTNmoQ1/VzBF/Ah2qQkNaZzdLprh29vJREZGARb+NOS1NlQg5RdCNySNmTSMYtZCBf7fwPeXvY0wSxO2rtmHcTHmF2ZpVZHeQ5N9KNCpRiqSkDqLmHgJiQOuz0BEL+sgJY9bf86/vzAjrZLOQTmezeLbW10stkUWm5bM4rGCw1gDh22Jw79GzyRJaZx8GP7ahqjBjtkaex2Jvb/df5nO7q7vp9zw/fX4fXbHPkxnf8y+/fZwN7v/LnGdecst/7gtTD66+TE5JLkhMq4M23Uuk8tkokfvDeNqXP8zBTmhsSwn21AIytEgKPyRYgJr+DHBdeSHb0mQfPrmIwBCY9GymXaLvtkdPmLewdFBrBhosAD7FiQpm36gSRI8McotR937mJ+GOy68T4AfAzj2gEt0ZZfYGMC9nxjg9mABPiWpH3AxTtY0IQdy2wYBXuD6E9ZHYO1gkJHYLcJ6/BPD2hksrO/JC/eahftsF9NTknB5fiL6CKJBjcEp0dsGoku6PyWk8bAh3U343e5VJyzMT+Pd/DMueMeKmJ53ZR8uiLVrVORVsjs/8ldkw5/vo5SGMWAxNq6M+p8lMbypSrEpr3V8QH4jB105dQaLKx/YkTW8MYbLSx8fcXEJ2WKN3RVNigWm22O/vDTyB4kWH2ndybHHKuZ2oOtItbwBuEwWK1LyKaJZ8BbRWW1wz7was8ZjjmKJrdZB9iV0F8+LSxYmL2WBBCmOKk7kN3OUxXUXqFoTqgZjErLQ9JkIN6HiZXGNBxpwe7tfoxKEJq015Y9TnLQXiETHBIRMSChngkQok2z13KcJW1WZ717Y9gCE7QEhwUDqVGkjQAjS0Sdss7RKP2Pu5w479+uonPPItC/dccp+tGj74t9Jkmbh1WcaeCRkQ2IaaIsxW5tpoKOyL9CZRItr3n/ani8Zy76kNITvOpMfDggcliJ65hi7LGczTeQIwrE9QPFUl2EbxhWyXA97HgvKHW8s0sXNxQuOnOnzim/MkMDtIQsdkckFtdxFWQmHoS99k5SAewMuTRamM3x/555k+sWRwaqhSoJF1qj6uZACDpYOOMhZeq95Va0sVRGN3CHSGK/kBHtK5ztezyjbbit2xZQ3BSQBM9A+d8PbmP6fzFM5F2xRzTyxNoEUi3SNsU5OjmWrW7OwJHyiLzWTe5MNsIk1jYN/mSPzQ+022JFtcN/ieUeMwh10YThvAkI2JHTAGDOv6L/VDtvyA5IjvgWsCyNX2MjAPuQUL7b0Z5YBulArS1Yrp+dqZehSK0hIl1qBbBRhzWolFxxmof/E80JSM/JzylvPbp9IRJZBWpvI8scOLT0yOrT0qi6rnkFSYelxzyAJCy6l5Tw/DAeELEhIEyZt0DCATM2YVLX59EyvcP9NPXJ06ZXTkl7BEMLSq1cl+bqtfw3Kmk5p0RdinN+dZffE9VwxGWJ+UVif8VBrZh/LZZV7wsudPqez94wk168u8yDbMwUeWiIPHXHWaC+/xJbEw4eYruK8aLxn4ZbGnSaSju0cUUJmqUQGtqiEcjGjlwzEwFeaNlYxtA2OySWNXnLMBagEdg8rZ9vg3wDqGoqopOyF6k1YAgsFptrPnb1sCchCqoCstlBYrOeatuaQRVfVo1yP4kMRu/z/RnwbreWU3/8aNbHW4Q1AH6vK2o+2SWBAqME2CXxmm0QXtsgdgOwx6JkU40ME25dO1QT3KFmpK0qjXgygIqOwCX3LnB0XJKLehYpQLXcfsi2a3JB0w5qrpPjMikxCVjyFrmtW42U9r2dKhGCV7FJrAsttp9qPs5UI9gsWi4i6lKjU0ePll+zdZJ2mvOioT7CNd5qOm8o/XLnsMvtnF2zzHvuKf8mWzIMlC/U6Ll9h0wGMay9Vcy1NbrAWNTduv9ye2a9qafBHlwoxINTUUiGGS5KalyVcueTUM61ShNx9C61gjf9irYL70FvTKqRZq+Sy3O89qJzj8pTqhVbA79ntWXO58Fa9iqXm+K4Xzz5LWFacbfXxrFHY+BZsgnxJp1OuIqhMXVUz3QG00yg6ZXtnzkDM7FycKQJCNiTUUKZoa27TcgdQmVIEX+O+6RUMvi7dzQOjOPvE7Txn6xV0x55mvRpAZcscgmIBD+ReHH8BQvjE+OsS2cvVp69+vHhh0hiVG4FKb/9Il2kxwTPhbkMosA1CsfelKffuycUCDXAhr0GaLRwVn/mikXGVvYm1+T0yqG9IAh7bvbRDG7p+3FCHtu2qr6PLRHuWpHOKBoPfO28wgE1B4g41htKOQnJPV32hSRdnDgCZFoh5DqzynYtTCx1ryGtwk5t3UudOD4AFoSO+rwMiy2oPWQNo3SmDg14jyzwKAde+0AO+gyxIVtfWEhtYft3+UC6x9BK2luMoS3t7w9aVQ9RVS2is4K4Erd0z0IKw72KUmjB+PBGXl0BnANm+Itk/eR//524/9XXgjwLork56A9hZpAqx+1b2dsSXg3lgu3nV9nS+nh1765gNyepKjM1mta5UauF1cXEivkag62pVta2nZAJuz9GXL7KsMWjCXXbWxsLX8yZ1RpUvHioHwuAp9uOg0+VSE2y9tLFy41EbvLT6b+JUrtPomYmDKYkpRsWXu9KjuU5j2+ihA9dt4oZQu1IE631zrIdexX62lgFCTblOCzY06NYrufT1CO1/157TRONj+bODlVso23AFctmr+jmEvjDPto6+L65DPypXbyTNey6juM7YhxywDRxyryl+sa/7Hz3Ogb3/ZWk0+w8= +chart.setOption(option, true) ``` -### 嵌入本地 drawio 文件 -链接属性 `link-type` 值需要是 `drawio` 字符串。使用链接的形式也不会影响其他 Markdown 解析器解析。 +## Draw.io + +The value of the link attribute `link-type` needs to be a `drawio` string. The use of the link format will not affect other Markdown resolver resolving. -```markdown [drawio](./test.drawio){link-type="drawio"} + +## Luckysheet Table + +The value of the link attribute `link-type` needs to be a `luckysheet` string. The use of the link format will not affect other Markdown resolver resolving. + +> [!WARNING] +> [Luckysheet](https://github.com/mengshukeji/Luckysheet) has many bugs and should be used with caution. + +[luckysheet](./test.luckysheet){link-type="luckysheet"} + +## Container Block + +Support functions similiar to [VuePress Container Block](https://v2.vuepress.vuejs.org/zh/reference/default-theme/markdown.html#%E8%87%AA%E5%AE%9A%E4%B9%89%E5%AE%B9%E5%99%A8),using [markdown-it-container](https://github.com/markdown-it/markdown-it-container) to achieve. + +**Use It** + +```md +::: [title] +[content] +::: ``` -## 嵌入 Luckysheet 表格文件 -链接属性 `link-type` 值需要是 `luckysheet` 字符串。使用链接的形式也不会影响其他 Markdown 解析器解析。 +`type` is required, `title` and `content` are optional. + +The supported `types` are: tip, warning, danger, details, code-group, group, group-item, row, col, section, div + +**Example** + +::: tip +This is a prompt +::: + +::: warning +This is a warning +::: + +::: danger +This is a danger warning +::: + +::: danger STOP +Dangerous area, no pass +::: + +::: details +This is a details label +::: -```markdown -[drawio](./test.luckysheet){link-type="luckysheet"} +::: details click to expand more +This is a details label +::: + +:::: group This is label group +::: group-item Tab 1 +test 1 +::: + +::: group-item *Tab 2 +test 2 +Title starts with `*` mean that this tab is activated by default +::: + +::: group-item Tab 3 +test 3 +::: +:::: + +::: code-group +```js [test.js] +let a = 1 +``` + +```ts [test.ts] +let a: number = 1 +``` +::: + + +::::: row Columns +:::: col TODO +::: warning +Item 1 +::: +::: warning +Item 2 +::: +::: warning +Item 3 +::: +:::: +:::: col DONE +::: tip +Item 4 +::: +::: tip +Item 5 +::: +:::: +::::: + +:::: row +::: col Column 1 +test 1 +::: +::: col Column 2 +test 2 +::: +::: col Column 3 +test 3 +::: +:::: + +## AI Copilot + +Yank Note integrates with artificial intelligence platforms such as [OpenAI](https://openai.com) and [Google AI](https://ai.google.dev/), enabling features like intelligent autocompletion and text rewriting. + +> [!NOTE] +> You need to obtain the relevant API tokens yourself. *Requires AI Copilot extension installed and enabled*. + +## Front Matter + +The page supports configuration information similar to [Jekyll Front Matter](https://jekyllrb.com/docs/front-matter/) + +Built-in variables + +variable name | type | description +---- | ----- | --- +`headingNumber` | `boolean` | whether to enable the page title serial number +`wrapCode` | `boolean` | whether to enable code wrapping +`enableMacro` | `boolean` | whether to enable macro replacement +`define` | `Record` | Macro definition, string replacing +`defaultPreviewer` | `string` | The default previewer for the document, some extensions may provide a special preview interface. Such as *Reveal.js extension* +`mdOptions` | `Record` | Markdown-it parse options +`mdOptions.html` | `boolean` | Enable HTML tags in source +`mdOptions.breaks` | `boolean` | Convert `\n` in paragraphs into `
` +`mdOptions.linkify` | `boolean` | Autoconvert URL-like text to links +`mdOptions.typographer` | `boolean` | Enable some language-neutral replacement + quotes beautification +`katex` | `Record` | [Katex Options](https://katex.org/docs/options.html) + +## Macro Replacement +> [!NOTE] +> *Available in premium version* + +Yank Note allows you to embed macros in the page to dynamically replace the document. + +> [!IMPORTANT] +> Before using, you need to enable macro replacement in Front Matter and define `enableMacro: true`. + +> [!WARNING] +> Using macro replacement may lead to inaccurate correspondence between source code and preview line numbers. Yank Note has dealt with it as much as possible, but some cases may still cause synchronous scrolling exceptions. + +### Definition + +The `define` field can define some text replacement mappings. Supports definition in another file, supports macro expressions. For details, please refer to the Front Matter at the top of this document. + +> [!TIP] +> You can define global macros in the settings. But you still need to define `enableMacro: true` in Front Matter. + +- App Name: --APP_NAME-- +- App Version: --APP_VERSION-- +- From Another File: --TEST_DEFINE-- + +### Macro Expression + +Syntax: + +```md +[= =] ``` -## 快捷键 - -这里仅列出部分常用快捷键和自定义快捷键,默认编辑器快捷键参考 [vscode](https://code.visualstudio.com/)。 - -功能 | Windows | macOS ----- | ------ | ----- -剪切所选/当前行 | Ctrl + X | Cmd + X -复制所选/当前行 | Ctrl + C | Cmd + X -撤销 | Ctrl + Z | Cmd + Z -重做 | Ctrl + Shift + Z | Cmd + Shift + Z -在选中区的所有行的最后添加光标 | Shift + Alt + I | Shift + Option + I -在相邻行插入光标 | Ctrl + Alt + ↑/↓ | Cmd + Option + ↑/↓ -为下一个匹配项添加光标 | Ctrl + D | Cmd + D -查找 | Ctrl + F | Cmd + F -打开文件快速跳转面板 | Ctrl + P | Cmd + P -插入文档链接 | Ctrl + Alt + I | Cmd + Option + I -保存文档 | Ctrl + S | Cmd + S -连接行 | Ctrl + J | Cmd + J -强制插入新行,忽略预置补全规则 | Ctrl + Enter | Cmd + Enter -强制插入 Tab,忽略预置补全规则 | Shift + Enter | Shift + Enter -当前行上移 | Ctrl + Shift + Up | Cmd + Shift + Up -当前行下移 | Ctrl + Shift + Down | Cmd + Shift + Down -重复当前行 | Ctrl + Shift + D | Cmd + Shift + D -插入当前日期 | Shift + Alt + D | Shift + Option + D -插入当前时间 | Shift + Alt + T | Shift + Option + T -插入文件附件 | Shift + Alt + F | Shift + Option + F -在内置终端里面运行选中内容 | Shift + Alt + R | Shift + Option + R -转换大写 | Ctrl + K, Ctrl + U | Cmd + K, Cmd + U -转换小写 | Ctrl + K, Ctrl + L | Cmd + K, Cmd + K -粘贴 HTML 富文本 | Ctrl + D + V | Cmd + D + V -粘贴图片为 base64 链接 | Ctrl + B + V | Cmd + B + V -转换单个外链图片到本地 | Ctrl + Shift + 单击图片 | Cmd + Shift + 单击图片 -转换所有外链图片到本地 | Ctrl + Shift + L | Cmd + Shift + L -编辑表格单元格 | 双击单元格 | 双击单元格 -编辑表格单元格(弹出框) | Ctrl + 单击单元格 | Cmd + 单击单元格 或 -复制文档标题链接 | Ctrl + 单击标题 | Cmd + 单击标题 -切换编辑器 Tab | Ctrl + Alt + Left/Right | Ctrl + Option + Left/Rig -切换侧栏 | Alt + E | Option + E -切换编辑器自动换行 | Alt + W | Option + W -切换文档预览显示 | Alt + V | Option + V -切换终端 | Alt + T | Option + T -查看 Readme | Alt + H | Option + H - -## 元素属性书写 -此功能使用 [markdown-it-attrs](https://github.com/arve0/markdown-it-attrs) 实现 -示例红色文字: -**test**{style="color:red"} - -## 命令行参数 -在向别人交接文档的时候,可以使用脚本,自定义命令行参数启动程序,方便对方查看文档。 - -名称 | 作用 | 默认值 | 说明 | 示例 + `expression` is the js expression that needs to be executed, and supports await/Promise asynchronous expressions. + +If the expression needs to contain [\= or =\], please enter `[\=` or `=\]` to escape and replace. + +### Examples + +- whether to enable the page title serial number: [= headingNumber =] +- use variable: [= customVar =] +- custom variable: [= $export('testVar', 'Test') =][= testVar =] +- custom function: [= $export('format', (a, b) => `${a}, ${b}!`) =][= format('HELLO', 'WORLD') =] +- further processing: XXXXXXXXXXXXXX [= $afterMacro(src => src.replace(/X{4,}/g, 'YYYYY')) =] +- application version: [= $ctx.version =] +- current document name: [= $doc.basename =] +- current time: [= $ctx.lib.dayjs().format('YYYY-MM-DD HH:mm') =] +- sequence: [= $seq`Figure-` =] | [= $seq`Figure-` =] | [= $seq`Figure-` =] | [= $seq`Table-` =] | [= $seq`Table-` =] +- qualifier escape: [= '[\= =\]' =] +- Arithmetic: [= (1 + 2) / 2 =] +- reference file (support 3 levels of nesting, you can use Front Matter variable defined in the target document): + > [= $include('./_FRAGMENT.md', true) =] +- variable defined in the referenced document: [= customVarFromOtherDoc =] +- your IP address: [= fetch('https://ifconfig.me/ip').then(r => r.text()) =] +- weather forecast: + ``` + [= await ctx.utils.sleep(1000), fetch('https://wttr.in?0AT').then(r => r.text()) =] + ``` +- Nine-Nine Multiplication Table: + [= + (function nine (num) { + let res = '' + for (let i = 1; i <= num; i++) { + let str = ''; + for (let k = 1; k <= num; k++) { + if (i >= k) { + str += k + 'x' + i + '=' + i*k + ' '; + } + } + res = res + str + '\n' + } + return res + })(9) + =] + +### Available Variables + +Macro expression can use variables defined in Front Matter, or use the following built-in variables + +variable name | type | description +---- | ----- | --- +`$ctx` | `object` | Editor `ctx`,refer to [Plug-In Development GUide](PLUGIN.md) and [Api Document](https://yn-api-doc.vercel.app/modules/renderer_context.html) +`$include` | `(path: string, trim = false) => Result` | Introduce other document fragment methods +`$export` | `(key: string, val: any) => Result` | Define a variable that can be used in this document +`$afterMacro` | `(fn: (src: string) => string) => Result` | Define a macro-replaced callback function that can be used for further processing of the replaced text. +`$noop` | `() => Result` | no operation, Used for holding text space +`$doc` | `object` | Current document information +`$seq` | `(label: string) => Result` | Sequence +`$doc.basename` | `string` | File name of current document (no suffix) +`$doc.name` | `string` | File name of current document +`$doc.path` | `string` | Current document path +`$doc.repo` | `string` | Current document repository +`$doc.content` | `string` | Current document content +`$doc.status` | `'loaded', 'save-failed', 'saved'` | Current document status + +## Command Line Parameters + +When handing over documents to others, you can use scripts or custom command line parameters to start the application to facilitate the other party to view the documents. + +name | use | default value | description | example ----------------- | ------------ | ----- | ----------------------- | ---- ---port | 服务器监听端口 | 3044 | 端口 | --port=8080 ---disable-tray | 禁用常驻托盘 | false | true/false | --disable-tray ---readonly | 编辑器只读 | false | true/false | --readonly ---show-status-bar | 显示状态栏 | true | true/false | --show-status-bar=false ---data-dir | 数据目录 | 无 | 目录路径字符串 | --data-dir='./.data' ---init-repo | 初始仓库名 | 无 | 字符串 | --init-repo='test' ---init-file | 加载文件路径 | 无 | 文件路径,相对于仓库路径 | --init-file='/1.md' - -[^1]: 这是一个脚注 -[^2]: 这也是一个脚注 +--port | Server listening port | 3044 | port | --port=8080 +--disable-tray | Disable the resident tray | false | true/false | --disable-tray +--readonly | Editor readonly | false | true/false | --readonly +--show-status-bar | Show status bar | true | true/false | --show-status-bar=false +--data-dir | Data directory | none | directory path string | --data-dir='./.data' +--init-repo | Initial repository name | none | string | --init-repo='test' +--init-file | Load file path | none | file path, relative to repository path | --init-file='/1.md' + +## Custom Styles + +1. Right click the tray icon and click "Open Main Dir", go to the `themes` folder. +2. Copy `github.css` to a new CSS file ans modify it. +3. Open Setting => Appearance => Custom CSS switch CSS file. + +## Plug-In Development + +Please refer to [Plug-In Development Guide](PLUGIN.md) + +[^1]: This is a footnote +[^2]: This is a footnote too diff --git a/help/FEATURES_ZH-CN.md b/help/FEATURES_ZH-CN.md new file mode 100644 index 000000000..de19047cb --- /dev/null +++ b/help/FEATURES_ZH-CN.md @@ -0,0 +1,761 @@ +--- +headingNumber: true +enableMacro: true +customVar: Hello +define: + --APP_NAME--: Yank Note + --APP_VERSION--: '[= $ctx.version =]' +--- + +# Yank-Note 特色功能使用说明 + +[English](./FEATURES.md) | 中文 + +[toc]{type: "ol", level: [2]} + +## 应用数据 + +`` 为当前操作系统的用户主目录,例如: + +1. Windows: `C:\Users\` +1. Linux: `/home/` +1. macOS: `/Users/` + +应用相关的数据目录存放在 `/yank-note` 下面,点击托盘菜单“打开主目录”即可查看 + +目录说明: + +1. 配置文件 `/yank-note/config.json` +1. 导出 docx 参考文档 `/yank-note/pandoc-reference.docx` +1. 文档历史版本 `/yank-note/histories` + > [!TIP] + > 如果您不小心丢失了您的文档数据,您可以到此文件夹尝试找回。 + + > [!CAUTION] + > 出于性能的考虑,超过 *102400* 个字符的文档将不会储存历史记录。因此请谨慎在文档中嵌入 Base64 图片。 + +1. 插件 `/yank-note/plugins` +1. 主题 `/yank-note/themes` +1. 扩展 `/yank-note/extensions` +1. 其他用户数据 `/yank-note/data` + +## TOC 生成 + +需要生成目录的地方写入 `[toc]{type: "ul", level: [1,2,3]}` +可以控制目录样式 `ul` 或 `ol` 和级别 + +## 待办切换 + +在预览界面打勾试试 ++ [x] ~~2021-06-06 10:27~~ TEST1 ++ [x] ~~2021-06-06 10:27~~ TEST2 ++ [x] ~~2021-06-06 10:27~~ TEST3 + +## 加密文档 + +1. 以 `.c.md` 结尾的文档视为加密文档,可以用来保存机密的信息。 +2. 加密和解密过程均在前端完成。 +3. 请务必保管好文档密码,密码一旦丢失就只能自己暴力破解了。 + +## Markdown 增强 + +在编辑器中键入 `/` 可以获得提示 + ++ 高亮:==marked== ++ 上标:29^th^ ++ 下标:H~2~0 ++ 脚注:脚注[^1]语法[^2] ++ Emoji: :) :joy: ++ 缩写: + *[HTML]: Hyper Text Markup Language + *[W3C]: World Wide Web Consortium + The HTML specification is maintained by the W3C. ++ Wiki 链接:支持使用 `[[文件名#锚点|显示文本]]` 或 `[[文件名:行,列|显示文本]]` 语法来链接文档,如 [[README#Highlights|特色功能]] [[README:3,4]] + +## Github Alerts + +此功能使用 [markdown-it-github-alerts](https://github.com/antfu/markdown-it-github-alerts) 实现,支持 [Github 风格的警告提示](https://github.com/orgs/community/discussions/16925)。 + +> [!NOTE] 注意 +突出强调用户在浏览时应该注意的信息。 + +> [!TIP] 提示 +> 提供可选的信息以帮助用户更加成功。 + +> [!IMPORTANT] 重要 +> 对于用户成功至关重要的关键信息。 + +> [!WARNING] 警告 +> 由于潜在风险,需要立即引起用户的注意的关键内容。 + +> [!CAUTION] 小心 +> 行动的负面潜在后果。 + +### 元素属性书写 + +此功能使用 [markdown-it-attributes](https://github.com/purocean/markdown-it-attributes) 实现。 + +- 红色文字,白色背景,居中和边框{.bgw .text-center .with-border style="color:red"} +- 显示为**块元素**{.block} +- 转义语法\{style="color:red"} + +**一些内置样式类:** + +| 类名 | 说明 | +| -- | -- | +| `avoid-page-break` | 打印/导出PDF时避免页面在此元素中断 | +| `new-page` | 打印/导出PDF时在此元素前分页 | +| `skip-print` | 打印/导出PDF时跳过此元素 | +| `skip-export` | 导出/复制HTML时跳过此元素 | +| `inline` | 当前元素显示为行内元素 | +| `block` | 当前元素显示为块元素 | +| `reduce-brightness` | 使用暗色主题时候降低此元素亮度 | +| `bgw` | 设置当前元素背景为白色 | +| `copy-inner-text` | 标记 “Ctrl/Cmd + 单击左键” 拷贝元素文字 | +| `wrap-code` | 应用于代码块,让其自动换行 | +| `text-left` | 当前元素文字左对齐 | +| `text-center` | 当前元素文字居中对齐 | +| `text-right` | 当前元素文字右对齐 | +| `with-border` | 给当前元素加上边框 | + +### 图片增强 + +![](mas_en.svg?.inline) + +1. 一个段落下如果只有一个图片元素,默认会渲染成块元素并居中。如果要强制显示为行内元素,可以在图片链接参数后面追加 `.inline` 如上图所示。 +1. 如果要给图片添加白色背景优化展示效果(针对某些透明图片),可以在图片链接参数后面追加 `.bgw` 如:![](mas_en.svg?.bgw) +1. 可以使用[markdown-it-imsize](https://github.com/tatsy/markdown-it-imsize)的方式来设置图片尺寸 + 例如这是一个宽度为 16px 的图片: ![](logo-small.png =16x) + +## 思维导图 + +只需要在列表根节点加上 `{.mindmap}` 即可。 + ++ 中心节点{.mindmap} + + [1] 状态可见原则 + + [2] 环境贴切原则 + + [3] 用户可控原则 + + [4] 一致性原则 + + [5] 防错原则 + + [6] 易取原则 + + [7] 灵活高效原则 + + [8] 优美且简约原则 + + [9] 容错原则 + + [10] 人性化帮助原则 + +脑图使用 [kityminder-core](https://github.com/fex-team/kityminder-core) 实现。 + +## Mermaid 图形 + +```mermaid +graph LR +A[Hard] -->|Text| B(Round) +B --> C{Decision} +C -->|One| D[Result 1] +C -->|Two| E[Result 2] +``` + +```mermaid +sequenceDiagram +Alice->>John: Hello John, how are you? +loop Healthcheck + John->>John: Fight against hypochondria +end +Note right of John: Rational thoughts! +John-->>Alice: Great! +John->>Bob: How about you? +Bob-->>John: Jolly good! +``` + +```mermaid +gantt +section Section +Completed :done, des1, 2014-01-06,2014-01-08 +Active :active, des2, 2014-01-07, 3d +Parallel 1 : des3, after des1, 1d +Parallel 2 : des4, after des1, 1d +Parallel 3 : des5, after des3, 1d +Parallel 4 : des6, after des4, 1d +``` + +```mermaid +stateDiagram-v2 +[*] --> Still +Still --> [*] +Still --> Moving +Moving --> Still +Moving --> Crash +Crash --> [*] +``` + +```mermaid +pie +"Dogs" : 386 +"Cats" : 85 +"Rats" : 15 +``` + +```mermaid +journey + title My working day + section Go to work + Make tea: 5: Me + Go upstairs: 3: Me + Do work: 1: Me, Cat + section Go home + Go downstairs: 5: Me + Sit down: 3: Me +``` + +## PlantUML 图形 + +您可以在 *设置* 中配置使用本地端点或 PlantUML 在线端点来生成图形。 + +> [!IMPORTANT] +> 如果使用本地端点,则系统需要有 Java 环境,并安装有 Graphviz +如果提示 `Cannot find Graphviz`,请参考 [Test your GraphViz installation](https://plantuml.com/graphviz-dot) + +示例如下 + +@startuml +a -> b +@enduml + +## 表格增强 + +此功能使用 [markdown-it-multimd-table](https://github.com/RedBug312/markdown-it-multimd-table) 实现 +支持在表格中使用多行文本和列表。支持表格说明渲染 + +您可以双击/右键单元格快捷编辑单元格内容,相关快捷键: +- `DBLClick`: 编辑单元格 +- `Escape`: 退出编辑 +- `Enter`: 确认编辑并编辑下一行 +- `Shift + Enter`: 确认编辑并编辑上一行 +- `Cmd/Ctrl + Shift + Enter`: 确认编辑并插入下一行 +- `Tab`: 确认编辑并编辑下一列 +- `Shift + Tab`: 确认编辑并编辑上一列 + +First header | Second header +-------------|--------------- +List: | More \ +- [ ] over | data \ +- several | +Test | Test +[测试表格] + +First header | Second header +:-----------:|:--------------: +AAAAAAAAAAAA | BBBBBBBBBBBBBB +AAAAAAAAAAAA | BBBBBBBBBBBBBB +AAAAAAAAAAAA | BBBBBBBBBBBBBB +AAAAAAAAAAAA | BBBBBBBBBBBBBB +AAAAAAAAAAAA | BBBBBBBBBBBBBB +Test | Test +[小尺寸表格] +{.small} + +| h1 | h2 | h3 | +| -- | -- | -- | +| x1 | x2 | x3 {rowspan=2 style="color:red"} | +| x4 {colspan=2} | +[合并单元格] + +## 数学公式 + +此功能由 [KaTeX](https://github.com/KaTeX/KaTeX) 提供。 + +$$ +\begin{array}{c} +\nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} & += \frac{4\pi}{c}\vec{\mathbf{j}} \nabla \cdot \vec{\mathbf{E}} & = 4 \pi \rho \\ +\nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} & = \vec{\mathbf{0}} \\ +\nabla \cdot \vec{\mathbf{B}} & = 0 +\end{array} +$$ + +equation | description +----------|------------ +$\nabla \cdot \vec{\mathbf{B}} = 0$ | divergence of $\vec{\mathbf{B}}$ is zero +$\nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} = \vec{\mathbf{0}}$ | curl of $\vec{\mathbf{E}}$ is proportional to the rate of change of $\vec{\mathbf{B}}$ +$\nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} = \frac{4\pi}{c}\vec{\mathbf{j}} \nabla \cdot \vec{\mathbf{E}} = 4 \pi \rho$ | _what?_ + +## 运行代码 + +支持运行 `JavaScript` `PHP` `nodejs` `Python` `bash` `bat` 代码。 +此功能执行外部命令实现,所以需要安装相应环境。 + +代码块第一行需要包含以 `--run--` 字符串,示例如下 + +Js 代码默认在 Web Worker 中运行,如果需要在主线程中运行,可以在代码块后面加上 `--no-worker--` 参数 + +```js +// --run-- +await new Promise(r => setTimeout(r, 500)) +console.log('HELLOWORLD') +``` + +主线程中运行的代码支持使用 `ctx` 对象访问编辑器 API,示例如下 + +```js +// --run-- --no-worker-- +await new Promise(r => setTimeout(r, 500)) +ctx.ui.useToast().show("info", "HELLOWORLD!") +console.log('HELLOWORLD') +``` + +如果需要输出 HTML,可以在代码块后面加上 `--output-html--` 参数 + +```js +// --run-- --output-html-- +console.log(`output HTML`) +``` + +```node +// --run-- +console.log('HELLOWORLD') +``` + +```php +// --run-- +echo 'HELLOWORLD!'; +``` + +```python +# --run-- +print('HELLOWORLD') +``` + +```shell +# --run-- +date +``` + +```bat +REM --run-- +@echo HELLOWORLD +``` + +```c +// --run-- gcc $tmpFile.c -o $tmpFile.out && $tmpFile.out + +#include + +int main () { + printf("Hello, World!"); + return 0; +} +``` + +```java +// --run-- java $tmpFile.java + +class HelloWorld { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +} +``` + +## 集成终端 + +1. 使用 `Alt/Option + T` 或者点击状态栏 **切换终端** 菜单唤起集成终端 +1. 支持在编辑器中选中一段代码后按下 `Shift + Alt/Option + R` 直接在终端中运行命令。免去复制粘贴。 +1. 切换内置终端工作目录到当前目录 `右键目录` + +## 小工具 + +支持在文档中嵌入 HTML 小工具。 +HTML 代码块第一行需要包含以 `--applet--` 字符串,其余字符串作为小工具标题,示例如下 + +```html + + + + +
+ 输入 + + + + + + + +
+``` + +如果没有标题,将没有外部边框装饰 + +```html + + +``` + +你也可以 *安装并启用 Vue Repl 扩展*,使用 Vue SFC 来编写小工具。 + +## ECharts 图形 + +Js 代码块第一行包含以 `--echarts--` 字符串会被解析成 ECharts 图形,示例如下 + +```js +// --echarts-- + +const option = { + // backgroundColor: '#2c343c', + + title: { + text: 'Customized Pie', + left: 'center', + top: 20, + textStyle: { + color: '#888' + } + }, + + tooltip : { + trigger: 'item', + formatter: "{a}
{b} : {c} ({d}%)" + }, + + visualMap: { + show: false, + min: 80, + max: 600, + inRange: { + colorLightness: [0, 1] + } + }, + series : [ + { + name:'访问来源', + type:'pie', + radius : '55%', + center: ['50%', '50%'], + data:[ + {value:335, name:'直接访问'}, + {value:310, name:'邮件营销'}, + {value:274, name:'联盟广告'}, + {value:235, name:'视频广告'}, + {value:400, name:'搜索引擎'} + ].sort(function (a, b) { return a.value - b.value; }), + roseType: 'radius', + label: { + normal: { + textStyle: { + color: '#888' + } + } + }, + labelLine: { + normal: { + lineStyle: { + color: '#888' + }, + smooth: 0.2, + length: 10, + length2: 20 + } + }, + itemStyle: { + normal: { + color: '#c23531', + shadowBlur: 200, + shadowColor: 'rgba(0, 0, 0, 0.5)' + } + }, + + animationType: 'scale', + animationEasing: 'elasticOut', + animationDelay: function (idx) { + return Math.random() * 200; + } + } + ] +} + +chart.setOption(option, true) +``` + +## Draw.io 图形 + +链接属性 `link-type` 值需要是 `drawio` 字符串。使用链接的形式也不会影响其他 Markdown 解析器解析。 + +[drawio](./test.drawio){link-type="drawio"} + +## Luckysheet 表格 + +链接属性 `link-type` 值需要是 `luckysheet` 字符串。使用链接的形式也不会影响其他 Markdown 解析器解析。 + +> [!WARNING] +> 现阶段 [Luckysheet](https://github.com/mengshukeji/Luckysheet) Bug 较多,使用需谨慎。 + +[luckysheet](./test.luckysheet){link-type="luckysheet"} + +## 容器块 + +支持类似 [VuePress 容器块](https://v2.vuepress.vuejs.org/zh/reference/default-theme/markdown.html#%E8%87%AA%E5%AE%9A%E4%B9%89%E5%AE%B9%E5%99%A8) 功能,使用 [markdown-it-container](https://github.com/markdown-it/markdown-it-container) 实现 + +**使用** + +```md +::: [title] +[content] +::: +``` + +`type` 是必需的, `title` 和 `content` 是可选的。 + +支持的 `type` 有:tip, warning, danger, details, code-group, group, group-item, row, col, section, div + +**示例** + +::: tip +这是一个提示 +::: + +::: warning +这是一个警告 +::: + +::: danger +这是一个危险警告 +::: + +::: danger STOP +危险区域,禁止通行 +::: + +::: details +这是一个 details 标签 +::: + +::: details 点击展开更多 +这是一个 details 标签 +::: + +:::: group 这是标签组 +::: group-item Tab 1 +test 1 +::: + +::: group-item *Tab 2 +test 2 +标题前带 `*` 表示默认激活此选项卡 +::: + +::: group-item Tab 3 +test 3 +::: +:::: + +::: code-group +```js [test.js] +let a = 1 +``` + +```ts [test.ts] +let a: number = 1 +``` +::: + +::::: row 分列示例 +:::: col TODO +::: warning +Item 1 +::: +::: warning +Item 2 +::: +::: warning +Item 3 +::: +:::: +:::: col DONE +::: tip +Item 4 +::: +::: tip +Item 5 +::: +:::: +::::: + +:::: row +::: col Column 1 +test 1 +::: +::: col Column 2 +test 2 +::: +::: col Column 3 +test 3 +::: +:::: + +## AI Copilot 人工智能写作助手 + +Yank Note 接入了 [OpenAI](https://openai.com)、[Google AI](https://ai.google.dev/) 等人工智能平台,可以使用人工智能进行智能补全、文本重写等功能 + +> [!NOTE] +> 相关 API token 需要你自行获取。且需要 *安装并启用 AI Copilot 扩展* + +## Front Matter + +页面支持类似 [Jekyll Front Matter](https://jekyllrb.com/docs/front-matter/) 配置信息 + +内置变量 + +变量名 | 类型 | 描述 +---- | ----- | --- +`headingNumber` | `boolean` | 是否开启页面标题序号编号 +`wrapCode` | `boolean` | 是否开启代码换行 +`enableMacro` | `boolean` | 是否开启宏替换 +`define` | `Record` | 宏定义,定义文本替换 +`defaultPreviewer` | `string` | 文档默认的预览器,某些扩展可能提供特殊的预览界面。如 *Reveal.js 扩展* +`mdOptions` | `Record` | Markdown-it 解析参数 +`mdOptions.html` | `boolean` | 开启 HTML 解析 +`mdOptions.breaks` | `boolean` | 转换 `\n` 成 `
` +`mdOptions.linkify` | `boolean` | 自动转换链接 +`mdOptions.typographer` | `boolean` | 开启语言替换和引号美化 +`katex` | `Record` | [Katex 配置](https://katex.org/docs/options.html) + +## 宏替换 + +> [!NOTE] +> 此功能 *高级版可用* + +Yank Note 允许你在页面中嵌入宏,用以动态地替换文档。 + +> [!IMPORTANT] +> 使用前需要先在 Front Matter 开启宏替换,定义 `enableMacro: true`。 + +> [!WARNING] +> 使用宏替换可能会导致源码和预览行号对应不准确,Yank Note 已经尽可能处理,但某些情况可能仍然会出现同步滚动异常。 + +### 文本替换 + +Front Matter 中的 `define` 字段可以定义一些文本替换映射。支持在另一个文件定义,支持宏表达式。具体可参考本文件顶部 Front Matter 部分。 + +> [!TIP] +> 你还可以在设置中配置 *全局宏替换* ,这样所有文档都可以使用。不过,你仍然需要在 Front Matter 中定义 `enableMacro: true`。 + +- 应用名: --APP_NAME-- +- 应用版本: --APP_VERSION-- +- 另一个文件的定义: --TEST_DEFINE-- + +### 宏表达式 + +语法: + +```md +[= =] +``` + +其中 `expression` 是需要执行的 js 表达式,支持 await/Promise 异步表达式。 + +如果表达式中需要包含 [\= 或 =\] 请输入 `[\=` 或 `=\]` 转义替换 + +### 一些示例 + +- 是否开启页面标题序号编号: [= headingNumber =] +- 使用变量: [= customVar =] +- 定义变量: [= $export('testVar', 'Test') =][= testVar =] +- 定义函数: [= $export('format', (a, b) => `${a}, ${b}!`) =][= format('HELLO', 'WORLD') =] +- 进一步处理: XXXXXXXXXXXXXX [= $afterMacro(src => src.replace(/X{4,}/g, 'YYYYY')) =] +- 应用版本:[= $ctx.version =] +- 当前文档名: [= $doc.basename =] +- 当前时间: [= $ctx.lib.dayjs().format('YYYY-MM-DD HH:mm') =] +- 计数器: [= $seq`图-` =] | [= $seq`图-` =] | [= $seq`图-` =] | [= $seq`表-` =] | [= $seq`表-` =] +- 限定符转义: [= '[\= =\]' =] +- 四则运算: [= (1 + 2) / 2 =] +- 引用文件(支持最多嵌套 3 层,可使用目标文档中定义的 Front Matter 变量) + > [= $include('./_FRAGMENT.md', true) =] +- 被引用文档中定义的变量:[= customVarFromOtherDoc =] +- 你的 IP 地址:[= fetch('https://ifconfig.me/ip').then(r => r.text()) =] +- 天气预报 + ``` + [= await ctx.utils.sleep(1000), fetch('https://wttr.in?0AT').then(r => r.text()) =] + ``` +- 九九乘法表 + [= + (function nine (num) { + let res = '' + for (let i = 1; i <= num; i++) { + let str = ''; + for (let k = 1; k <= num; k++) { + if (i >= k) { + str += k + 'x' + i + '=' + i*k + ' '; + } + } + res = res + str + '\n' + } + return res + })(9) + =] + +### 可用变量 + +宏表达式可以使用在 Front Matter 定义的变量,也可以使用下面的内置变量 + +变量名 | 类型 | 描述 +---- | ----- | --- +`$ctx` | `object` | 编辑器 `ctx`,可参考[插件开发指南](PLUGIN.md) 和[Api 文档](https://yn-api-doc.vercel.app/modules/renderer_context.html) +`$include` | `(path: string, trim = false) => Result` | 引入其他文档片段方法 +`$export` | `(key: string, val: any) => Result` | 定义一个本文档可以使用的变量 +`$noop` | `() => Result` | 无操作函数,可用于文本占位使用 +`$afterMacro` | `(fn: (src: string) => string) => Result` | 定义一个宏替换后的回调函数,可用于对替换后的文本进行进一步处理。 +`$seq` | `(label: string) => Result` | 文档内部计数器 +`$doc` | `object` | 当前文档信息 +`$doc.basename` | `string` | 当前文档文件名(无后缀) +`$doc.name` | `string` | 当前文档文件名 +`$doc.path` | `string` | 当前文档路径 +`$doc.repo` | `string` | 当前文档仓库 +`$doc.content` | `string` | 当前文档内容 +`$doc.status` | `'loaded', 'save-failed', 'saved'` | 当前文档状态 + +## 命令行参数 + +在向别人交接文档的时候,可以使用脚本,自定义命令行参数启动程序,方便对方查看文档。 + +名称 | 作用 | 默认值 | 说明 | 示例 +----------------- | ------------ | ----- | ----------------------- | ---- +--port | 服务器监听端口 | 3044 | 端口 | --port=8080 +--disable-tray | 禁用常驻托盘 | false | true/false | --disable-tray +--readonly | 编辑器只读 | false | true/false | --readonly +--show-status-bar | 显示状态栏 | true | true/false | --show-status-bar=false +--data-dir | 数据目录 | 无 | 目录路径字符串 | --data-dir='./.data' +--init-repo | 初始仓库名 | 无 | 字符串 | --init-repo='test' +--init-file | 加载文件路径 | 无 | 文件路径,相对于仓库路径 | --init-file='/1.md' + +## 自定义样式 + +1. 右键点击托盘图标,点击“打开主目录”,进入 `<主目录>/themes` 目录。 +2. 复制 `github.css` 为一个新 CSS 文件,修改 CSS 内容 +3. 打开设置 => 外观 => 自定义 CSS 切换 CSS 配置 + +## 插件开发 + +请参考[插件开发指南](PLUGIN_ZH-CN.md) + +[^1]: 这是一个脚注 +[^2]: 这也是一个脚注 diff --git a/help/PLUGIN.md b/help/PLUGIN.md new file mode 100644 index 000000000..3b26aa92d --- /dev/null +++ b/help/PLUGIN.md @@ -0,0 +1,122 @@ +--- +headingNumber: true +--- + +# Plug-In Development Guide + +English | [中文](./PLUGIN_ZH-CN.md) + +[toc]{type: "ol"} + +## Preface + +Yank Note is a open source, completely **hackable** note application. + +My vision is to allow users to customize their own editor according to their own ideas, so that this editor can better assist users in their work and study. For example, for the scenario of [Git Submit](https://github.com/purocean/yn/issues/65#issuecomment-962472562), users can write their own plug-ins to implement this function without waiting for developers to adapt. + +At now, almost all functions inside Yank Note are implemented through a thin plug-in system ([dozens of built-in plug-ins](https://github.com/purocean/yn/tree/develop/src/renderer/plugins)). The capabilities used by the built-in plug-ins are exactly the same as the plug-ins written by users, and you can even use some third-party libraries used by Yank Note in the plug-ins. + +## Write A Plug-In + +1. Right click the tray icon and click "Open Main Dir" +2. Create a new file `plugin-hello.js` in `
/plugins` +3. Edit the `plugin-hello.js` file and write the following content. + ```js + // register a plug-in + window.registerPlugin({ + name: 'plugin-hello', + register: ctx => { + // add menu on status bar + ctx.statusBar.tapMenus(menus => { + menus['plugin-hello'] = { + id: 'plugin-hello', + position: 'left', + title: 'HELLO', + onClick: () => { + ctx.ui.useToast().show('info', 'HELLO WORLD!'); + } + } + }) + } + }); + ``` +4. Right-click the tray icon and click "Develop > Reload" + +Now you can see the "HELLO" menu in the status bar expectantly. + +## Core Concepts + +Yank Note has some concepts that are the basis for supporting the entire plug-in system: + +1. Hook +1. Action + +### Hook + +When Yank Note performs some operations, it will trigger some hook calls. + +Use [`ctx.registerHook`](https://yn-api-doc.vercel.app/modules/renderer_core_hook.html#registerHook) to register a hook processing method. + +Use [`ctx.triggerHook`](https://yn-api-doc.vercel.app/modules/renderer_core_hook.html#triggerHook) to trigger a hook. + +The option `{ breakable: true }` is appended when calling `triggerHook`, indicating that the hook call is breakable. + +The following internal hook call is breakable: + +- `ACTION_AFTER_RUN` +- `ACTION_BEFORE_RUN` +- `TREE_NODE_SELECT` +- `VIEW_ELEMENT_CLICK` +- `VIEW_ELEMENT_DBCLICK` +- `VIEW_KEY_DOWN` +- `EDITOR_PASTE_IMAGE` + +Breakable hook processing methods need to have a return value `Promise | boolean`. When the hook processing method returns `true`, the subsequent hooks will no longer run。 + +For internal hook types, please refer to [Api Document](https://yn-api-doc.vercel.app/modules/renderer_types.html#BuildInHookTypes) + +### Action + +Yank Note has an Action Center [`ctx.action`](https://yn-api-doc.vercel.app/modules/renderer_core_action.html), which provides action management and operation。 + +For internal action, please refer to [Api Document](https://yn-api-doc.vercel.app/modules/renderer_types.html#BuildInActions) + +## Plug-In Capabilities + +As you can see from the above, the functions of the plug-in are all implemented through the modules of `ctx`. + +In addition to the above core modules, there are many other modules under ctx, which basically cover all aspects of the editor. + +Run the following code, you can see which modules are available of `ctx`. + +```js +// --run-- --no-worker-- +console.log(Object.keys(ctx).join('\n')) +``` + +Refer to [Api Document](https://yn-api-doc.vercel.app/modules/renderer_context.html) to get more instructions. + +## Distribute Plugins + +If you want to distribute your own plugins/themes to others, please refer to https://github.com/purocean/yank-note-extension#readme + +## More + +In general, Yank Note encourages users to create their own work-learning tools that only need a few lines of code to help their work and study. + +In addition, if you only need to create some handy tools, you don't need to write plug-ins, you can use [RunCode](FEATURES.md#RunCode) function or write [Widgets](FEATURES.md#Widgets) to achieve. The ability to run js code here is also completely open, and the global variable `ctx` also has all the above functions. + +For example, run js code: + +```js +// --run-- --no-worker-- +ctx.ui.useToast().show("info", "HELLOWORLD!") +console.log("hello world!") +``` + +Widgets + +```html + + +``` diff --git a/help/PLUGIN_ZH-CN.md b/help/PLUGIN_ZH-CN.md new file mode 100644 index 000000000..4110fc34a --- /dev/null +++ b/help/PLUGIN_ZH-CN.md @@ -0,0 +1,120 @@ +--- +headingNumber: true +--- + +# 插件开发指南 + +[English](./PLUGIN.md) | 中文 + +[toc]{type: "ol"} + +## 前言 + +Yank Note 是一个完全开放,Hackable 的笔记应用。 + +我的理想是让用户可以按照自己的想法来定制自己的编辑器,让这款编辑器可以更好的辅助用户的工作和学习。比如针对 [Git 提交](https://github.com/purocean/yn/issues/65#issuecomment-962472562) 的这个场景,用户就可以自己编写插件来实现这个功能,不用等到开发者来适配。 + +目前,Yank Note 内部几乎所有功能功能,均是通过一套薄薄的插件体系来实现([几十个内置插件](https://github.com/purocean/yn/tree/develop/src/renderer/plugins))。内置插件所用到的能力,和用户编写的插件完全一致,甚至你可以在插件中使用一些 Yank Note 使用的第三方库。 + +## 编写一个插件 + +1. 右键点击托盘图标,点击“打开主目录” +2. 在 `<主目录>/plugins` 中新建一个 `plugin-hello.js` 文件 +3. 编辑 `plugin-hello.js` 文件,写入如下内容。 + ```js + // 注册一个插件 + window.registerPlugin({ + name: 'plugin-hello', + register: ctx => { + // 添加状态栏菜单 + ctx.statusBar.tapMenus(menus => { + menus['plugin-hello'] = { + id: 'plugin-hello', + position: 'left', + title: 'HELLO', + onClick: () => { + ctx.ui.useToast().show('info', 'HELLO WORLD!'); + } + } + }) + } + }); + ``` +4. 右键点击托盘图标,点击“开发 > 重载页面” + +现在应该可以在状态栏看到 “HELLO” 菜单了。 + +## 核心概念 + +Yank Note 有一些概念,是支撑整个插件体系的基础: + +1. Hook 钩子 +1. Action 动作 + +### Hook 钩子 + +Yank Note 在执行一些操作的时候,会触发一些钩子调用。 + +使用 [`ctx.registerHook`](https://yn-api-doc.vercel.app/modules/renderer_core_hook.html#registerHook) 可以注册一个钩子处理方法。使用 [`ctx.triggerHook`](https://yn-api-doc.vercel.app/modules/renderer_core_hook.html#triggerHook) 则可以触发一个钩子。 + +调用 `triggerHook` 时候附加选项 `{ breakable: true }`,表明这个钩子调用是可中断的。 + +如下面的内部钩子调用是可中断的 + +- `ACTION_AFTER_RUN` +- `ACTION_BEFORE_RUN` +- `TREE_NODE_SELECT` +- `VIEW_ELEMENT_CLICK` +- `VIEW_ELEMENT_DBCLICK` +- `VIEW_KEY_DOWN` +- `EDITOR_PASTE_IMAGE` + +可中断的钩子处理方法需要有返回值 `Promise | boolean`。当钩子处理方法返回 `true`,则后续的钩子不再运行。 + +内部的钩子类型可以参考 [Api 文档](https://yn-api-doc.vercel.app/modules/renderer_types.html#BuildInHookTypes) + +### Action 动作 + +Yank Note 有一个 Action 中心 [`ctx.action`](https://yn-api-doc.vercel.app/modules/renderer_core_action.html),提供动作的管理和运行。 + +内部 Action 可以参考 [Api 文档](https://yn-api-doc.vercel.app/modules/renderer_types.html#BuildInActions) + +## 插件能力 + +从上面可以看到,插件的功能都是通过 `ctx` 下面挂载的模块来实现。 + +除了上述核心的几个模块,ctx 下面还有很多其他模块,基本涵盖了编辑器的各个方面。 + +运行下面的代码,你可以看到 `ctx` 下都有哪些模块可供使用。 + +```js +// --run-- --no-worker-- +console.log(Object.keys(ctx).join('\n')) +``` + +参考 [Api 文档](https://yn-api-doc.vercel.app/modules/renderer_context.html) 获得更多使用说明。 + +## 分发插件 + +如果你想将自己编写的插件/主题分发给别人使用,请参考 https://github.com/purocean/yank-note-extension#readme + +## 更多 + +总体来说,Yank Note 鼓励用户打造自己的工作学习工具,只需要几行代码,即可给自己的工作学习助力。 + +另外,如果您只需要打造一些趁手的工具,可以不用编写插件,可以使用[代码运行](FEATURES_ZH-CN.md#运行代码)功能或者编写 [小工具](FEATURES_ZH-CN.md#小工具)来实现。这里的 js 代码运行能力也是完全开放,全局变量 `ctx` 也具有上述所有的功能。 + +例如运行代码功能: + +```js +// --run-- --no-worker-- +ctx.ui.useToast().show("info", "HELLOWORLD!") +console.log("hello world!") +``` + +小工具 + +```html + + +``` diff --git a/help/_FRAGMENT.md b/help/_FRAGMENT.md new file mode 100644 index 000000000..f7f3bb0b2 --- /dev/null +++ b/help/_FRAGMENT.md @@ -0,0 +1,6 @@ +--- +customVarFromOtherDoc: Hello, It's Me. +define: + --TEST_DEFINE--: definition from a fragment. +--- +*Content from a fragment.* diff --git a/help/logo-small.png b/help/logo-small.png new file mode 100644 index 000000000..112829e5b Binary files /dev/null and b/help/logo-small.png differ diff --git a/help/qrcode-wechat.jpg b/help/qrcode-wechat.jpg new file mode 100644 index 000000000..d3e24bff5 Binary files /dev/null and b/help/qrcode-wechat.jpg differ diff --git a/help/test.drawio b/help/test.drawio new file mode 100644 index 000000000..174048fd2 --- /dev/null +++ b/help/test.drawio @@ -0,0 +1 @@ +7V1fd6O4Dv80eZwewDYhj22S3rnnTLs92zmzdx9pcFJ2Cc4C6Z/99NcOkGDZSZMUMLSdlyE2CJD0k2RJpgM0Xr78J/FXjzcsoNHAsYKXAZoMHMdG7oj/J0Ze8xHPQvnAIgmD4qTdwH34Ly0GrWJ0HQY0lU7MGIuycCUPzlgc01kmjflJwp7l0+Ysku+68hdUGbif+ZE6+kcYZI/FWzjD3fh3Gi4eyzvb5Qsv/fLk4k3SRz9gz5UhNB2gccJYlh8tX8Y0Eswr+ZJfd71ndvtgCY2zYy5w8gue/GhdvNvAcSN+6dVKPF32Wryy+89aPNLV0k8WYTxAl3zWWnGBXm3eSgx+y9gqn8DlREZfsm9+FC6KK2b8qWhSmQvojCV+FrLihHUc0CQKY7o5p7wpP1oU/28eLc0SFi/K0buEzWia8qvt8oSHZCDe+xpeyIfkaysTKzj2uIfI6YyJ6DzLZ7xiBr7Z/Wua0SU/4T5bc1AceKom7n6XUK4xuRi0t3akmzpPNMlCjobLXLKTZRgEYu6qEPVkK2fGT51HGwWfh1zx0NWcxVkBaNspfl/7yzASpuA7jZ6oIC3Yny0jcRI/TJjQi6D4taFQPIxdaMPfWxwicQa/1ZhFLNk8Lpq619fjcaHo/NHpy16w2FsIcttF2ZJmCZeGVVzgIDe/5BX8ft4ZAbu0UY8VAzAqxvzC7iy2pHfQ5AcFOvVIRQpSpzEXL6VJKPQZiOj5Mczo/cqfid/P3ArrpLNXjiez+Pq6LhZjmcW2o7J4pOGwWwOHscLh/8ZPNM1Ykn4Y/mJL1mBit8ZeorD3t9tvk+nN5e1EGL4/739Ob/jBZPpr+uO3u5vp7U+F69xbrsThqjD56Op9ckhzQ2RdWHhIzpPLeFyP3lvWxaj6z5bkhEaqnLClERSpQVDuR4oJnP7HBJexH72mYfrlmw8ACI1ky2bjFn3zsP+IeQNHe7EiVm49BdiPMM349B1L0/CBU2456t7F/CxaC+F9AfwQwF0PuMSh6hIbA7j3iQGOewvwCc38UIhx/MhSumdt2yDAC1x/wfoArIkLViS4RViPPjGsSW9hfUufhdcs3Ge7mJ7QVMjzC9EHEA1yDKREbxuILul+Ski7/Ya0mfC73buOeZifJevZV1zwhhWxPe8C70+ItWtUbMWo3Pixv6BL8TIfJTXsAha71oVV/ecoDG8qU2yrVckPyG9E0AWpMliufLhE1fDGGP45iksIyzn2oWxSHDDdHvvVytMvGgcfqe5E8EjHXBO6rtahFC7TYEFLPsVsE7zFbFoZ3DGvwqzRSKBYYWtBXtA8zDz+CGydzIqzCkhmPLKgxVmOnsVVF6irCW0HExrx0PRJfgwdL4t73LFQ2NtdjUoSmlJryp++uGgnEIWODQjZkFD+zgqhjWS3732csHW1rA4KG3dP2B4QEgykjpU2AoQgnRqF/ZnLMMN+r/0MpXPuufZla0HZj4O2b/6TptkmvPpaBh4I2ZC8DMRyzNbqMlBXBYLOJA4uRYOpQV8yUn1JbhlVJr87ICB8iejZI3fIlxC2jYgkHOwBise6DGxZF8gZeq7n8aCceCOZrttgvKBWBETGN+FIEPaQRxPIFoKar+NNCoejL3tVlEB4AyFNHqZzfP8UnmTyjahgrSFL4sqs0fVzIQ0cnBrgUNLoC6+2laVtRKN2iDTGKzWDNGGztchnlG23W3YlTDQFpCE30L5ww6uE/UVnmboWbFHNPDk3gTRFusZYpyaDVKtbsbA0emDPFZN7tRngE48sCf/ljsyPmrfBRLXBhuN5IkfhBJ0ZztuAEIaE9hhj7hX918ppK3FCesC3gLowGkobGfhBTvFcS6/pp+6+WjmqWpFuqZVVl1pBQnWpFViNIrdmtVJTedPIfxDrQlox8jMmWs+uH2hM52FWmdisHw1aemQZtPQnZuY6AUmNpXfNQhImXErLeXoYDgg5kFBNmMSgYQDZNWPyxCRgJ/TK7ZypR6QuvSIt6RUMIZya9UrNN05fwjKnU1r0QI7zzVl2T67nyosh7hel+oyH2jP7alrllop0py/o7DwjzfXL5DoIe7bEQ0fmIZFnrRbXl2re4i5hiyRPGu9YuGKJ0YUkweSAEnJLJTOwPSUs6XadgS7wlTZ2dQxtg2NqSqOTHBsCVAK752pn2+Cfo/BP4VIfohJ7D/tbK1QCL2/r/dzJZUtAFlIFZGsLheV8ro3rDVlQXVmPsh4lhmJ++/8NxDZah5S//xy0UuvwuqeP28zae9skXECowTYJpGYtum+Lht2TvQt6JuX4EMH2pWM1YXiQrNIVVaNe9DEjo7EJppOkQ7AQ9c5UhG25e59tqckNKQ9cc5YUnZiRSelCLKGrmtV+Ws8zq0QIZsnOtSYw3Xas/ThZiWC/YFFErE2Jjkq/bD4+ZnTJiw76BGy90XTc2PpDTbtM/1mHq7zHfsu/dEVn4ZyHeobTV65NAONaXKrp9om+M2pu337t6QFqr1JYU6nQBYSaKhW6sCRZc1miNMM90ipNyG04tII5/rO1Cu5Db02rUM1apablfu9A5twtL9l+0Ar4PdyaNceOwqHtp1gqju8yePL5gmUh2FYd3zQKWz/CZZiXdIxyFUFlMpXNxH1sp9F0ypo2ZyBmJmevFAEhDAk1tFLENbdp4T5mpjTB18iwXsHg69zdPDCKw0du5zlZr6A79mrWqz5mtsp9ol1SLOCBhmfHX4CQe2T8dY7s1ezTdz8JnjnzB+VGoNLb37N5VkyIlbDZEApsg9DsfWnMveu21r0bLvQlzDaFo+JYFI2si82XWA3skXEMIwl47OG5HdrQ9bsNdWjjof4+tZloNUGlaTD43XiDAWwKkneocZSaCsnrylq16uJ0VT3DyHRAzLOnyncqTh10qCGvwU1uJeXOAwtCR/5eB0SW0xqyiJpj6QGyrO4hyz4IgSE+0wO+gSxItq6tJRhY/pr9IXH6AVuHEG1qb2fYDDlE0kDzkwnQIrOgBWHf2Si1Yfx4JC7Pgc4HySLt28f/tdtPfx/4RwHqzk6SXmaRulfFI/LHwTyw3Xzb9nS6nh366hiGZOtaGNsNa52av/pFk1T+jIDpbBUPz2UmuC06ejVbNRYue9PGIup54yqjyg8PlQNR+JD4SWi0XGqDrZfY1W48aoOXx3z0pmsmTuc6LbMmDi5JbDkqPt+VHlzrNLaNHjrwuk1cL3NXmmDddD0ZBGxnO1JAqCnX6cCGhpr1qqwqano/OuM5bTQ6tH4mrnYLZQuuoPTg4INPiy6FHdg5+L04c37Udd7WvKcyijPGPkTANnDIvab4xX/u/uhxDuzdn45G0/8D \ No newline at end of file diff --git a/help/test.luckysheet b/help/test.luckysheet new file mode 100644 index 000000000..c8c5a32a2 --- /dev/null +++ b/help/test.luckysheet @@ -0,0 +1 @@ +[{"name":"Sheet1","color":"","status":1,"order":0,"data":[[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null],[null,null,null,null,null,null,null,null,null,null,null,null,null,null]],"config":{"merge":{},"rowlen":{},"rowhidden":{}},"index":0,"jfgird_select_save":[],"luckysheet_select_save":[{"row":[0,0],"column":[0,0],"row_focus":0,"column_focus":0,"left":0,"width":73,"top":0,"height":19,"left_move":0,"width_move":73,"top_move":0,"height_move":19}],"visibledatarow":[],"visibledatacolumn":[],"ch_width":4560,"rh_height":1760,"luckysheet_selection_range":[],"zoomRatio":1,"scrollLeft":0,"scrollTop":0,"calcChain":[],"filter_select":null,"filter":null,"luckysheet_conditionformat_save":[],"luckysheet_alternateformat_save":[],"dataVerification":{},"hyperlink":{},"celldata":[]}] \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000..bd1a5cfa5 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,16 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + setupFilesAfterEnv: [ + 'jest-extended' + ], + coveragePathIgnorePatterns: [ + ], + moduleNameMapper: { + '@/(.*)': '/src/$1', + '@share/(.*)': '/src/share/$1', + '@main/(.*)': '/src/main/$1', + '@fe/(.*)': '/src/renderer/$1', + "^lodash-es$": "lodash" + } +}; diff --git a/package.json b/package.json index d4c92f56e..6bcfbf6cb 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "yank.note", - "version": "3.3.3", - "description": "Yank Note 一款面向程序员的 Markdown 笔记应用", + "version": "3.82.1", + "description": "Yank Note: A highly extensible Markdown editor, designed for productivity.", "main": "dist/main/app.js", - "license": "GPL-3.0", + "license": "AGPL-3.0", "author": { "name": "purocean", "email": "purocean@outlook.com" @@ -12,95 +12,150 @@ "dev": "vite", "start": "npm run build:main && electron ./dist/main/app.js", "build:fe": "vue-tsc --noEmit --esModuleInterop true && vite build", - "build:fe-demo": "yarn run build:fe --mode=demo && node scripts/copy-assets.js && cp help/* dist/renderer/", + "build:fe-demo": "node scripts/install-demo-extensions.js && yarn run build:fe --mode=demo && node scripts/copy-assets.js && cp help/* dist/renderer/ && cp src/main/resources/github.css dist/renderer/", "serve:fe": "vite preview", - "build:main": "tsc --esModuleInterop true src/main/app.ts --outDir ./dist/main && node ./scripts/copy-assets.js", + "build:main": "tsc --esModuleInterop true --target es2021 --moduleResolution node --module commonjs src/main/app.ts --outDir ./dist && node ./scripts/copy-assets.js", "build": "npm run build:fe && npm run build:main", - "lint": "vue-tsc --noEmit --esModuleInterop true && eslint src/ --ext .tsx,.ts,.vue" + "lint": "vue-tsc --noEmit --esModuleInterop true && eslint src/ --ext .tsx,.ts,.vue", + "test": "yarn run jest --coverage --coverage-reporters=text --silent", + "api-doc": "yarn run typedoc --options .typedoc.json", + "prepare": "husky install", + "rebuild-pty": "electron-rebuild -f -w node-pty" }, "dependencies": { + "@electron/remote": "^2.1.2", + "@vscode/ripgrep": "^1.15.6", + "adm-zip": "^0.5.9", + "app-license": "^0.3.0", + "async-lock": "^1.4.0", + "chokidar": "^3.6.0", "command-exists": "^1.2.9", - "electron-log": "^4.3.5", - "electron-progressbar": "^2.0.1", - "electron-store": "^5.2.0", - "electron-updater": "^4.3.9", - "fs-extra": "^10.0.0", - "koa": "^2.13.1", + "dayjs": "^1.10.5", + "electron-context-menu": "^3.6.1", + "electron-log": "^4.4.8", + "electron-progressbar": "^2.1.0", + "electron-store": "^8.1.0", + "electron-updater": "^6.1.1", + "fs-extra": "^11.2.0", + "glob": "^8.0.3", + "ip": "^1.1.9", + "jsonrpc-bridge": "^0.0.4", + "jsonwebtoken": "^9.0.0", + "koa": "^2.16.1", "koa-body": "^4.2.0", + "lodash": "^4.17.21", "mime": "^2.5.2", - "mime-types": "^2.1.31", "mitt": "^2.1.0", - "natural-orderby": "^2.0.3", - "node-pty": "^0.10.1", - "opn": "^6.0.0", - "plantuml-pipe": "^1.3.6", + "node-pty": "^1.1.0-beta27", + "pako": "^2.0.4", + "plantuml-pipe": "^1.4.0", + "qrcode": "^1.5.3", "request": "^2.88.2", + "ripgrep-wrapper": "^1.1.1", "safe-buffer": "^5.2.1", - "socket.io": "^2.4.0", + "socket.io": "^4.7.2", + "socks": "^2.8.3", + "tar-stream": "^2.2.0", "transliteration": "^2.2.0", + "undici": "^6.21.2", + "uuid": "^9.0.0", + "yaml": "^2.2.2", "yargs": "^15.3.1" }, "devDependencies": { + "@commitlint/cli": "^16.2.4", + "@commitlint/config-conventional": "^16.2.4", + "@electron/notarize": "^2.1.0", + "@electron/rebuild": "^3.7.1", "@json-editor/json-editor": "^2.5.4", + "@types/adm-zip": "^0.4.34", + "@types/async-lock": "^1.3.0", + "@types/canvas-confetti": "^1.6.0", "@types/command-exists": "^1.2.0", "@types/crypto-js": "^4.0.1", + "@types/dom-to-image": "^2.6.4", "@types/fs-extra": "^9.0.11", + "@types/glob": "^8.1.0", + "@types/ip": "^1.1.0", + "@types/jest": "^27.0.3", + "@types/jsonwebtoken": "^8.5.8", "@types/koa": "^2.0.49", "@types/lodash-es": "^4.17.4", - "@types/markdown-it": "^12.0.2", + "@types/markdown-it": "^13.0.7", "@types/mime": "^2.0.3", - "@types/mime-types": "^2.1.0", - "@types/node": "^15.12.2", + "@types/node": "^18.11.9", + "@types/pako": "^1.0.3", + "@types/prismjs": "^1.26.0", + "@types/qrcode": "^1.5.2", "@types/request": "^2.48.5", - "@types/socket.io-client": "^1.4.34", + "@types/socket.io-client": "^3.0.0", "@types/sortablejs": "^1.10.6", + "@types/tar-stream": "^2.2.2", "@types/turndown": "^5.0.0", + "@types/uuid": "^8.3.4", "@types/yargs": "^15.0.0", - "@typescript-eslint/eslint-plugin": "^4.27.0", - "@typescript-eslint/parser": "^4.27.0", - "@vitejs/plugin-vue": "^1.2.3", - "@vue/compiler-sfc": "^3.1.1", - "@vue/eslint-config-standard": "^5.1.2", - "@vue/eslint-config-typescript": "^5.0.2", - "autoprefixer": "^10.2.6", - "crypto-js": "^3.3.0", - "dayjs": "^1.10.5", - "echarts": "^5.1.2", - "electron": "11.4.6", - "electron-builder": "^22.11.7", - "electron-notarize": "^1.0.0", - "eslint": "^6.7.2", - "eslint-plugin-import": "^2.20.2", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "@vitejs/plugin-vue": "^5.2.1", + "@vitejs/plugin-vue-jsx": "^4.1.1", + "@vue/compiler-sfc": "^3.5.13", + "@vue/eslint-config-standard": "^8.0.1", + "@vue/eslint-config-typescript": "^13.0.0", + "autoprefixer": "^10.4.16", + "canvas-confetti": "^1.6.0", + "crypto-js": "^4.2.0", + "dexie": "^4.0.8", + "dom-to-image": "^2.6.0", + "electron": "33.3.1", + "electron-builder": "^26.0.12", + "eslint": "^8.57.0", + "eslint-plugin-import": "^2.29.1", "eslint-plugin-node": "^11.1.0", - "eslint-plugin-promise": "^4.2.1", - "eslint-plugin-standard": "^4.0.0", - "eslint-plugin-vue": "^7.11.1", - "github-markdown-css": "^4.0.0", - "highlight.js": "^11.0.1", - "katex": "^0.13.11", + "eslint-plugin-promise": "^7.0.0", + "eslint-plugin-vue": "^9.27.0", + "filenamify": "^5.1.0", + "front-matter": "^4.0.2", + "husky": "^8.0.1", + "jest": "^29.7.0", + "jest-extended": "^1.2.0", + "joplin-turndown-plugin-gfm": "^1.0.12", + "js-untar": "^2.0.0", + "juice": "^8.0.0", + "katex": "^0.16.21", "lodash-es": "^4.17.21", - "luckyexcel": "^1.0.1", - "luckysheet": "^2.1.13", - "markdown-it": "^12.0.6", - "markdown-it-attrs": "^4.0.0", - "markdown-it-multimd-table": "^4.1.0", - "mermaid": "^8.10.2", - "monaco-editor": "^0.25.2", + "markdown-it": "^14.1.0", + "markdown-it-abbr": "^2.0.0", + "markdown-it-attributes": "^1.2.0", + "markdown-it-container": "^4.0.0", + "markdown-it-emoji": "^3.0.0", + "markdown-it-github-alerts": "^0.3.0", + "markdown-it-mark": "^4.0.0", + "markdown-it-multimd-table": "^4.2.3", + "markdown-it-sub": "^2.0.0", + "markdown-it-sup": "^2.0.0", + "monaco-editor": "^0.49.0", "normalize.css": "^8.0.1", - "sass": "^1.35.1", - "socket.io-client": "^2.4.0", + "parse-author": "^2.0.0", + "path-browserify": "^1.0.1", + "prismjs": "^1.30.0", + "sass": "^1.83.0", + "socket.io-client": "^4.7.2", "sortablejs": "^1.13.0", - "ts-node": "^10.0.0", - "turndown": "^7.0.0", - "typescript": "^4.3.2", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "turndown": "^7.2.0", + "typedoc": "^0.26.5", + "typescript": "^5.5.4", "utility-types": "^3.10.0", - "viewerjs": "^1.10.0", - "vite": "^2.3.7", - "vue": "^3.1.4", - "vue-router": "^4.0.0-0", - "vue-tsc": "^0.0.24", - "vuex": "^4.0.0-0", - "xterm": "^4.13.0", - "xterm-addon-fit": "^0.5.0" + "viewerjs": "^1.11.6", + "vite": "^6.1.6", + "vue": "^3.5.13", + "vue-tsc": "^2.2.0", + "xterm": "^4.18.0", + "xterm-addon-fit": "^0.5.0", + "xterm-theme": "^1.1.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" } } diff --git a/scripts/copy-assets.js b/scripts/copy-assets.js index 3660824bf..0ce7015d1 100644 --- a/scripts/copy-assets.js +++ b/scripts/copy-assets.js @@ -12,7 +12,17 @@ dirs.forEach(dir => { fs.copySync(origin, dist) }) -// copy readme.md -const readme = path.join(__dirname, '..', 'README.md') -const md = fs.readFileSync(readme).toString('UTF-8').replace(/\]\(\.\/help\//ig, '](./').replace(/src="\.\/help\//ig, 'src="./') +// copy README.md +let readme = path.join(__dirname, '..', 'README.md') +let md = fs.readFileSync(readme).toString('UTF-8').replace(/\]\(\.\/help\//ig, '](./').replace(/src="\.\/help\//ig, 'src="./') fs.writeFileSync(path.join(__dirname, '..', './help/README.md'), md) + +// copy README_ZH-CN.md +readme = path.join(__dirname, '..', 'README_ZH-CN.md') +md = fs.readFileSync(readme).toString('UTF-8').replace(/\]\(\.\/help\//ig, '](./').replace(/src="\.\/help\//ig, 'src="./') +fs.writeFileSync(path.join(__dirname, '..', './help/README_ZH-CN.md'), md) + +// copy README_RU.md +readme = path.join(__dirname, '..', 'README_RU.md') +md = fs.readFileSync(readme).toString('UTF-8').replace(/\]\(\.\/help\//ig, '](./').replace(/src="\.\/help\//ig, 'src="./') +fs.writeFileSync(path.join(__dirname, '..', './help/README_RU.md'), md) diff --git a/scripts/download-pandoc.js b/scripts/download-pandoc.js index 30aa97249..f9ba16088 100644 --- a/scripts/download-pandoc.js +++ b/scripts/download-pandoc.js @@ -3,8 +3,12 @@ const fs = require('fs') const path = require('path') const request = require('request') -const filename = os.platform() + '-' + 'pandoc-2.7.3' + (os.platform() === 'win32' ? '.exe' : '') -const downloadUrl = 'https://github.com/purocean/yn/releases/download/v1.1/' + filename +const forceArm64 = process.argv.includes('--force-arm64') + +const filename = os.platform() + '-' + 'pandoc-2.14.2' + (os.platform() === 'win32' ? '.exe' : '') +const downloadUrl = 'https://github.com/purocean/yn/releases/download/v1.1/' + (forceArm64 ? (filename + '-arm64') : filename) + +console.info('Download pandoc', downloadUrl, filename) const dir = path.join(__dirname, '../bin') if (!fs.existsSync(dir)) { @@ -13,7 +17,11 @@ if (!fs.existsSync(dir)) { const filePath = path.join(dir, filename) if (fs.existsSync(filePath)) { - console.warn('Pandoc exists. Skip download.', filePath) + if (forceArm64) { + fs.unlinkSync(filePath) + } else { + console.warn('Pandoc exists. Skip download.', filePath) + } } else { console.info('Download pandoc', downloadUrl, filename, filePath) request(downloadUrl).pipe(fs.createWriteStream(filePath, { mode: 0o755 })) diff --git a/scripts/download-plantuml.js b/scripts/download-plantuml.js new file mode 100644 index 000000000..90aaabda3 --- /dev/null +++ b/scripts/download-plantuml.js @@ -0,0 +1,21 @@ +const fs = require('fs') +const path = require('path') +const request = require('request') + +const filename = 'plantuml.jar' +const downloadUrl = 'https://github.com/plantuml/plantuml/releases/download/v1.2025.0/plantuml.jar' + +console.info('Download plantuml', downloadUrl, filename) + +const dir = path.join(__dirname, '../bin') +if (!fs.existsSync(dir)) { + fs.mkdirSync(dir) +} + +const filePath = path.join(dir, filename) +if (fs.existsSync(filePath)) { + console.warn('Pandoc exists. Skip download.', filePath) +} else { + console.info('Download plantuml', downloadUrl, filename, filePath) + request(downloadUrl).pipe(fs.createWriteStream(filePath, { mode: 0o755 })) +} diff --git a/scripts/download-ripgrep.sh b/scripts/download-ripgrep.sh new file mode 100755 index 000000000..1ba0e3c95 --- /dev/null +++ b/scripts/download-ripgrep.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +set -e + +# Runing only on Darwin +if [ "$(uname)" != "Darwin" ]; then + exit 0 +fi + +NODE_MODULES_PATH="$(dirname "$0")/../node_modules" +BIN_PATH="$(dirname "$0")/../bin" + +# Download x64 ripgrep +export npm_config_arch=x64 +node "$NODE_MODULES_PATH/@vscode/ripgrep/lib/postinstall.js" --force +mv "$NODE_MODULES_PATH/@vscode/ripgrep/bin/rg" "$BIN_PATH/rg-darwin-x64" + +# Download arm64 ripgrep +export npm_config_arch=arm64 +node "$NODE_MODULES_PATH/@vscode/ripgrep/lib/postinstall.js" --force +mv "$NODE_MODULES_PATH/@vscode/ripgrep/bin/rg" "$BIN_PATH/rg-darwin-arm64" diff --git a/scripts/install-demo-extensions.js b/scripts/install-demo-extensions.js new file mode 100644 index 000000000..bb10f0f71 --- /dev/null +++ b/scripts/install-demo-extensions.js @@ -0,0 +1,90 @@ +// @ts-check + +const request = require('request'); +const fs = require('fs-extra'); +const path = require('path') +const zlib = require('zlib') +const tar = require('tar-stream') +const stream = require('stream') + +function installExtension (extension) { + const extensionPath = path.join( + __dirname, + '../src/renderer/public/extensions', + extension.name.replace(/\//g, '$') + ) + + fs.ensureDirSync(extensionPath) + + return new Promise((resolve, reject) => { + const url = extension.dist.tarball; + request({ url, encoding: null }, (err, _, body) => { + if (err) { + reject(err) + return + } + + zlib.unzip(body, (err, data) => { + if (err) { + reject(err) + return + } + + const extract = tar.extract() + + extract.on('entry', (header, stream, next) => { + const filePath = path.join(extensionPath, header.name.replace(/^package/, '')) + console.log('[extension] write file', filePath) + + fs.ensureFile(filePath).then(() => { + const fileStream = fs.createWriteStream(filePath) + stream.pipe(fileStream) + stream.on('end', next) + }).catch(reject) + }) + + extract.on('finish', () => { + resolve(undefined) + }) + + extract.on('error', reject) + + stream.Readable.from(data).pipe(extract) + }) + }) + }) +} + +async function install (extensions) { + for (const extension of extensions) { + if (extension.name.startsWith('@yank-note/extension-')) { + console.log('[extension] install', extension.name) + await installExtension(extension) + } else { + console.log('[extension] skip', extension.name) + } + } +} + +const registryUrl = 'https://raw.githubusercontent.com/purocean/yank-note-registry/main/index.json' +console.log('Download registry') +request(registryUrl, (err, _, body) => { + if (err) { + console.error(err); + return; + } + + const registry = JSON.parse(body); + + const extensions = registry.filter(x => + x.origin === 'official' && + x.name !== '@yank-note/extension-milkdown' + ) + .map(x => ({ id: x.name, enabled: true, isDev: false })) + const extensionsFile = path.join(__dirname, '../src/renderer/public/extensions.json') + console.log(`Writing extensions ${extensionsFile}`) + fs.writeJSONSync(extensionsFile, extensions); + + console.log('Install extensions', extensions.length) + install(registry) +}) diff --git a/scripts/install.sh b/scripts/install.sh deleted file mode 100755 index 6032d4db2..000000000 --- a/scripts/install.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -export npm_config_disturl=https://atom.io/download/electron -export npm_config_target=11.4.6 -export npm_config_runtime="electron" - -yarn install diff --git a/scripts/notarize.js b/scripts/notarize.js index 1c637b2b5..a134da539 100644 --- a/scripts/notarize.js +++ b/scripts/notarize.js @@ -1,5 +1,5 @@ require('dotenv').config(); -const { notarize } = require('electron-notarize'); +const { notarize } = require('@electron/notarize'); exports.default = async function notarizing(context) { const { electronPlatformName, appOutDir } = context; @@ -10,9 +10,11 @@ exports.default = async function notarizing(context) { } return await notarize({ + tool: 'notarytool', appBundleId: 'yank.note', appPath: `${appOutDir}/${appName}.app`, appleId: process.env.APPLEID, appleIdPassword: process.env.APPLEIDPASS, + teamId: process.env.TEAMID, }); }; diff --git a/src/main/app.ts b/src/main/app.ts index 1a607b78f..dd60d302b 100644 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -1,32 +1,82 @@ -import { protocol, app, BrowserWindow, Menu, Tray, powerMonitor, dialog, OpenDialogOptions } from 'electron' +/* eslint-disable @typescript-eslint/no-var-requires */ +import { protocol, app, Menu, Tray, powerMonitor, dialog, OpenDialogOptions, screen, shell, BrowserWindow, Display, Rectangle } from 'electron' +import type TBrowserWindow from 'electron' import * as path from 'path' import * as os from 'os' - +import * as fs from 'fs-extra' import * as yargs from 'yargs' -import server from './server' +import httpServer from './server' +import store from './storage' import { APP_NAME } from './constant' -import { mainMenus, inputMenu, selectionMenu, getTrayMenus } from './menus' +import { getTrayMenus, getMainMenus } from './menus' import { transformProtocolRequest } from './protocol' -import opn from 'opn' import startup from './startup' import { registerAction } from './action' import { registerShortcut } from './shortcut' +import { initJSONRPCClient, jsonRPCClient } from './jsonrpc' +import { $t } from './i18n' +import { getProxyDispatcher, newProxyDispatcher } from './proxy-dispatcher' +import config from './config' +import { initProxy } from './proxy' +import { initEnvs } from './envs' + +app.commandLine.appendSwitch('enable-features', 'SharedArrayBuffer') + +type WindowState = { maximized: boolean } & Rectangle + +initProxy() +initEnvs() + +const electronContextMenu = require('electron-context-menu') +const electronRemote = require('@electron/remote/main') const isMacos = os.platform() === 'darwin' const isLinux = os.platform() === 'linux' let urlMode: 'scheme' | 'dev' | 'prod' = 'scheme' +let skipBeforeUnloadCheck = false +let macOpenFilePath = '' const trayEnabled = !(yargs.argv['disable-tray']) -const backendPort = Number(yargs.argv.port) || 3044 +const backendPort = Number(yargs.argv.port) || config.get('server.port', 3044) const devFrontendPort = 8066 -Menu.setApplicationMenu(mainMenus) +electronRemote.initialize() +electronContextMenu({ + showLookUpSelection: true, + showSearchWithGoogle: false, + showCopyImage: true, + showCopyImageAddress: false, + showSaveImage: false, + showSaveImageAs: true, + showSaveLinkAs: false, + showInspectElement: false, + showServices: true, +}) +Menu.setApplicationMenu(getMainMenus()) + +let fullscreen = false +let win: TBrowserWindow.BrowserWindow | null = null +let tray: Tray | null = null + +const getOpenFilePathFromArgv = (argv: string[]) => { + const filePath = [...argv].reverse().find(x => + x !== process.argv[0] && + !x.startsWith('-') && + !x.endsWith('app.js') + ) + + return filePath ? path.resolve(process.cwd(), filePath) : null +} + +const getDeepLinkFromArgv = (argv: string[]) => { + const lastArgv = argv[argv.length - 1] + if (lastArgv && lastArgv.startsWith(APP_NAME + '://')) { + return lastArgv + } -// 主窗口 -let win: BrowserWindow | null = null -// 系统托盘 -let tray = null + return null +} const getUrl = (mode?: typeof urlMode) => { mode = mode ?? urlMode @@ -60,38 +110,188 @@ const hideWindow = () => { } } +const restoreWindowBounds = () => { + const state: WindowState | null = store.get('window.state', null) as any + if (state) { + if (state.maximized) { + win!.maximize() + } else { + const validateWindowState = (state: WindowState, displays: Display[]): WindowState | undefined => { + if (state.width <= 0 || state.height <= 0) { + return undefined + } + + const getWorkingArea = (display: Display): Rectangle | undefined => { + if (display.workArea.width > 0 && display.workArea.height > 0) { + return display.workArea + } + + if (display.bounds.width > 0 && display.bounds.height > 0) { + return display.bounds + } + + return undefined + } + + if (displays.length === 1) { + const displayWorkingArea = getWorkingArea(displays[0]) + if (displayWorkingArea) { + const ensureStateInDisplayWorkingArea = (): void => { + if (!state || typeof state.x !== 'number' || typeof state.y !== 'number' || !displayWorkingArea) { + return + } + + if (state.x < displayWorkingArea.x) { + // prevent window from falling out of the screen to the left + state.x = displayWorkingArea.x + } + + if (state.y < displayWorkingArea.y) { + // prevent window from falling out of the screen to the top + state.y = displayWorkingArea.y + } + } + + // ensure state is not outside display working area (top, left) + ensureStateInDisplayWorkingArea() + + if (state.width > displayWorkingArea.width) { + // prevent window from exceeding display bounds width + state.width = displayWorkingArea.width + } + + if (state.height > displayWorkingArea.height) { + // prevent window from exceeding display bounds height + state.height = displayWorkingArea.height + } + + if (state.x > (displayWorkingArea.x + displayWorkingArea.width - 128)) { + // prevent window from falling out of the screen to the right with + // 128px margin by positioning the window to the far right edge of + // the screen + state.x = displayWorkingArea.x + displayWorkingArea.width - state.width + } + + if (state.y > (displayWorkingArea.y + displayWorkingArea.height - 128)) { + // prevent window from falling out of the screen to the bottom with + // 128px margin by positioning the window to the far bottom edge of + // the screen + state.y = displayWorkingArea.y + displayWorkingArea.height - state.height + } + + // again ensure state is not outside display working area + // (it may have changed from the previous validation step) + ensureStateInDisplayWorkingArea() + } + + return state + } + + // Multi Monitor (non-fullscreen): ensure window is within display bounds + let display: Display | undefined + let displayWorkingArea: Rectangle | undefined + try { + display = screen.getDisplayMatching({ x: state.x, y: state.y, width: state.width, height: state.height }) + displayWorkingArea = getWorkingArea(display) + } catch (error) { + // Electron has weird conditions under which it throws errors + // e.g. https://github.com/microsoft/vscode/issues/100334 when + // large numbers are passed in + } + + if ( + display && // we have a display matching the desired bounds + displayWorkingArea && // we have valid working area bounds + state.x + state.width > displayWorkingArea.x && // prevent window from falling out of the screen to the left + state.y + state.height > displayWorkingArea.y && // prevent window from falling out of the screen to the top + state.x < displayWorkingArea.x + displayWorkingArea.width && // prevent window from falling out of the screen to the right + state.y < displayWorkingArea.y + displayWorkingArea.height // prevent window from falling out of the screen to the bottom + ) { + return state + } + + return undefined + } + + const displays = screen.getAllDisplays() + const validatedState = validateWindowState(state, displays) + if (validatedState) { + win!.setBounds(validatedState) + } + } + } +} + +const saveWindowBounds = () => { + if (win) { + const fullscreen = win.isFullScreen() + const maximized = win.isMaximized() + + // save bounds only when not fullscreen + if (!fullscreen) { + const state: WindowState = { ...win.getBounds(), maximized } + store.set('window.state', state) + } + } +} + const createWindow = () => { win = new BrowserWindow({ maximizable: true, show: false, - minWidth: 800, + minWidth: 940, minHeight: 500, frame: false, backgroundColor: '#282a2b', titleBarStyle: isMacos ? 'hidden' : undefined, - fullscreenable: false, + fullscreenable: true, webPreferences: { webSecurity: false, nodeIntegration: true, - enableRemoteModule: true, + contextIsolation: false, }, - // Linux 上设置窗口图标 + // for linux icon. ...(isLinux ? { icon: path.join(__dirname, './assets/icon.png') } : undefined) }) - // win.maximize() - win.show() win.setMenu(null) + win && win.loadURL(getUrl()) + restoreWindowBounds() + win.once('ready-to-show', () => { + // open file from argv + const filePath = macOpenFilePath || getOpenFilePathFromArgv(process.argv) + if (filePath) { + win?.show() + tryOpenFile(filePath) + return + } - // 不明原因,第一次启动窗口不能正确加载js - setTimeout(() => { - win && win.loadURL(getUrl()) - }, 0) + // reset macOpenFilePath + macOpenFilePath = '' + + // hide window on startup + if (config.get('hide-main-window-on-startup', false)) { + hideWindow() + } else { + win?.show() + } + }) + + win.on('ready-to-show', () => { + skipBeforeUnloadCheck = false + }) win.on('close', e => { - if (trayEnabled) { + e.preventDefault() + + saveWindowBounds() + + // keep running in tray + if (trayEnabled && config.get('keep-running-after-closing-window', !isMacos)) { hideWindow() - e.preventDefault() + } else { + // quit app + quit() } }) @@ -99,21 +299,32 @@ const createWindow = () => { win = null }) - win.webContents.on('context-menu', (e, props) => { - const { selectionText, isEditable } = props - if (isEditable) { - inputMenu.popup({ window: win || undefined }) - } else if (selectionText && selectionText.trim() !== '') { - selectionMenu.popup({ window: win || undefined }) + win.on('enter-full-screen', () => { + fullscreen = true + }) + + win.on('leave-full-screen', () => { + fullscreen = false + }) + + initJSONRPCClient(win.webContents) + + win.webContents.on('will-navigate', (e) => { + e.preventDefault() + }) + + win.webContents.on('will-prevent-unload', (e) => { + if (skipBeforeUnloadCheck) { + e.preventDefault() } }) } -const showWindow = () => { +const showWindow = (showInCurrentWindow = true) => { if (win) { const show = () => { if (win) { - // macos 上展示图标 + // macos need show in dock isMacos && app.dock.show() win.setSkipTaskbar(false) win.show() @@ -121,72 +332,136 @@ const showWindow = () => { } } - if (isMacos) { - show() + if (showInCurrentWindow && !fullscreen) { + if (isMacos) { + // show in current workspace + win.setVisibleOnAllWorkspaces(true) + show() + win.setVisibleOnAllWorkspaces(false) + } else { + // hide first, then show in current desktop. for windows 10. + hideWindow() + setTimeout(show, 100) + } } else { - // 先隐藏再显示,以便在 windows 10 当前虚拟窗口展示 - hideWindow() - setTimeout(show, 100) + show() } } else { createWindow() } } -const reload = () => { - win && win.loadURL(getUrl()) -} - -const quit = () => { - if (!win) { - app.exit(0) - return - } +const ensureDocumentSaved = () => { + return new Promise((resolve, reject) => { + if (!win) { + reject(new Error('window is not ready')) + return + } - const contents = win.webContents - if (contents) { + const contents = win!.webContents contents.executeJavaScript('window.documentSaved', true).then(val => { if (!win) { + reject(new Error('window is not ready')) return } - if (val === false) { - dialog.showMessageBox(win, { - type: 'question', - buttons: ['取消', '放弃保存并退出'], - title: '提示', - message: '有文档未保存,是否要退出?' - }).then(choice => { - if (choice.response === 1) { - win && win.destroy() - app.quit() - } - }) - } else { - win.destroy() - app.quit() + if (val) { + resolve(undefined) + return } + + dialog.showMessageBox(win!, { + type: 'question', + title: $t('quit-check-dialog.title'), + message: $t('quit-check-dialog.desc'), + buttons: [ + $t('quit-check-dialog.buttons.cancel'), + $t('quit-check-dialog.buttons.discard') + ], + }).then(choice => { + if (choice.response === 1) { + resolve(undefined) + } else { + reject(new Error('document not saved')) + } + }, reject) }) + }) +} + +const reload = async () => { + if (win) { + skipBeforeUnloadCheck = true + await ensureDocumentSaved() + win.loadURL(getUrl()) + } +} + +const quit = async () => { + saveWindowBounds() + + if (!win) { + app.exit(0) + return } + + await ensureDocumentSaved() + + win.destroy() + app.quit() } -const showSetting = () => { +const showSetting = (key?: string) => { if (!win || !win.webContents) { return } showWindow() - win.webContents.executeJavaScript('window.ctx.action.getAction("status-bar.show-setting")();', true) + // delay to show setting panel to ensure window is ready. + setTimeout(() => { + jsonRPCClient.call.ctx.setting.showSettingPanel(key) + }, 200) +} + +const toggleFullscreen = () => { + win && win.setFullScreen(!fullscreen) } const serve = () => { try { - const handler = server(backendPort) + const { callback: handler, server } = httpServer(backendPort) + + if (server) { + server.on('error', (e: Error) => { + console.error(e) + + if (e.message.includes('EADDRINUSE') || e.message.includes('EACCES')) { + // wait for electron app ready. + setTimeout(async () => { + await dialog.showMessageBox({ + type: 'error', + title: 'Error', + message: $t('app.error.EADDRINUSE', String(backendPort)) + }) + + setTimeout(() => { + showSetting('server.port') + }, 500) + }, 4000) + return + } + + throw e + }) + } + protocol.registerStreamProtocol('yank-note', async (request, callback) => { - // 自定义 protocol 协议转换为 koa 请求 + // transform protocol data to koa request. const { req, res, out } = await transformProtocolRequest(request) + ;(req as any)._protocol = true await handler(req, res) + // eslint-disable-next-line n/no-callback-literal callback({ headers: res.getHeaders() as any, statusCode: res.statusCode, @@ -206,8 +481,9 @@ const showOpenDialog = (params: OpenDialogOptions) => { } const showTray = () => { - tray = new Tray(path.join(__dirname, './assets/tray.png')) - tray.setToolTip('Yank Note 一款面向程序员的 Markdown 编辑器') + const img = isMacos ? 'trayTemplate.png' : 'tray.png' + tray = new Tray(path.join(__dirname, `./assets/${img}`)) + tray.setToolTip(`${$t('app-name')} - ${$t('slogan')}`) if (isMacos) { tray.on('click', function (this: Tray) { this.popUpContextMenu() }) } else { @@ -216,10 +492,36 @@ const showTray = () => { tray.setContextMenu(getTrayMenus()) } -const openInBrowser = () => opn(getUrl('prod')) +const openInBrowser = () => shell.openExternal(getUrl('prod')) + +function refreshMenus () { + Menu.setApplicationMenu(getMainMenus()) + if (tray) { + tray.setContextMenu(getTrayMenus()) + } +} + +async function tryOpenFile (path: string) { + console.log('tryOpenFile', path) + const stat = await fs.stat(path) + + if (stat.isFile()) { + jsonRPCClient.call.ctx.doc.switchDocByPath(path) + showWindow() + } else { + win && dialog.showMessageBox(win, { message: 'Yank Note only support open file.' }) + } +} + +async function tryHandleDeepLink (url: string) { + if (url) { + jsonRPCClient.call.ctx.base.triggerDeepLinkOpen(url) + } +} registerAction('show-main-window', showWindow) registerAction('hide-main-window', hideWindow) +registerAction('toggle-fullscreen', toggleFullscreen) registerAction('show-main-window-setting', showSetting) registerAction('reload-main-window', reload) registerAction('get-main-widow', () => win) @@ -230,15 +532,54 @@ registerAction('get-dev-frontend-port', () => devFrontendPort) registerAction('open-in-browser', openInBrowser) registerAction('quit', quit) registerAction('show-open-dialog', showOpenDialog) +registerAction('refresh-menus', refreshMenus) +registerAction('get-proxy-dispatcher', getProxyDispatcher) +registerAction('new-proxy-dispatcher', newProxyDispatcher) powerMonitor.on('shutdown', quit) +if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient(APP_NAME, process.execPath, [path.resolve(process.argv[1])]) + } +} else { + app.setAsDefaultProtocolClient(APP_NAME) +} + const gotTheLock = app.requestSingleInstanceLock() if (!gotTheLock) { app.exit() } else { - app.on('second-instance', () => { + app.on('second-instance', (e, argv) => { + console.log('second-instance', argv) showWindow() + + const url = getDeepLinkFromArgv(argv) + if (url) { + tryHandleDeepLink(url) + return + } + + // only check last param of argv. + const path = getOpenFilePathFromArgv([argv[argv.length - 1]]) + if (path) { + tryOpenFile(path) + } + }) + + app.on('open-file', (e, path) => { + e.preventDefault() + + if (!win || win.webContents.isLoading()) { + macOpenFilePath = path + } else { + tryOpenFile(path) + } + }) + + app.on('open-url', (e, url) => { + e.preventDefault() + tryHandleDeepLink(url) }) app.on('ready', () => { @@ -246,17 +587,102 @@ if (!gotTheLock) { serve() showWindow() + // getLocale returns empty string before ready. so refresh menus after ready. + refreshMenus() + if (trayEnabled) { showTray() } registerShortcut({ 'show-main-window': showWindow, + 'hide-main-window': hideWindow, 'open-in-browser': openInBrowser }) }) app.on('activate', () => { - showWindow() + showWindow(false) + }) + + app.on('web-contents-created', (_, webContents) => { + electronRemote.enable(webContents) + + // fix focus issue after dialog show on Windows. + webContents.on('frame-created', (_, { frame }) => { + if (!frame) { + return + } + + frame.on('dom-ready', () => { + frame.executeJavaScript(`if ('ctx' in window && ctx?.env?.isWindows) { + window._FIX_ELECTRON_DIALOG_FOCUS ??= function () { + setTimeout(() => { + ctx.env.getElectronRemote().getCurrentWindow().blur(); + ctx.env.getElectronRemote().getCurrentWindow().focus(); + }, 0); + }; + + if (!window._ORIGIN_ALERT) { + window._ORIGIN_ALERT = window.alert; + window.alert = function (...args) { + window._ORIGIN_ALERT(...args); + window._FIX_ELECTRON_DIALOG_FOCUS(); + }; + } + + if (!window._ORIGIN_CONFIRM) { + window._ORIGIN_CONFIRM = window.confirm; + window.confirm = function (...args) { + const res = window._ORIGIN_CONFIRM(...args); + window._FIX_ELECTRON_DIALOG_FOCUS(); + return res; + }; + } + }`) + }) + }) + + webContents.setWindowOpenHandler(({ url, features }) => { + if (url.includes('__allow-open-window__')) { + return { action: 'allow' } + } + + const allowList = [ + `${APP_NAME}://`, + `http://localhost:${backendPort}`, + `http://localhost:${devFrontendPort}`, + `http://127.0.0.1:${backendPort}`, + `http://127.0.0.1:${devFrontendPort}`, + ] + + if (!allowList.find(x => url.startsWith(x))) { + shell.openExternal(url) + return { action: 'deny' } + } + + const webPreferences: Record = {} + + // electron not auto parse features below. https://www.electronjs.org/docs/latest/api/window-open + const extraFeatureKeys = [ + 'experimentalFeatures', + 'nodeIntegrationInSubFrames', + 'webSecurity', + ] + + extraFeatureKeys.forEach(key => { + const match = features.match(new RegExp(`${key}=([^,]+)`)) + if (match) { + webPreferences[key] = match[1] === 'true' ? true : match[1] === 'false' ? false : match[1] + } + }) + + return { + action: 'allow', + overrideBrowserWindowOptions: { + webPreferences: Object.keys(webPreferences).length > 0 ? webPreferences : undefined, + } + } + }) }) } diff --git a/src/main/assets/trayTemplate.png b/src/main/assets/trayTemplate.png new file mode 100644 index 000000000..4d1dc82c2 Binary files /dev/null and b/src/main/assets/trayTemplate.png differ diff --git a/src/main/assets/trayTemplate@2x.png b/src/main/assets/trayTemplate@2x.png new file mode 100644 index 000000000..bef895916 Binary files /dev/null and b/src/main/assets/trayTemplate@2x.png differ diff --git a/src/main/assets/trayTemplate@3x.png b/src/main/assets/trayTemplate@3x.png new file mode 100644 index 000000000..339ee0a2a Binary files /dev/null and b/src/main/assets/trayTemplate@3x.png differ diff --git a/src/main/assets/trayTemplate@4x.png b/src/main/assets/trayTemplate@4x.png new file mode 100644 index 000000000..cb4ef580d Binary files /dev/null and b/src/main/assets/trayTemplate@4x.png differ diff --git a/src/main/config.ts b/src/main/config.ts index 355f8ec72..27476db80 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -1,24 +1,58 @@ -import * as fs from 'fs' +import * as fs from 'fs-extra' +import cloneDeep from 'lodash/cloneDeep' import { CONFIG_FILE } from './constant' +import store from './storage' const configFile = CONFIG_FILE const writeJson = (data: any) => { - fs.writeFileSync(configFile, JSON.stringify(data, null, 4), 'utf8') + if (!data) return + + data = cloneDeep(data) + // save license to store + if (data.license) { + store.set('license', data.license) + } else { + store.delete('license') + } + + delete data.license + fs.ensureFileSync(configFile) + fs.writeJsonSync(configFile, data, { spaces: 2 }) } const readJson = () => { - if (fs.existsSync(configFile)) { - const data = fs.readFileSync(configFile) - return JSON.parse(data.toString()) - } else { + try { + const result = fs.readJSONSync(configFile) + + // get license from store + const license = store.get('license', '') + result.license = license || result.license || '' + + return result + } catch (error) { + console.error(error) return null } } -const getAll = () => readJson() || {} +let cache: any = null +let readAt = 0 +const getAll = () => { + if (Date.now() - readAt > 1000) { + cache = null + } + + if (!cache) { + cache = readJson() || {} + readAt = Date.now() + } + + return cache +} const setAll = (data: any) => { + cache = null writeJson(data) } @@ -32,7 +66,7 @@ const get = (key: string, defaultVal: any = null) => { const config = getAll() if (config[key] === undefined) { - set(key, defaultVal) // 写入默认值到配置文件 + set(key, defaultVal) // write default value to config file. return defaultVal } diff --git a/src/main/constant.ts b/src/main/constant.ts index 3a0e1ffb8..25da0614d 100644 --- a/src/main/constant.ts +++ b/src/main/constant.ts @@ -10,15 +10,26 @@ export const APP_NAME = 'yank-note' export const HOME_DIR = homedir export const USER_DIR = path.resolve((yargs.argv['data-dir'] as any) || path.join(homedir, APP_NAME)) -export const TRASH_DIR = path.join(USER_DIR, 'trash') export const CONFIG_FILE = path.join(USER_DIR, 'config.json') export const STATIC_DIR = path.join(__dirname, '../renderer') export const HELP_DIR = path.join(__dirname, '../../help') export const ASSETS_DIR = path.join(__dirname, 'assets') +export const HISTORY_DIR = path.join(USER_DIR, './histories') export const USER_PLUGIN_DIR = path.join(USER_DIR, './plugins') +export const USER_THEME_DIR = path.join(USER_DIR, './themes') +export const USER_EXTENSION_DIR = path.join(USER_DIR, './extensions') +export const USER_DATA = path.join(USER_DIR, './data') export const BIN_DIR = convertAppPath(path.join(__dirname, '../../bin')) export const RESOURCES_DIR = convertAppPath(path.join(__dirname, 'resources')) +export const CACHE_DIR = path.join(os.tmpdir(), APP_NAME) + +export const BUILD_IN_STYLES = ['github.css'] + +export const PANDOC_REFERENCE_FILE = 'pandoc-reference.docx' + +export const GITHUB_URL = 'https://github.com/purocean/yn' + export const FLAG_DISABLE_SERVER = false export const FLAG_DISABLE_DEVTOOL = false diff --git a/src/main/envs.ts b/src/main/envs.ts new file mode 100644 index 000000000..5ea96dcdb --- /dev/null +++ b/src/main/envs.ts @@ -0,0 +1,52 @@ +import { registerAction } from './action' +import config from './config' +import yaml from 'yaml' +import os from 'os' + +const keyEnvs = 'envs' +const isWin = os.platform() === 'win32' +const OLD_ENVS = { ...process.env } + +export function initEnvs () { + if (isWin) { + console.log('envs: disable on windows') + return + } + + process.env = OLD_ENVS + const envsStr = config.get(keyEnvs, '') + console.log('envs:', envsStr) + + let envs: Record + try { + envs = yaml.parse(envsStr) + if (!envs || typeof envs !== 'object') { + return + } + } catch (error) { + console.error('parse envs error:', error) + return + } + + const sep = isWin ? ';' : ':' + + Object.keys(envs).forEach(key => { + if (key.toUpperCase() === 'PATH') { + const path = Array.isArray(envs[key]) + ? envs[key] + : (typeof envs[key] === 'string' ? envs[key].split(sep) : []) + + const paths = Array.from(new Set([ + ...path, + ...((process.env[key] || '').split(sep)), + ...(isWin ? [] : ['/usr/local/bin']), + ])) + + process.env.PATH = paths.join(sep) + } else { + process.env[key] = envs[key] + } + }) +} + +registerAction('envs.reload', initEnvs) diff --git a/src/main/extension.ts b/src/main/extension.ts new file mode 100644 index 000000000..0ab8eb9a4 --- /dev/null +++ b/src/main/extension.ts @@ -0,0 +1,154 @@ +import * as path from 'path' +import * as fs from 'fs-extra' +import { request } from 'undici' +import { unzip } from 'zlib' +import tar from 'tar-stream' +import { USER_EXTENSION_DIR } from './constant' +import { getAction } from './action' +import { Readable } from 'stream' +import config from './config' + +const RE_EXTENSION_ID = /^[@$a-z0-9-_]+$/ + +const configKey = 'extensions' + +let abortcontroller: AbortController | null = null + +function getExtensionPath (id: string) { + const dir = id.replace(/\//g, '$') + + if (!RE_EXTENSION_ID.test(dir)) { + throw new Error('Invalid extension id') + } + + return path.join(USER_EXTENSION_DIR, dir) +} + +export function dirnameToId (dirname: string) { + return dirname.replace(/\$/g, '/') +} + +async function checkDirectory (path: string) { + if (!(await fs.lstat(path)).isDirectory()) { + throw new Error('Extension path is not a directory') + } +} + +function changeExtensionConfig (id: string, val: { enabled: boolean }) { + const extensions = config.get(configKey, {}) || {} + extensions[id] = { ...extensions[id], ...val } + config.set(configKey, extensions) +} + +export async function list () { + const list = (await fs.readdir(USER_EXTENSION_DIR, { withFileTypes: true })) + .filter(x => (x.isDirectory() || x.isSymbolicLink()) && RE_EXTENSION_ID.test(x.name)) + + const extensionsSettings = config.get(configKey, {}) + + Object.keys(extensionsSettings).forEach(key => { + if (!list.some(x => dirnameToId(x.name) === key)) { + delete extensionsSettings[key] + } + }) + + config.set(configKey, extensionsSettings) + + return list.map(x => { + const id = dirnameToId(x.name) + const ext = extensionsSettings[id] + return { id, enabled: (ext && ext.enabled), isDev: x.isSymbolicLink() } + }) +} + +export async function install (id: string, url: string) { + console.log('[extension] install', id, url) + + if (abortcontroller) { + throw new Error('Another extension is being installed') + } + + const extensionPath = getExtensionPath(id) + if (await fs.pathExists(extensionPath)) { + console.log('[extension] already installed. upgrade:', id) + await checkDirectory(extensionPath) + + if (!(await fs.lstat(extensionPath)).isDirectory()) { + throw new Error('Extension path is not a directory') + } + } + + const dispatcher = await getAction('get-proxy-dispatcher')(url) + try { + abortcontroller = new AbortController() + const res = await request(url, { dispatcher, signal: abortcontroller.signal, maxRedirections: 3 }) + const body = await res.body.arrayBuffer() + + await new Promise((resolve, reject) => { + unzip(body, (err, data) => { + if (err) { + reject(err) + return + } + + const extract = tar.extract() + + extract.on('entry', (header, stream, next) => { + if (header.name.includes('..')) { + console.log('[extension] invalid file name', header.name) + next() + return + } + + const filePath = path.join(extensionPath, header.name.replace(/^package/, '')) + console.log('[extension] write', header.type, filePath) + + if (header.type === 'file') { + fs.ensureFile(filePath).then(() => { + const fileStream = fs.createWriteStream(filePath) + stream.pipe(fileStream) + stream.on('end', next) + }).catch(reject) + } else { + next() + } + }) + + extract.on('finish', () => { + resolve(undefined) + }) + + extract.on('error', reject) + + Readable.from(data).on('error', reject).pipe(extract) + }) + }) + } finally { + abortcontroller = null + } +} + +export async function abortInstallation () { + console.log('[extension] abort installation') + + if (abortcontroller) { + abortcontroller.abort() + abortcontroller = null + } +} + +export async function uninstall (id: string) { + const extensionPath = getExtensionPath(id) + if (await fs.pathExists(extensionPath)) { + await checkDirectory(extensionPath) + await fs.remove(extensionPath) + } +} + +export async function enable (id: string) { + changeExtensionConfig(id, { enabled: true }) +} + +export async function disable (id: string) { + changeExtensionConfig(id, { enabled: false }) +} diff --git a/src/main/global.d.ts b/src/main/global.d.ts index 95e302a5a..ed1a2f951 100644 --- a/src/main/global.d.ts +++ b/src/main/global.d.ts @@ -1,2 +1 @@ -declare module 'opn' declare module 'electron-progressbar' diff --git a/src/main/helper.ts b/src/main/helper.ts index b76178986..50939e453 100644 --- a/src/main/helper.ts +++ b/src/main/helper.ts @@ -1 +1,131 @@ +import { PassThrough, Readable } from 'stream' +import { ReadableStream } from 'stream/web' + export const convertAppPath = (path: string) => path.replace('app.asar', 'app.asar.unpacked') + +export function mergeStreams (streams: NodeJS.ReadableStream[]) { + let pass = new PassThrough() + let waiting = streams.length + for (const stream of streams) { + pass = stream.pipe(pass, { end: false }) + stream.once('end', () => --waiting === 0 && pass.emit('end')) + } + return pass +} + +export function createStreamResponse (isCanceled = () => false) { + function isReadableEnded (stream: any) { + if (stream.readableEnded === true) return true + const rState = stream._readableState + if (!rState || rState.errored) return false + if (typeof rState?.ended !== 'boolean') return null + return rState.ended + } + + /** + * from https://github.com/nodejs/node/blob/ba67fe66eb7777d5055c785be153374843fc647e/lib/internal/streams/readable.js + * @param {ReadableStream} readableStream + * @param {{ + * highWaterMark? : number, + * encoding? : string, + * objectMode? : boolean, + * signal? : AbortSignal, + * }} [options] + * @returns {Readable} + */ + function newStreamReadableFromReadableStream (readableStream: ReadableStream, options: any = {}) { + const reader = readableStream.getReader() + let closed = false + + const readable = new Readable({ + ...options, + read () { + reader.read().then((chunk) => { + if (chunk.done) { + // Value should always be undefined here. + readable.push(null) + } else { + readable.push(chunk.value) + } + }, + (error) => readable.destroy(error)) + }, + + destroy (error, callback) { + function done () { + try { + callback(error) + } catch (error) { + // In a next tick because this is happening within + // a promise context, and if there are any errors + // thrown we don't want those to cause an unhandled + // rejection. Let's just escape the promise and + // handle it separately. + process.nextTick(() => { throw error }) + } + } + + if (!closed) { + reader.cancel().then(done, done) + return + } + done() + }, + }) + + reader.closed.then( + () => { + closed = true + if (!isReadableEnded(readable)) { readable.push(null) } + }, + (error) => { + closed = true + readable.destroy(error) + } + ) + + return readable + } + + let close: () => void = () => undefined + let enqueue: (type: T, payload: any) => void = () => undefined + const stream = new ReadableStream({ + start (controller) { + if (isCanceled()) { + controller.close() + return + } + + const _enqueue = (chunk: any) => { + if (isCanceled()) { + close() + } else { + controller.enqueue(chunk) + } + } + + close = () => { + try { + controller.close() + } catch { + // ignore + } + } + + enqueue = (type: T, payload: any) => { + if (type === 'null') { + _enqueue(null) + } else { + const message = { type, payload } + _enqueue(JSON.stringify(message) + '\n') + } + } + } + }) + + // Readable.fromWeb only available in node 17 + // const response = Readable.fromWeb(stream) + const response = newStreamReadableFromReadableStream(stream) + + return { response, close, enqueue } +} diff --git a/src/main/i18n.ts b/src/main/i18n.ts new file mode 100644 index 000000000..31d7d8177 --- /dev/null +++ b/src/main/i18n.ts @@ -0,0 +1,19 @@ +import { app } from 'electron' +import { Language, MsgPath, translate } from '../share/i18n' +import { getAction, registerAction } from './action' +import config from './config' + +export type LanguageName = 'system' | Language + +let lang: LanguageName = config.get('language', 'system') + +export function $t (path: MsgPath, ...args: string[]) { + return translate(lang === 'system' ? app.getLocale() as any : lang, path, ...args) +} + +export function setLanguage (language?: LanguageName) { + lang = language || 'system' + getAction('refresh-menus')() +} + +registerAction('i18n.change-language', setLanguage) diff --git a/src/main/jsonrpc.ts b/src/main/jsonrpc.ts new file mode 100644 index 000000000..6680e1747 --- /dev/null +++ b/src/main/jsonrpc.ts @@ -0,0 +1,39 @@ +import { app, ipcMain } from 'electron' +import { JSONRPCClient, JSONRPCClientChannel, JSONRPCError, JSONRPCRequest, JSONRPCResult } from 'jsonrpc-bridge' + +type Ctx = { + setting: { + showSettingPanel: (key?: string) => void + }, + doc: { + switchDocByPath: (path: string) => Promise + }, + base: { + triggerDeepLinkOpen: (url: string) => Promise + } +} + +class ElectronMainClientChannel implements JSONRPCClientChannel { + webContent: Electron.WebContents + + constructor (webContent: Electron.WebContents) { + this.webContent = webContent + } + + send (message: JSONRPCRequest): void { + this.webContent.send('jsonrpc', message) + } + + setMessageHandler (callback: (message: Partial & JSONRPCError>) => void): void { + ipcMain.on('jsonrpc', (_event, message) => { + callback(message) + }) + } +} + +export let jsonRPCClient: JSONRPCClient<{ ctx: Ctx }> + +export function initJSONRPCClient (webContent: Electron.WebContents) { + const clientChannel: JSONRPCClientChannel = new ElectronMainClientChannel(webContent) + jsonRPCClient = new JSONRPCClient(clientChannel, { debug: !app.isPackaged }) +} diff --git a/src/main/jwt.ts b/src/main/jwt.ts new file mode 100644 index 000000000..3e1282594 --- /dev/null +++ b/src/main/jwt.ts @@ -0,0 +1,21 @@ +import * as crypto from 'crypto' +import jwt from 'jsonwebtoken' +import config from './config' + +interface Payload { + role: 'admin' | 'guest' +} + +const HEAD = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' + +function getKey () { + return config.get('server.jwt-secret', crypto.randomBytes(16).toString('hex')) +} + +export function getToken (payload: Payload, expiresIn?: string | number) { + return jwt.sign(payload, getKey(), { algorithm: 'HS256', expiresIn }).substring(HEAD.length + 1) +} + +export function verify (token: string) { + return jwt.verify(HEAD + '.' + token, getKey(), { complete: false }) as Payload +} diff --git a/src/main/menus.ts b/src/main/menus.ts index d4ea920a0..75bd62aff 100644 --- a/src/main/menus.ts +++ b/src/main/menus.ts @@ -1,32 +1,20 @@ -import { app, Menu } from 'electron' +import { app, Menu, shell } from 'electron' import { getAction } from './action' -import { FLAG_DISABLE_DEVTOOL, FLAG_DISABLE_SERVER, USER_DIR } from './constant' +import { FLAG_DISABLE_DEVTOOL, FLAG_DISABLE_SERVER, GITHUB_URL, USER_DIR } from './constant' import { getAccelerator } from './shortcut' -import opn from 'opn' import { checkForUpdates } from './updater' +import { $t } from './i18n' -export const selectionMenu = Menu.buildFromTemplate([ - { role: 'copy' }, -]) - -export const inputMenu = Menu.buildFromTemplate([ - { role: 'copy' }, - { role: 'paste' }, - { role: 'cut' }, - { type: 'separator' }, - { role: 'undo' }, - { role: 'redo' }, - { type: 'separator' }, - { role: 'selectAll' }, -]) - -export const mainMenus = process.platform === 'darwin' ? Menu.buildFromTemplate([ +export const getMainMenus = () => process.platform === 'darwin' ? Menu.buildFromTemplate([ { label: 'Application', submenu: [ - { type: 'normal', label: '偏好设置', click: () => getAction('show-main-window-setting')() }, - { type: 'normal', label: '关闭窗口', accelerator: 'Command+W', click: () => getAction('hide-main-window')() }, - { type: 'normal', label: '退出', accelerator: 'Command+Q', click: () => getAction('quit')() } + { type: 'normal', label: $t('app.preferences'), click: () => getAction('show-main-window-setting')() }, + { type: 'normal', label: $t('app.toggle-fullscreen'), accelerator: 'Ctrl+command+F', click: () => getAction('toggle-fullscreen')() }, + { type: 'separator' }, + { role: 'services', submenu: [] }, + { type: 'separator' }, + { type: 'normal', label: $t('app.quit'), accelerator: 'Command+Q', click: () => getAction('quit')() }, ] }, { @@ -40,38 +28,43 @@ export const mainMenus = process.platform === 'darwin' ? Menu.buildFromTemplate( { role: 'paste', accelerator: 'CmdOrCtrl+V', }, { role: 'selectAll', accelerator: 'CmdOrCtrl+A' } ] - } + }, + // support multiple window + { + role: 'window', + submenu: [{ role: 'minimize' }, { role: 'close' }] + }, ]) : null export const getTrayMenus = () => Menu.buildFromTemplate([ { type: 'normal', - label: '打开主界面', + label: $t('app.tray.open-main-window'), accelerator: getAccelerator('show-main-window'), click: () => getAction('show-main-window')() }, { type: 'normal', - label: '浏览器中打开', + label: $t('app.tray.open-in-browser'), accelerator: getAccelerator('open-in-browser'), visible: !FLAG_DISABLE_SERVER, click: () => getAction('open-in-browser')() }, { type: 'normal', - label: '打开主目录', + label: $t('app.tray.open-main-dir'), click: () => { - opn(USER_DIR) + shell.openPath(USER_DIR) } }, { type: 'normal', - label: '偏好设置', + label: $t('app.tray.preferences'), click: () => getAction('show-main-window-setting')() }, { type: 'checkbox', - label: '开机启动', + label: $t('app.tray.start-at-login'), checked: app.getLoginItemSettings().openAtLogin, click: x => { app.setLoginItemSettings({ openAtLogin: x.checked }) @@ -81,13 +74,13 @@ export const getTrayMenus = () => Menu.buildFromTemplate([ { type: 'separator' }, { type: 'submenu', - label: '开发', + label: $t('app.tray.dev.dev'), visible: !FLAG_DISABLE_DEVTOOL, submenu: [ { type: 'radio', checked: getAction('get-url-mode')() === 'scheme', - label: '正式端口(Scheme)', + label: $t('app.tray.dev.port-prod', 'Scheme'), click: () => { getAction('set-url-mode')('scheme') getAction('reload-main-window')() @@ -96,7 +89,7 @@ export const getTrayMenus = () => Menu.buildFromTemplate([ { type: 'radio', checked: getAction('get-url-mode')() === 'prod', - label: `正式端口(${getAction('get-backend-port')()})`, + label: $t('app.tray.dev.port-prod', getAction('get-backend-port')()), click: () => { getAction('set-url-mode')('prod') getAction('reload-main-window')() @@ -105,7 +98,7 @@ export const getTrayMenus = () => Menu.buildFromTemplate([ { type: 'radio', checked: getAction('get-url-mode')() === 'dev', - label: `开发端口(${getAction('get-dev-frontend-port')()})`, + label: $t('app.tray.dev.port-dev', getAction('get-dev-frontend-port')()), click: () => { getAction('set-url-mode')('dev') getAction('reload-main-window')() @@ -114,14 +107,14 @@ export const getTrayMenus = () => Menu.buildFromTemplate([ { type: 'separator' }, { type: 'normal', - label: '重载页面', + label: $t('app.tray.dev.reload'), click: () => { getAction('reload-main-window')() } }, { type: 'normal', - label: '主窗口开发工具', + label: $t('app.tray.dev.dev-tool'), click: () => { const win = getAction('get-main-widow')() win && win.webContents.openDevTools() @@ -130,7 +123,7 @@ export const getTrayMenus = () => Menu.buildFromTemplate([ { type: 'separator' }, { type: 'normal', - label: '强制重新启动', + label: $t('app.tray.dev.restart'), click: () => { app.relaunch() app.exit(1) @@ -138,7 +131,7 @@ export const getTrayMenus = () => Menu.buildFromTemplate([ }, { type: 'normal', - label: '强制退出', + label: $t('app.tray.dev.force-quit'), click: () => { app.exit(1) } @@ -149,12 +142,12 @@ export const getTrayMenus = () => Menu.buildFromTemplate([ type: 'normal', label: 'GitHub', click: () => { - opn('https://github.com/purocean/yn') + shell.openExternal(GITHUB_URL) } }, { type: 'normal', - label: `版本 ${app.getVersion()}`, + label: `${$t('app.tray.version', app.getVersion())}`, click: () => { checkForUpdates() } @@ -162,7 +155,7 @@ export const getTrayMenus = () => Menu.buildFromTemplate([ { type: 'separator' }, { type: 'normal', - label: '退出', + label: $t('app.tray.quit'), click: () => { setTimeout(() => { getAction('quit')() diff --git a/src/main/protocol.ts b/src/main/protocol.ts index e1bab93c2..2aa292873 100644 --- a/src/main/protocol.ts +++ b/src/main/protocol.ts @@ -48,6 +48,8 @@ export async function transformProtocolRequest (request: ProtocolRequest) { if (body) { req.headers['content-length'] = body.length.toString() req._read = Readable.from(body)._read + } else { + req._read = () => req.push(null) } const out = new Transform({ @@ -57,9 +59,22 @@ export async function transformProtocolRequest (request: ProtocolRequest) { }, }) - const res = new ServerResponse(req) - res.write = out.write.bind(out) - res.end = out.end.bind(out) + const res = new Proxy(new ServerResponse(req), { + get (target, prop: keyof typeof out) { + const val = out[prop] + if (val && typeof val === 'function') { + return val.bind(out) + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return target[prop] + }, + }) + + res.on('close', () => { + res.emit('finish') + }) return { req, res, out } } diff --git a/src/main/proxy-dispatcher.ts b/src/main/proxy-dispatcher.ts new file mode 100644 index 000000000..608afa37b --- /dev/null +++ b/src/main/proxy-dispatcher.ts @@ -0,0 +1,139 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import { session } from 'electron' +import { Dispatcher, ProxyAgent, Agent, buildConnector } from 'undici' + +// https://github.com/Kaciras/fetch-socks/blob/master/index.ts +import { SocksClient, SocksProxy } from 'socks' +import Connector = buildConnector.connector; +import TLSOptions = buildConnector.BuildOptions; + +export type SocksProxies = SocksProxy | SocksProxy[]; + +/** + * Since socks does not guess HTTP ports, we need to do that. + * + * @param protocol Upper layer protocol, "http:" or "https:" + * @param port A string containing the port number of the URL, maybe empty. + */ +function resolvePort (protocol: string, port: string) { + return port ? Number.parseInt(port) : protocol === 'http:' ? 80 : 443 +} + +/** + * Create an Undici connector which establish the connection through socks proxies. + * + * If the proxies is an empty array, it will connect directly. + * + * @param proxies The proxy server to use or the list of proxy servers to chain. + * @param tlsOpts TLS upgrade options. + */ +export function socksConnector (proxies: SocksProxies, tlsOpts: TLSOptions = {}): Connector { + const chain = Array.isArray(proxies) ? proxies : [proxies] + const { timeout = 1e4 } = tlsOpts + const undiciConnect = buildConnector(tlsOpts) + + return async (options, callback) => { + let { protocol, hostname, port, httpSocket } = options + + for (let i = 0; i < chain.length; i++) { + const next = chain[i + 1] + + const destination = i === chain.length - 1 ? { + host: hostname, + port: resolvePort(protocol, port), + } : { + port: next.port, + host: next.host ?? next.ipaddress!, + } + + const socksOpts = { + command: 'connect' as const, + proxy: chain[i], + timeout, + destination, + existing_socket: httpSocket, + } + + try { + const r = await SocksClient.createConnection(socksOpts) + httpSocket = r.socket + } catch (error: any) { + return callback(error, null) + } + } + + // httpSocket may not exist when the chain is empty. + if (httpSocket && protocol !== 'https:') { + return callback(null, httpSocket.setNoDelay()) + } + + /* + * There are 2 cases here: + * If httpSocket doesn't exist, let Undici make a connection. + * If httpSocket exists & protocol is HTTPS, do TLS upgrade. + */ + return undiciConnect({ ...options, httpSocket }, callback) + } +} + +export interface SocksDispatcherOptions extends Agent.Options { + + /** + * TLS upgrade options, see: + * https://undici.nodejs.org/#/docs/api/Client?id=parameter-connectoptions + * + * The connect function is not supported. + * If you want to create a custom connector, you can use `socksConnector`. + */ + connect?: TLSOptions; +} + +/** + * Create a Undici Agent with socks connector. + * + * If the proxies is an empty array, it will connect directly. + * + * @param proxies The proxy server to use or the list of proxy servers to chain. + * @param options Additional options passed to the Agent constructor. + */ +export function socksDispatcher (proxies: SocksProxies, options: SocksDispatcherOptions = {}) { + const { connect, ...rest } = options + return new Agent({ ...rest, connect: socksConnector(proxies, connect) }) +} + +export function newProxyDispatcher (proxyUrl: string): Dispatcher { + if (proxyUrl.toLowerCase().startsWith('socks://') || proxyUrl.toLowerCase().startsWith('socks5://')) { + const url = new URL(proxyUrl) + return socksDispatcher({ + type: 5, + host: url.hostname, + port: +url.port + }) + } + + return new ProxyAgent(proxyUrl) +} + +export async function getProxyDispatcher (url: string): Promise { + const proxy = await session.defaultSession.resolveProxy(url) + if (!proxy) { + return undefined + } + + const proxies = String(proxy).trim().split(/\s*;\s*/g).filter(Boolean) + const first = proxies[0] + const parts = first.split(/\s+/) + const type = parts[0] + + let proxyUrl = '' + if (type === 'SOCKS' || type === 'SOCKS5') { + // use a SOCKS proxy + proxyUrl = 'socks://' + parts[1] + } else if (type === 'PROXY' || type === 'HTTPS') { + proxyUrl = (type === 'HTTPS' ? 'https' : 'http') + '://' + parts[1] + } else { + return undefined + } + + return newProxyDispatcher(proxyUrl) +} diff --git a/src/main/proxy.ts b/src/main/proxy.ts new file mode 100644 index 000000000..bfc9e3ee9 --- /dev/null +++ b/src/main/proxy.ts @@ -0,0 +1,43 @@ +import { app, session } from 'electron' +import { registerAction } from './action' +import config from './config' + +const keyEnabled = 'proxy.enabled' +const keyServer = 'proxy.server' +const keyPacUrl = 'proxy.pac-url' +const keyBypassList = 'proxy.bypass-list' + +export function initProxy () { + const proxyEnabled = config.get(keyEnabled, false) + console.log('use proxy:', proxyEnabled) + + const proxyServer = config.get(keyServer, '') + if (proxyEnabled && proxyServer && proxyServer.includes(':')) { + const proxyPacUrl = config.get(keyPacUrl, '') + const proxyBypassList = config.get(keyBypassList, '') + + console.log('proxy server:', proxyServer) + console.log('proxy pac-url:', proxyPacUrl) + console.log('proxy bypass-list:', proxyBypassList) + + app.commandLine.appendSwitch('proxy-server', proxyServer) + proxyPacUrl && app.commandLine.appendSwitch('proxy-pac-url', proxyPacUrl) + proxyBypassList && app.commandLine.appendSwitch('proxy-bypass-list', proxyBypassList) + } +} + +function reloadProxy (config: any) { + console.log('reload proxy', config[keyEnabled]) + + if (config[keyEnabled]) { + session.defaultSession.setProxy({ + proxyRules: config[keyServer], + proxyBypassRules: config[keyBypassList], + pacScript: config[keyPacUrl] + }) + } else { + session.defaultSession.setProxy({ mode: 'system' }) + } +} + +registerAction('proxy.reload', reloadProxy) diff --git a/src/main/resources/github.css b/src/main/resources/github.css new file mode 100644 index 000000000..404806607 --- /dev/null +++ b/src/main/resources/github.css @@ -0,0 +1,490 @@ +/* +This file is automatically generated, please do not modify +*/ + +.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + line-height: 1.5; + color: #24292e; + font-family: -apple-system,MacEmoji,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; +} + +.markdown-body details { + display: block; +} + +.markdown-body summary { + display: list-item; +} + +.markdown-body a { + background-color: initial; +} + +.markdown-body a:active, +.markdown-body a:hover { + outline-width: 0; +} + +.markdown-body strong { + font-weight: inherit; + font-weight: bolder; +} + +.markdown-body h1 { + font-size: 2em; + margin: .67em 0; +} + +.markdown-body img { + border-style: none; +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre { + font-family: monospace,monospace; + font-size: 1em; +} + +.markdown-body hr { + box-sizing: initial; + height: 0; + overflow: visible; +} + +.markdown-body input { + font: inherit; + margin: 0; +} + +.markdown-body input { + overflow: visible; +} + +.markdown-body [type=checkbox] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body * { + box-sizing: border-box; +} + +.markdown-body input { + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +.markdown-body a { + color: #0366d6; + text-decoration: none; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body strong { + font-weight: 600; +} + +.markdown-body hr { + height: 0; + margin: 15px 0; + overflow: hidden; + background: transparent; + border: 0; + border-bottom: 1px solid #dfe2e5; +} + +.markdown-body hr:after, +.markdown-body hr:before { + display: table; + content: ""; +} + +.markdown-body hr:after { + clear: both; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body details summary { + cursor: pointer; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: 11px SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace; + line-height: 10px; + color: #444d56; + vertical-align: middle; + background-color: #fafbfc; + border: 1px solid #d1d5da; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #d1d5da; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body h1 { + font-size: 32px; +} + +.markdown-body h1, +.markdown-body h2 { + font-weight: 600; +} + +.markdown-body h2 { + font-size: 24px; +} + +.markdown-body h3 { + font-size: 20px; +} + +.markdown-body h3, +.markdown-body h4 { + font-weight: 600; +} + +.markdown-body h4 { + font-size: 16px; +} + +.markdown-body h5 { + font-size: 14px; +} + +.markdown-body h5, +.markdown-body h6 { + font-weight: 600; +} + +.markdown-body h6 { + font-size: 12px; +} + +.markdown-body p { + margin-top: 0; + margin-bottom: 10px; +} + +.markdown-body blockquote { + margin: 0; +} + +.markdown-body ol, +.markdown-body ul { + padding-left: 0; + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ol ol ol, +.markdown-body ol ul ol, +.markdown-body ul ol ol, +.markdown-body ul ul ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body code, +.markdown-body pre { + font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace; + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body input::-webkit-inner-spin-button, +.markdown-body input::-webkit-outer-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; +} + +.markdown-body hr { + border-bottom-color: #eee; +} + +.markdown-body:after, +.markdown-body:before { + display: table; + content: ""; +} + +.markdown-body:after { + clear: both; +} + +.markdown-body>:first-child { + margin-top: 0!important; +} + +.markdown-body>:last-child { + margin-bottom: 0!important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body blockquote, +.markdown-body details, +.markdown-body dl, +.markdown-body ol, +.markdown-body p, +.markdown-body pre, +.markdown-body table, +.markdown-body ul { + margin-top: 0; + margin-bottom: 16px; +} + +.markdown-body hr { + height: .25em; + padding: 0; + margin: 24px 0; + background-color: #e1e4e8; + border: 0; +} + +.markdown-body blockquote { + padding: 0 1em; + color: #6a737d; + border-left: .25em solid #dfe2e5; +} + +.markdown-body blockquote>:first-child { + margin-top: 0; +} + +.markdown-body blockquote>:last-child { + margin-bottom: 0; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: 600; + line-height: 1.25; +} + +.markdown-body h1 { + font-size: 2em; +} + +.markdown-body h1, +.markdown-body h2 { + padding-bottom: .3em; + border-bottom: 1px solid #eaecef; +} + +.markdown-body h2 { + font-size: 1.5em; +} + +.markdown-body h3 { + font-size: 1.25em; +} + +.markdown-body h4 { + font-size: 1em; +} + +.markdown-body h5 { + font-size: .875em; +} + +.markdown-body h6 { + font-size: .85em; + color: #6a737d; +} + +.markdown-body ol, +.markdown-body ul { + padding-left: 2em; +} + +.markdown-body ol ol, +.markdown-body ol ul, +.markdown-body ul ol, +.markdown-body ul ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li { + word-wrap: break-all; +} + +.markdown-body li>p { + margin-top: 16px; +} + +.markdown-body li+li { + margin-top: .25em; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: 16px; + font-size: 1em; + font-style: italic; + font-weight: 600; +} + +.markdown-body dl dd { + padding: 0 16px; + margin-bottom: 16px; +} + +.markdown-body table { + display: block; + width: 100%; + overflow: auto; +} + +.markdown-body table th { + font-weight: 600; +} + +.markdown-body table td, +.markdown-body table th { + padding: 6px 13px; + border: 1px solid #dfe2e5; +} + +.markdown-body table tr { + background-color: #fff; + border-top: 1px solid #c6cbd1; +} + +.markdown-body table tr:nth-child(2n) { + background-color: #f6f8fa; +} + +.markdown-body img { + max-width: 100%; + box-sizing: initial; +} + +.markdown-body img[align=right] { + padding-left: 20px; +} + +.markdown-body img[align=left] { + padding-right: 20px; +} + +.markdown-body code { + padding: .2em .4em; + margin: 0; + font-size: 85%; + background-color: rgba(27,31,35,.05); + border-radius: 3px; +} + +.markdown-body pre { + word-wrap: normal; +} + +.markdown-body pre>code { + padding: 0; + margin: 0; + font-size: 100%; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: 16px; +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + background-color: #f6f8fa; + border-radius: 3px; +} + +.markdown-body pre code { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: initial; + border: 0; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item+.task-list-item { + margin-top: 3px; +} + +.markdown-body .task-list-item input { + margin: 0 .2em .25em -1.6em; + vertical-align: middle; +} diff --git a/src/main/resources/pandoc-filter.lua b/src/main/resources/pandoc-filter.lua new file mode 100644 index 000000000..d75ccc88d --- /dev/null +++ b/src/main/resources/pandoc-filter.lua @@ -0,0 +1,6 @@ +function Image (img) + if string.sub(img.src, 1, 1) == '/' then + img.src = '.' .. img.src + end + return img +end diff --git a/src/main/resources/tpl.docx b/src/main/resources/pandoc-reference.docx similarity index 100% rename from src/main/resources/tpl.docx rename to src/main/resources/pandoc-reference.docx diff --git a/src/main/server/convert.ts b/src/main/server/convert.ts index 88248a3c6..c38b8ffe8 100644 --- a/src/main/server/convert.ts +++ b/src/main/server/convert.ts @@ -1,39 +1,67 @@ import * as os from 'os' -import * as fs from 'fs' +import * as fs from 'fs-extra' import * as path from 'path' import { spawn } from 'child_process' -import { BIN_DIR, RESOURCES_DIR } from '../constant' +import { BIN_DIR, PANDOC_REFERENCE_FILE, RESOURCES_DIR, USER_DIR } from '../constant' -const binPath = path.join(BIN_DIR, os.platform() + '-pandoc-2.7.3' + (os.platform() === 'win32' ? '.exe' : '')) -const docxTplPath = path.join(RESOURCES_DIR, './tpl.docx') +const binPath = path.join(BIN_DIR, os.platform() + '-pandoc-2.14.2' + (os.platform() === 'win32' ? '.exe' : '')) +const docxTplPath = path.join(USER_DIR, PANDOC_REFERENCE_FILE) +const filterPath = path.join(RESOURCES_DIR, './pandoc-filter.lua') -const convert = async (html: string, type: string) => { +const convert = async (source: string, fromType: string, toType: string, resourcePath: string) => { try { - const path = os.tmpdir() + `/yn_convert_${new Date().getTime()}.${type}` + const path = os.tmpdir() + `/yn_convert_${new Date().getTime()}.${toType}` return new Promise((resolve, reject) => { - const args = ['-f', 'html', '-o', path, '--reference-doc', docxTplPath] + const args = [ + '--self-contained', + '--lua-filter', filterPath, + '--resource-path', resourcePath, + '-f', fromType, + '-o', path, + '--reference-doc', docxTplPath + ] console.log(binPath, args) - const process = spawn(binPath, args) + const ps = spawn(binPath, args, { + env: { + ...process.env, + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US.UTF-8' + } + }) + + let errorMsg = '' + ps.stderr.on('data', (val) => { + errorMsg += val + }) + + ps.stderr.on('data', (val) => { + errorMsg += val + }) + + ps.on('close', async (code) => { + if (code) { + reject(new Error(errorMsg)) + return + } - process.on('close', () => { try { - const data = fs.readFileSync(path) - fs.unlinkSync(path) + const data = await fs.readFile(path) + await fs.unlink(path) resolve(data) } catch (error) { reject(error) } }) - process.on('error', error => { + ps.on('error', error => { reject(error) }) - process.stdin.write(html) - process.stdin.end() + ps.stdin.write(source) + ps.stdin.end() }) - } catch (e) { + } catch (e: any) { return e.message } } diff --git a/src/main/server/file.ts b/src/main/server/file.ts index 4116c3346..0a292aca3 100644 --- a/src/main/server/file.ts +++ b/src/main/server/file.ts @@ -1,16 +1,49 @@ +import { app, shell } from 'electron' +import ch from 'child_process' +import orderBy from 'lodash/orderBy' import * as fs from 'fs-extra' import * as path from 'path' import * as crypto from 'crypto' -import * as NaturalOrderby from 'natural-orderby' import * as yargs from 'yargs' -import opn from 'opn' -import * as wsl from '../wsl' -import mark, { MarkedFile } from './mark' +import AdmZip from 'adm-zip' +import dayjs from 'dayjs' +import { DEFAULT_EXCLUDE_REGEX, DOC_HISTORY_MAX_CONTENT_LENGTH, ENCRYPTED_MARKDOWN_FILE_EXT, isEncryptedMarkdownFile, isMarkdownFile, MARKDOWN_FILE_EXT, ROOT_REPO_NAME_PREFIX } from '../../share/misc' +import { createStreamResponse } from '../helper' +import { HISTORY_DIR } from '../constant' +import config from '../config' import repository from './repository' +import type { WatchOpts } from './watch-worker' + +// make sure watch-worker.ts is compiled +import './watch-worker' const readonly = !!(yargs.argv.readonly) -const isWsl = wsl.isWsl -const ignorePath = /node_modules/ + +let _watchProcess: ch.ChildProcess | null = null +let watchGid = 0 + +function getWatchProcess () { + if (!_watchProcess) { + console.log('start watch-worker process') + _watchProcess = ch.fork( + path.join(__dirname, '/watch-worker.js'), + { + env: { ELECTRON_RUN_AS_NODE: '1' }, + // execArgv: ['--inspect'] + } + ) + + _watchProcess.on('exit', () => { + _watchProcess = null + }) + + _watchProcess.on('error', () => { + _watchProcess = null + }) + } + + return _watchProcess +} interface XFile { name: string; @@ -22,193 +55,606 @@ interface XFile { interface TreeItem extends XFile { mtime?: number; birthtime?: number; - marked?: boolean; children?: XFile[]; + level: number; +} + +type Order = { by: 'mtime' | 'birthtime' | 'name' | 'serial', order: 'asc' | 'desc' } + +function getExcludeRegex () { + try { + const regex = config.get('tree.exclude', DEFAULT_EXCLUDE_REGEX) || '^$' + return new RegExp(regex) + } catch (error) { + return new RegExp(DEFAULT_EXCLUDE_REGEX) + } } -const withRepo = (repo = 'main', callback: (repoPath: string, ...targetPath: string[]) => any, ...target: string[]) => { - const repoPath = repository.getPath(repo) +function withRepo (repo = 'main', callback: (repoPath: string, ...targetPath: string[]) => Promise, ...target: string[]): Promise { + const isRootRepo = repo.startsWith(ROOT_REPO_NAME_PREFIX) + + const repoPath = isRootRepo + ? repo.substring(ROOT_REPO_NAME_PREFIX.length) + : repository.getPath(repo) + if (!repoPath) { - throw new Error(`仓库 ${repo} 不存在`) + throw new Error(`repo ${repo} not exists.`) } return callback(repoPath, ...target.map(x => { - const targetPath = path.join(repoPath, x) + // fix path + if (!x.startsWith('/')) { + x = '/' + x + } + + const targetPath = isRootRepo + ? x.replace(/^\//, repoPath) // replace first / to repoPath for case of `\\127.0.0.1/test/a.md` + : path.join(repoPath, x) if (!targetPath.startsWith(repoPath)) { - throw new Error('路径错误') + throw new Error('Path error.') } return targetPath })) } -const read = (repo: string, p: string) => withRepo(repo, (_, targetPath) => fs.readFileSync(targetPath), p) +function getHistoryFilePath (filePath: string) { + const historyFileName = path.basename(filePath) + '.' + crypto.createHash('md5').update(filePath).digest('hex') + '.zip' + return path.join(HISTORY_DIR, historyFileName) +} + +function readHistoryZip (zipFilePath: string) { + const compressedZip = new AdmZip(zipFilePath) + const entry = compressedZip.getEntry('versions.zip') + + if (!entry) { + throw new Error('history zip file error') + } + + return new AdmZip(entry.getData()) +} + +function writeHistoryZip (zip: AdmZip, zipFilePath: string) { + // store only + zip.getEntries().forEach(entry => { + entry.header.method = 0 + }) + + // compress entire file + const compressedZip = new AdmZip() + compressedZip.addFile('versions.zip', zip.toBuffer()) + compressedZip.writeZip(zipFilePath) +} + +async function writeHistory (filePath: string, content: any) { + let limit = Math.min(10000, config.get('doc-history.number-limit', 500)) + if (limit < 1) { + return + } + + const historyFilePath = getHistoryFilePath(filePath) + + let zip: AdmZip + let tooLarge = false + + if ((await fs.pathExists(historyFilePath))) { + const stats = await fs.stat(historyFilePath) + if (stats.size > 1024 * 1024 * 5) { // 5M + console.log('history file too large, limit max versions.', historyFilePath, stats.size) + tooLarge = true + } + + zip = readHistoryZip(historyFilePath) + } else { + zip = new AdmZip() + } + + const ext = isEncryptedMarkdownFile(filePath) ? ENCRYPTED_MARKDOWN_FILE_EXT : MARKDOWN_FILE_EXT + + zip.addFile(dayjs().format('YYYY-MM-DD HH-mm-ss') + ext, content) + + const entries = zip.getEntries() + if (tooLarge) { + limit = Math.min(limit, Math.floor(entries.length / 3 * 2)) + } + + orderBy(entries, x => x.entryName, 'desc').slice(limit).forEach(entry => { + if (!entry.comment) { + zip.deleteFile(entry) + } + }) + + writeHistoryZip(zip, historyFilePath) +} + +async function moveHistory (oldPath: string, newPath: string) { + if (!isMarkdownFile(oldPath)) { + return + } + + const oldHistoryPath = getHistoryFilePath(oldPath) + const newHistoryPath = getHistoryFilePath(newPath) + + if (!(await fs.pathExists(oldHistoryPath))) { + return + } + + if (await fs.pathExists(newHistoryPath)) { + await fs.unlink(newHistoryPath) + } + + await fs.move(oldHistoryPath, newHistoryPath) +} + +export function read (repo: string, p: string): Promise { + return withRepo(repo, (_, targetPath) => fs.readFile(targetPath), p) +} + +export function createReadStream (repo: string, p: string, options?: Parameters[1]): Promise> { + return withRepo(repo, async (_, targetPath) => fs.createReadStream(targetPath, options), p) +} + +export function stat (repo: string, p: string) { + return withRepo(repo, async (_, targetPath) => { + const stat = await fs.stat(targetPath) -const write = (repo: string, p: string, content: any) => { - if (readonly) throw new Error('只读模式') + return { + birthtime: stat.birthtimeMs, + mtime: stat.mtimeMs, + size: stat.size, + } + }, p) +} + +export function checkWriteable (repo: string, p: string) { + return withRepo(repo, async (_, targetPath) => { + if (readonly) { + return false + } + + try { + await fs.access(targetPath, fs.constants.W_OK) + return true + } catch (error) { + return false + } + }, p) +} - return withRepo(repo, (_, filePath) => { - fs.ensureFileSync(filePath) - fs.writeFileSync(filePath, content) +export function write (repo: string, p: string, content: any): Promise { + if (readonly) throw new Error('Readonly') + + return withRepo(repo, async (_, filePath) => { + // create dir. + if (filePath.endsWith(path.sep)) { + await fs.ensureDir(filePath) + return '' + } + + await fs.ensureFile(filePath) + await fs.writeFile(filePath, content) + + if (isMarkdownFile(filePath) && typeof content === 'string') { + if (content.length > DOC_HISTORY_MAX_CONTENT_LENGTH) { + console.log('skip write history for large file', filePath, content.length) + } else { + setTimeout(() => writeHistory(filePath, content), 0) + } + } return crypto.createHash('md5').update(content).digest('hex') }, p) } -const rm = (repo: string, p: string) => { - if (readonly) throw new Error('只读模式') +export async function rm (repo: string, p: string, trash = true) { + if (readonly) throw new Error('Readonly') - withRepo(repo, (repoPath, targetPath) => { + await withRepo(repo, async (repoPath, targetPath) => { if (targetPath !== repoPath) { - const newPath = path.join(repository.getTrashPath(repo), p.replace(/\.\./g, '')) + '.' + (new Date()).getTime() - fs.moveSync(targetPath, newPath) + if (trash) { + await shell.trashItem(targetPath) + } else { + await fs.remove(targetPath) + } } }, p) } -const mv = (repo: string, oldPath: string, newPath: string) => { - if (readonly) throw new Error('只读模式') +export async function mv (repo: string, oldPath: string, newPath: string) { + if (readonly) throw new Error('Readonly') - withRepo(repo, (_, oldP, newP) => { + await withRepo(repo, async (_, oldP, newP) => { if (oldPath !== newP) { - fs.moveSync(oldP, newP) + await fs.move(oldP, newP) + setTimeout(async () => { + await moveHistory(oldP, newP) + }, 0) } }, oldPath, newPath) } -const exists = (repo: string, p: string) => withRepo(repo, (_, targetPath) => fs.existsSync(targetPath), p) +export async function cp (repo: string, oldPath: string, newPath: string) { + if (readonly) throw new Error('Readonly') -const hash = (repo: string, p: string) => { - const content = read(repo, p) - return crypto.createHash('md5').update(content).digest('hex') + await withRepo(repo, async (_, oldP, newP) => { + await fs.copy(oldP, newP) + }, oldPath, newPath) +} + +export function exists (repo: string, p: string) { + return withRepo(repo, async (_, targetPath) => fs.existsSync(targetPath), p) } -const checkHash = (repo: string, p: string, oldHash: string) => { - return oldHash === hash(repo, p) +export async function hash (repo: string, p: string) { + const content = await read(repo, p) + return crypto.createHash('md5').update(content).digest('hex') } -const upload = (repo: string, buffer: Buffer, path: string) => { - if (readonly) throw new Error('只读模式') - write(repo, path, buffer) +export async function checkHash (repo: string, p: string, oldHash: string) { + return oldHash === await hash(repo, p) } -const travels = (location: string, repo: string, basePath: string, markedFiles: MarkedFile[] | null = null): any => { - if (!fs.statSync(location).isDirectory()) { - return [] +export async function upload (repo: string, buffer: Buffer, filePath: string, ifExists: 'rename' | 'overwrite' | 'skip' | 'error' = 'error'): Promise<{ path: string, hash: string }> { + if (readonly) throw new Error('Readonly') + + let newFilePath = filePath + + if (await exists(repo, filePath)) { + if (ifExists === 'overwrite') { + // do nothing + } else if (ifExists === 'skip') { + return { path: filePath, hash: await hash(repo, filePath) } + } else if (ifExists === 'rename') { + const dir = path.dirname(filePath) + const ext = path.extname(filePath) + const base = path.basename(filePath, ext) + + let i = 1 + while (await exists(repo, newFilePath)) { + i++ + + if (i > 10000) { + throw new Error('Too many files with the same name') + } + + const seq = i > 100 ? Math.floor(Math.random() * 1000000) : i + newFilePath = path.join(dir, base + `-${seq}` + ext).replace(/\\/g, '/') + } + } else { + throw new Error('File exists') + } } - const list = fs.readdirSync(location).filter(x => !x.startsWith('.') && !ignorePath.test(x)) + return { path: newFilePath, hash: await write(repo, newFilePath, buffer) } +} - const sortOptions = [(v: string) => v && v.charCodeAt(0) > 255 ? 1 : 0, (v: string) => v] +function getRelativePath (from: string, to: string) { + return '/' + path.relative(from, to).replace(/\\/g, '/') +} - const dirs = NaturalOrderby.orderBy(list.filter(x => fs.statSync(path.join(location, x)).isDirectory()), sortOptions) - const files = NaturalOrderby.orderBy(list.filter(x => !fs.statSync(path.join(location, x)).isDirectory()), sortOptions) +async function travels ( + location: string, + repo: string, + basePath: string, + data: TreeItem, + excludeRegex: RegExp, + includeRegex: RegExp | null, + order: Order, + noEmptyDir: boolean +): Promise { + const list = await fs.readdir(location) + + const dirs: TreeItem[] = [] + const files: TreeItem[] = [] + + await Promise.all(list.map(async name => { + const p = path.join(location, name) + const stat = await fs.stat(p).catch(e => { + console.error('travels', p, e) + return null + }) - markedFiles = markedFiles || mark.list() + if (!stat) { + return + } - return dirs.map(x => { - const p = path.join(location, x) - const xpath = path.relative(basePath, p).replace(/\\/g, '/') + if (stat.isFile()) { + if (excludeRegex.test(name)) { + return + } - return { - name: x, - path: xpath, - type: 'dir', - repo: repo, - children: travels(p, repo, basePath, markedFiles) - } as TreeItem - }).concat(files.map(x => { - const p = path.join(location, x) - const xpath = path.relative(basePath, p).replace(/\\/g, '/') - const stat = fs.statSync(p) + if (includeRegex && !includeRegex.test(name)) { + return + } - return { - name: x, - path: xpath, - type: 'file', - repo: repo, - marked: (markedFiles || []).findIndex(f => f.path === xpath && f.repo === repo) > -1, - birthtime: stat.birthtimeMs, - mtime: stat.mtimeMs - } as TreeItem + files.push({ + name, + path: getRelativePath(basePath, p), + type: 'file', + repo, + birthtime: stat.birthtimeMs, + mtime: stat.mtimeMs, + level: data.level + 1, + }) + } else if (stat.isDirectory()) { + const dirName = name + '/' + if (excludeRegex.test(dirName)) { + return + } + + if (includeRegex && !includeRegex.test(dirName)) { + return + } + + const dir: TreeItem = { + name, + path: getRelativePath(basePath, p), + type: 'dir', + repo, + children: [], + birthtime: stat.birthtimeMs, + mtime: stat.mtimeMs, + level: data.level + 1, + } + + await travels(p, repo, basePath, dir, excludeRegex, includeRegex, order, noEmptyDir) + + if (!(noEmptyDir && dir.children!.length === 0)) { + dirs.push(dir) + } + } })) + + const sort = (items: TreeItem[], order: Order) => orderBy(items, x => { + if (order.by === 'serial') { + const number = parseFloat(x.name) + if (!isNaN(number) && isFinite(number)) { + return number.toFixed(12).padStart(20) + x.name + } else { + return x.name + } + } + + return x[order.by] || x.name + }, order.order) + + data.children = sort(dirs, order) + .concat(sort(files, order)) } -const tree = (repo: string) => { - return withRepo(repo, repoPath => [{ +export async function tree (repo: string, order: Order, include?: string | RegExp, noEmptyDir?: boolean): Promise { + if (repo.startsWith(ROOT_REPO_NAME_PREFIX)) { + return [] + } + + const data: TreeItem[] = [{ name: '/', type: 'dir', path: '/', - repo: repo, - children: travels(repoPath, repo, repoPath) - }]) -} + repo, + children: [], + level: 1, + }] -const open = (repo: string, p: string) => { - withRepo(repo, (_, targetPath) => { - if (isWsl) { - targetPath = wsl.toWinPath(targetPath) - } + const includeRegex = include ? new RegExp(include) : null - opn(targetPath) - }, p) + await withRepo(repo, async repoPath => travels(repoPath, repo, repoPath, data[0], getExcludeRegex(), includeRegex, order, !!noEmptyDir)) + + return data } -const search = (repo: string, str: string) => { +export async function search (repo: string, str: string) { str = str.trim() if (!str) { return [] } - const files = [] as any + const files: TreeItem[] = [] + const excludeRegex = getExcludeRegex() - const match = (p: string, str: string) => { - return p.endsWith('.md') && !p.endsWith('.c.md') && new RegExp(str, 'i').test(fs.readFileSync(p, 'utf-8')) + const match = async (p: string, str: string) => { + return isMarkdownFile(p) && + !isEncryptedMarkdownFile(p) && + new RegExp(str, 'i') + .test(await fs.readFile(p, 'utf-8')) } - const travelFiles = (location: string, basePath: string) => { - if (!fs.statSync(location).isDirectory()) { + const travelFiles = async (location: string, basePath: string, level: number) => { + // limit results + if (files.length >= 70) { + return + } + + if (!(await fs.stat(location)).isDirectory()) { return } - const list = fs.readdirSync(location).filter(x => !x.startsWith('.') && !ignorePath.test(x)) + const list = await fs.readdir(location, { withFileTypes: true }) + + await Promise.all(list.map(async x => { + if (x.isDirectory()) { + if (excludeRegex.test(x.name + '/')) { + return + } - list.forEach(x => { - const p = path.join(location, x) + const p = path.join(location, x.name) + await travelFiles(p, basePath, level + 1) + } else if (x.isFile()) { + if (excludeRegex.test(x.name)) { + return + } - if (fs.statSync(p).isDirectory()) { - travelFiles(p, basePath) - } else if (fs.statSync(p).isFile()) { - if (match(p, str)) { + const p = path.join(location, x.name) + if (await match(p, str)) { files.push({ repo, - name: x, - path: path.relative(basePath, p).replace(/\\/g, '/'), + name: x.name, + path: getRelativePath(basePath, p), type: 'file', + level, }) } } - }) + })) } - withRepo(repo, repoPath => { - travelFiles(repoPath, repoPath) - }) + await withRepo(repo, repoPath => travelFiles(repoPath, repoPath, 1)) return files } -export default { - read, - write, - rm, - mv, - exists, - hash, - checkHash, - upload, - tree, - open, - search, +export function historyList (repo: string, path: string) { + return withRepo(repo, async (_, filePath) => { + const historyFilePath = getHistoryFilePath(filePath) + + if (!(await fs.pathExists(historyFilePath))) { + return { list: [], size: 0 } + } + + const stats = await fs.stat(historyFilePath) + const zip = readHistoryZip(historyFilePath) + const list = orderBy(zip.getEntries(), x => x.entryName, 'desc').map(x => ({ + name: x.entryName, + comment: x.comment + })) + + return { list, size: stats.size } + }, path) +} + +export function historyContent (repo: string, path: string, version: string) { + return withRepo(repo, async (_, filePath) => { + const historyFilePath = getHistoryFilePath(filePath) + + if (!(await fs.pathExists(historyFilePath))) { + return '' + } + + const zip = readHistoryZip(historyFilePath) + const entry = zip.getEntry(version) + if (!entry) { + return '' + } + + return await new Promise((resolve, reject) => { + entry.getDataAsync((data, err) => { + if (err) { + reject(err) + } else { + resolve(data.toString('utf-8')) + } + }) + }) + }, path) +} + +export async function deleteHistoryVersion (repo: string, p: string, version: string) { + if (readonly) throw new Error('Readonly') + + return withRepo(repo, async (_, filePath) => { + const historyFilePath = getHistoryFilePath(filePath) + + const zip = readHistoryZip(historyFilePath) + if (version === '--all--') { + zip.getEntries().slice().forEach(entry => { + if (!entry.comment) { + zip.deleteFile(entry) + } + }) + } else { + zip.deleteFile(version) + } + + writeHistoryZip(zip, historyFilePath) + }, p) +} + +export async function commentHistoryVersion (repo: string, p: string, version: string, msg: string) { + if (readonly) throw new Error('Readonly') + + return withRepo(repo, async (_, filePath) => { + const historyFilePath = getHistoryFilePath(filePath) + + const zip = readHistoryZip(historyFilePath) + const entry = zip.getEntry(version) + + if (!entry) { + return + } + + entry.comment = msg + + writeHistoryZip(zip, historyFilePath) + }, p) +} + +export async function watchFile (repo: string, p: string | string[], options: WatchOpts) { + return withRepo(repo, async (_, ...args) => { + const filePath = args.length === 1 ? args[0] : args + + const { response, enqueue, close } = createStreamResponse() + + type Message = { id: number, type: 'init' | 'stop' | 'enqueue', payload?: any } + + watchGid++ + + const id = watchGid + + const wp = getWatchProcess() + + wp.send({ id, type: 'init', payload: { filePath, options } } satisfies Message) + + const onMessage = (message: Message) => { + if (message.id !== id) { + return + } + + if (message.type === 'enqueue') { + try { + if (!response.closed) { + enqueue(message.payload.type, message.payload.data) + } + } catch (error) { + console.error('watchFile', filePath, 'enqueue error', error) + } + } + } + + const onError = (err: any) => { + console.error('watchFile', filePath, 'error', err) + _stop() + } + + const onExit = (code: any) => { + close() + console.log('watchFile', id, filePath, 'exit', code) + } + + const _stop = () => { + console.log('watchFile', id, filePath, 'stop') + wp.send({ id, type: 'stop' } satisfies Message) + app.off('quit', _stop) + wp.off('message', onMessage) + wp.off('error', onError) + wp.off('exit', onExit) + } + + wp.on('message', onMessage) + wp.on('error', onError) + wp.on('exit', onExit) + app.on('quit', _stop) + + response.once('close', () => { + console.log('watchFile', id, filePath, 'response close') + _stop() + }) + + response.once('error', (err) => { + console.warn('watchFile', id, filePath, 'error', err) + _stop() + }) + + return response + }, ...(Array.isArray(p) ? p : [p])) } diff --git a/src/main/server/index.ts b/src/main/server/index.ts index 2fe060ba3..bacc74365 100644 --- a/src/main/server/index.ts +++ b/src/main/server/index.ts @@ -1,58 +1,201 @@ -import * as fs from 'fs' +import * as os from 'os' +import ip from 'ip' +import * as fs from 'fs-extra' +import uniq from 'lodash/uniq' +import type NodePty from 'node-pty' +import isEqual from 'lodash/isEqual' import * as path from 'path' import Koa from 'koa' import bodyParser from 'koa-body' import * as mime from 'mime' -import request from 'request' +import * as undici from 'undici' import { promisify } from 'util' -import { STATIC_DIR, HOME_DIR, HELP_DIR, USER_PLUGIN_DIR, FLAG_DISABLE_SERVER } from '../constant' -import file from './file' -import dataRepository from './repository' +import { STATIC_DIR, HOME_DIR, HELP_DIR, USER_PLUGIN_DIR, FLAG_DISABLE_SERVER, APP_NAME, USER_THEME_DIR, RESOURCES_DIR, BUILD_IN_STYLES, USER_EXTENSION_DIR, USER_DATA } from '../constant' +import * as file from './file' +import * as search from './search' import run from './run' import convert from './convert' import plantuml from './plantuml' +import * as premium from './premium' import shell from '../shell' -import mark from './mark' import config from '../config' +import * as jwt from '../jwt' import { getAction } from '../action' +import * as extension from '../extension' +import type { FileReadResult } from '../../share/types' -const result = (status: 'ok' | 'error' = 'ok', message = '操作成功', data: any = null) => { +const isLocalhost = (address: string) => { + return ip.isEqual(address, '127.0.0.1') || ip.isEqual(address, '::1') +} + +const result = (status: 'ok' | 'error' = 'ok', message = 'success', data: any = null) => { return { status, message, data } } +const noCache = (ctx: any) => { + ctx.set('Cache-Control', 'no-store, no-cache, must-revalidate') + ctx.set('Pragma', 'no-cache') + ctx.set('Expires', 0) +} + +const checkPermission = (ctx: any, next: any) => { + const token = ctx.query._token || (ctx.headers['x-yn-authorization'] ?? (ctx.headers.authorization || '')).replace('Bearer', '').trim() + + if (ctx.req._protocol || (!token && isLocalhost(ctx.request.ip))) { + ctx.req.jwt = { role: 'admin' } + return next() + } + + if (!ctx.path.startsWith('/api')) { + return next() + } + + const allowList = { + public: [ + '/api/help', + '/api/custom-css', + '/api/custom-styles', + '/api/plugins', + '/api/attachment', + '/api/plantuml', + '/api/settings/js', + '/api/extensions', + ], + guest: [ + '/api/file', + '/api/settings', + '/api/proxy-fetch' + ] + } + + if (ctx.method === 'GET' && allowList.public.some(x => ctx.path.startsWith(x))) { + return next() + } + + let payload + try { + payload = jwt.verify(token) + ctx.req.jwt = payload + } catch (error) { + ctx.status = 401 + throw error + } + + if (payload.role === 'admin') { + return next() + } + + if (payload.role === 'guest' && ctx.method === 'GET' && allowList.guest.some(x => ctx.path.startsWith(x))) { + return next() + } + + ctx.status = 403 + throw new Error('Forbidden') +} + +const isAdmin = (ctx: any) => ctx.req.jwt && ctx.req.jwt.role === 'admin' + +const checkIsAdmin = (ctx: any) => { + if (!isAdmin(ctx)) { + throw new Error('Forbidden') + } +} + +const checkPrivateRepo = (ctx: any, repo: string) => { + if (repo.startsWith('__')) { + checkIsAdmin(ctx) + } +} + const fileContent = async (ctx: any, next: any) => { if (ctx.path === '/api/file') { if (ctx.method === 'GET') { - ctx.body = result('ok', '获取成功', { - content: file.read(ctx.query.repo, ctx.query.path).toString(), - hash: file.hash(ctx.query.repo, ctx.query.path) - }) + const { repo, path, asBase64 } = ctx.query + + checkPrivateRepo(ctx, repo) + + const stat = await file.stat(repo, path) + + // limit 30mb + if (stat.size > 30 * 1024 * 1024) { + throw new Error('File is too large.') + } + + const content = await file.read(repo, path) + + const data: FileReadResult = { + content: content.toString(asBase64 ? 'base64' : undefined), + hash: await file.hash(repo, path), + stat: await file.stat(repo, path), + writeable: await file.checkWriteable(repo, path), + } + + ctx.body = result('ok', 'success', data) } else if (ctx.method === 'POST') { - const oldHash = ctx.request.body.old_hash + const { oldHash, content, asBase64, repo, path } = ctx.request.body if (!oldHash) { - throw new Error('未传递文件hash') - } else if (oldHash === 'new' && file.exists(ctx.request.body.repo, ctx.request.body.path)) { - throw new Error('文件已经存在') - } else if (oldHash !== 'new' && !file.checkHash(ctx.request.body.repo, ctx.request.body.path, oldHash)) { - throw new Error('磁盘文件已经更新,请刷新文件') + throw new Error('No hash.') + } else if (oldHash === 'new' && (await file.exists(repo, path))) { + throw new Error('File or directory already exists.') + } else if (oldHash !== 'new' && !(await file.checkHash(repo, path, oldHash))) { + throw new Error('File is stale. Please refresh.') } - const hash = file.write(ctx.request.body.repo, ctx.request.body.path, ctx.request.body.content) - ctx.body = result('ok', '保存成功', hash) + let saveContent = content + if (asBase64) { + saveContent = Buffer.from( + content.startsWith('data:') ? content.substring(content.indexOf(',') + 1) : content, + 'base64' + ) + } + + ctx.body = result('ok', 'success', { + hash: await file.write(repo, path, saveContent), + stat: await file.stat(repo, path), + }) } else if (ctx.method === 'DELETE') { - file.rm(ctx.query.repo, ctx.query.path) + const trash = ctx.query.trash !== 'false' + await file.rm(ctx.query.repo, ctx.query.path, trash) ctx.body = result() } else if (ctx.method === 'PATCH') { - if (file.exists(ctx.request.body.repo, ctx.request.body.newPath)) { - throw new Error('文件已经存在') + const { repo, oldPath, newPath } = ctx.request.body + if (oldPath === newPath) { + throw new Error('No change.') } - file.mv(ctx.request.body.repo, ctx.request.body.oldPath, ctx.request.body.newPath) + if ((await file.exists(repo, newPath)) && newPath.toLowerCase() !== oldPath.toLowerCase()) { + throw new Error('File or directory already exists.') + } + + await file.mv(repo, oldPath, newPath) + ctx.body = result() + } else if (ctx.method === 'PUT') { + const { repo, oldPath, newPath } = ctx.request.body + if ((await file.exists(repo, newPath))) { + throw new Error('File or directory already exists.') + } + + await file.cp(repo, oldPath, newPath) ctx.body = result() } } else if (ctx.path === '/api/tree') { - ctx.body = result('ok', '获取成功', file.tree(ctx.query.repo)) + const arr = (ctx.query.sort || '').split('-') + const sort = { by: arr[0] || 'name', order: arr[1] || 'asc' } + ctx.body = result('ok', 'success', (await file.tree(ctx.query.repo, sort, ctx.query.include, ctx.query.noEmptyDir === 'true'))) + } else if (ctx.path === '/api/history/list') { + ctx.body = result('ok', 'success', (await file.historyList(ctx.query.repo, ctx.query.path))) + } else if (ctx.path === '/api/history/content') { + ctx.body = result('ok', 'success', (await file.historyContent(ctx.query.repo, ctx.query.path, ctx.query.version))) + } else if (ctx.path === '/api/history/delete') { + const { repo, path, version } = ctx.request.body + ctx.body = result('ok', 'success', (await file.deleteHistoryVersion(repo, path, version))) + } else if (ctx.path === '/api/history/comment') { + const { repo, path, version, msg } = ctx.request.body + ctx.body = result('ok', 'success', (await file.commentHistoryVersion(repo, path, version, msg))) + } else if (ctx.path === '/api/watch-file') { + const { repo, path, options } = ctx.request.body + ctx.body = await file.watchFile(repo, path, options) } else { await next() } @@ -64,104 +207,271 @@ const attachment = async (ctx: any, next: any) => { const path = ctx.request.body.path const repo = ctx.request.body.repo const attachment = ctx.request.body.attachment + const exists = ctx.request.body.exists const buffer = Buffer.from(attachment.substring(attachment.indexOf(',') + 1), 'base64') - file.upload(repo, buffer, path) - ctx.body = result('ok', '上传成功', path) + const res = await file.upload(repo, buffer, path, exists) + ctx.body = result('ok', 'success', res) } else if (ctx.method === 'GET') { - ctx.type = mime.getType(ctx.query.path) - ctx.body = file.read(ctx.query.repo, ctx.query.path) + let { repo, path } = ctx.query + + if (!repo || !path) { + const filePath = ctx.path.replace('/api/attachment', '') + const arr = filePath.split('/') + repo = decodeURIComponent(arr[1] || '') + path = decodeURI(arr.slice(2).join('/')) + } + + if (!repo || !path) { + throw new Error('Invalid path.') + } + + checkPrivateRepo(ctx, repo) + + noCache(ctx) + + try { + // support range + const range = ctx.headers.range + if (range) { + const { size } = await file.stat(repo, path) + const requestRange = range.replace('bytes=', '').split('-').map((x: string) => parseInt(x || '-1')) + const start = requestRange[0] < 0 ? 0 : requestRange[0] + const end = requestRange[1] < 0 + ? size - 1 + : requestRange[1] + + const chunkSize = end - start + 1 + + ctx.status = 206 + ctx.set('Content-Range', `bytes ${start}-${end}/${size}`) + ctx.set('Accept-Ranges', 'bytes') + ctx.set('Content-Length', chunkSize) + ctx.body = await file.createReadStream(repo, path, { start, end }) + } else { + ctx.body = await file.read(repo, path) + } + ctx.type = mime.getType(path) + } catch (error: any) { + if (error.code === 'ENOENT') { + ctx.status = 404 + ctx.body = result('error', 'Not found') + } else { + throw error + } + } } } else { await next() } } -const open = async (ctx: any, next: any) => { - if (ctx.path.startsWith('/api/open')) { - if (ctx.method === 'GET') { - file.open(ctx.query.repo, ctx.query.path) - ctx.body = result() - } +const searchFile = async (ctx: any, next: any) => { + if (ctx.path.startsWith('/api/search') && ctx.method === 'POST') { + const query = ctx.request.body.query + ctx.body = await search.search(query) } else { await next() } } -const markFile = async (ctx: any, next: any) => { - if (ctx.path.startsWith('/api/mark')) { - if (ctx.method === 'GET') { - ctx.body = result('ok', '获取成功', mark.list()) - } else if (ctx.method === 'POST') { - mark.add({ repo: ctx.query.repo, path: ctx.query.path }) - ctx.body = result() - } else if (ctx.method === 'DELETE') { - mark.remove({ repo: ctx.query.repo, path: ctx.query.path }) - ctx.body = result() +const plantumlGen = async (ctx: any, next: any) => { + if (ctx.path.startsWith('/api/plantuml')) { + try { + const { type, content } = await plantuml(ctx.query.data) + ctx.type = type + ctx.body = content + ctx.set('cache-control', 'max-age=86400') // 1 day. + } catch (error) { + ctx.body = error } } else { await next() } } -const searchFile = async (ctx: any, next: any) => { - if (ctx.path.startsWith('/api/search')) { - const search = ctx.query.search - const repo = ctx.query.repo - - ctx.body = result('ok', '操作成功', file.search(repo, search)) +const runCode = async (ctx: any, next: any) => { + if (ctx.path.startsWith('/api/run')) { + ctx.body = await run.runCode(ctx.request.body.cmd, ctx.request.body.code) } else { await next() } } -const repository = async (ctx: any, next: any) => { - if (ctx.path.startsWith('/api/repositories')) { - ctx.body = result('ok', '获取成功', dataRepository.list()) +const convertFile = async (ctx: any, next: any) => { + if (ctx.path.startsWith('/api/convert/')) { + const source = ctx.request.body.source + const fromType = ctx.request.body.fromType + const toType = ctx.request.body.toType + const resourcePath = ctx.request.body.resourcePath + + ctx.set('content-type', 'application/octet-stream') + ctx.body = await convert(source, fromType, toType, resourcePath) } else { await next() } } -const plantumlGen = async (ctx: any, next: any) => { - if (ctx.path.startsWith('/api/plantuml/png')) { - ctx.type = 'image/png' - try { - ctx.body = await plantuml(ctx.query.data) - ctx.set('cache-control', 'max-age=86400') // 一天过期 - } catch (error) { - ctx.body = error +const tmpFile = async (ctx: any, next: any) => { + if (ctx.path.startsWith('/api/tmp-file')) { + const absPath = path.join(os.tmpdir(), APP_NAME + '-' + ctx.query.name.replace(/\//g, '_')) + if (ctx.method === 'GET') { + ctx.body = await fs.readFile(absPath) + } else if (ctx.method === 'POST') { + let body: any = ctx.request.body.toString() + + if (ctx.query.asBase64) { + body = Buffer.from( + body.startsWith('data:') ? body.substring(body.indexOf(',') + 1) : body, + 'base64' + ) + } + + await fs.writeFile(absPath, body) + ctx.body = result('ok', 'success', { path: absPath }) + } else if (ctx.method === 'DELETE') { + await fs.unlink(absPath) + ctx.body = result('ok', 'success') } } else { await next() } } -const runCode = async (ctx: any, next: any) => { - if (ctx.path.startsWith('/api/run')) { - const rst = run.runCode(ctx.request.body.language, ctx.request.body.code) - ctx.body = result('ok', '运行成功', rst) - } else { - await next() - } -} +const userFile = async (ctx: any, next: any) => { + if (ctx.path.startsWith('/api/user-file')) { + const filePath = ctx.query.name.replace(/\.+/g, '.') // replace multiple dots with one dot -const convertFile = async (ctx: any, next: any) => { - if (ctx.path.startsWith('/api/convert/')) { - const html = ctx.request.body.html - const type = ctx.request.body.type + if (!filePath) { + throw new Error('Invalid path') + } + + const absPath = path.join(USER_DATA, filePath) + + if (ctx.method === 'GET') { + ctx.body = await fs.readFile(absPath) + } else if (ctx.method === 'POST') { + let body: any = ctx.request.body.toString() + + if (ctx.query.asBase64) { + body = Buffer.from( + body.startsWith('data:') ? body.substring(body.indexOf(',') + 1) : body, + 'base64' + ) + } + + await fs.ensureFile(absPath) + await fs.writeFile(absPath, body) + ctx.body = result('ok', 'success', { path: absPath }) + } else if (ctx.method === 'DELETE') { + await fs.unlink(absPath) + ctx.body = result('ok', 'success') + } + } else if (ctx.path.startsWith('/api/user-dir')) { + const dirPath = ctx.query.name.replace(/\.+/g, '.') // replace multiple dots with one dot + + if (!dirPath) { + throw new Error('Invalid path') + } - ctx.type = mime.getType(`file.${type}`) - ctx.body = await convert(html, type) + const absPath = path.join(USER_DATA, dirPath) + + if (ctx.method === 'GET') { + const recursive = ctx.query.recursive === 'true' + + const data: ({ name: string, absolutePath: string, path: string, isFile: boolean, isDir: boolean })[] = [] + + const readDirRecursive = async (dir: string) => { + const items = await fs.readdir(dir, { withFileTypes: true }) + + for (const item of items) { + const absolutePath = path.resolve(dir, item.name) + data.push({ + name: item.name, + absolutePath, + path: path.relative(absPath, absolutePath).replace(/\\/g, '/'), + isFile: item.isFile(), + isDir: item.isDirectory(), + }) + + if (recursive && item.isDirectory()) { + await readDirRecursive(absolutePath) + } + } + } + + await readDirRecursive(absPath) + + ctx.body = result('ok', 'success', data) + } } else { await next() } } const proxy = async (ctx: any, next: any) => { - if (ctx.path.startsWith('/api/proxy')) { - const url = ctx.query.url - const headers = ctx.query.headers ? JSON.parse(ctx.query.headers) : undefined - ctx.body = request(url, { headers }) + if (ctx.path.startsWith('/api/proxy-fetch/')) { + const url = ctx.originalUrl.replace(/^.*\/api\/proxy-fetch\//, '') + + // TODO ssrf + // const _url = new URL(url) + // if ( + // _url.hostname === 'localhost' || + // ( + // (ip.isV4Format(_url.hostname) || ip.isV6Format(_url.hostname)) && + // ip.isPrivate(_url.hostname) + // ) + // ) { + // throw new Error('Invalid URL') + // } + + let signal: AbortSignal | undefined + let timeoutTimer: NodeJS.Timeout | undefined + + try { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + host, + 'x-proxy-url': proxyUrl, + 'x-proxy-timeout': proxyTimeout, + 'x-proxy-max-redirections': maxRedirections = '3', + ...headers + } = ctx.headers + + const dispatcher = proxyUrl + ? getAction('new-proxy-dispatcher')(proxyUrl) + : await getAction('get-proxy-dispatcher')(url) + + if (proxyTimeout) { + const controller = new AbortController() + signal = controller.signal + timeoutTimer = setTimeout(() => { + controller.abort() + timeoutTimer = undefined + }, Number(proxyTimeout)) + } + + const response = await undici.request(url, { + dispatcher, + method: ctx.method, + headers, + body: ctx.req, + signal, + maxRedirections: Number(maxRedirections) + }) + + // Set the response status, headers, and body + ctx.status = response.statusCode + ctx.set(response.headers) + ctx.body = response.body + + response.body.once('close', () => { + timeoutTimer && clearTimeout(timeoutTimer) + }) + } catch (error: any) { + ctx.status = 500 + timeoutTimer && clearTimeout(timeoutTimer) + throw error + } } else { await next() } @@ -171,10 +481,10 @@ const readme = async (ctx: any, next: any) => { if (ctx.path.startsWith('/api/help')) { if (ctx.query.path) { ctx.type = mime.getType(ctx.query.path) - ctx.body = fs.readFileSync(path.join(HELP_DIR, ctx.query.path.replace('../', ''))) + ctx.body = await fs.readFile(path.join(HELP_DIR, ctx.query.path.replace('../', ''))) } else { - ctx.body = result('ok', '获取成功', { - content: fs.readFileSync(path.join(HELP_DIR, ctx.query.doc.replace('../', ''))).toString() + ctx.body = result('ok', 'success', { + content: await fs.readFile(path.join(HELP_DIR, ctx.query.doc.replace('../', '')), 'utf-8') }) } } else { @@ -187,27 +497,119 @@ const userPlugin = async (ctx: any, next: any) => { ctx.type = 'application/javascript; charset=utf-8' let code = '' - fs.readdirSync(USER_PLUGIN_DIR, { withFileTypes: true }) - .filter(x => x.isFile() && x.name.endsWith('.js')) - .forEach(x => { - code += `// ===== ${x.name} =====\n` + - fs.readFileSync(path.join(USER_PLUGIN_DIR, x.name)) + - '\n// ===== end =====\n\n' - }) + for (const x of await fs.readdir(USER_PLUGIN_DIR, { withFileTypes: true })) { + if (x.isFile() && x.name.endsWith('.js')) { + code += `;(async function () {; // ===== ${x.name} =====\n` + + (await fs.readFile(path.join(USER_PLUGIN_DIR, x.name))) + + '\n;})(); // ===== end =====\n\n' + } + } + ctx.body = code } else { await next() } } +const customCss = async (ctx: any, next: any) => { + if (ctx.path.startsWith('/api/custom-styles')) { + const files: string[] = [...BUILD_IN_STYLES] + for (const x of await fs.readdir(USER_THEME_DIR, { withFileTypes: true })) { + if (x.isFile() && x.name.endsWith('.css')) { + files.push(x.name) + } + } + + ctx.body = result('ok', 'success', Array.from(new Set(files))) + } else if (ctx.path.startsWith('/custom-css')) { + const configKey = 'custom-css' + const defaultCss = BUILD_IN_STYLES[0] + + ctx.type = 'text/css' + noCache(ctx) + + try { + const filename = config.get(configKey, defaultCss) + + if (filename.startsWith('extension:')) { + const extensions = await extension.list() + const extensionName = filename.substring('extension:'.length, filename.indexOf('/')) + if (extensions.some(x => x.enabled && x.id === extension.dirnameToId(extensionName))) { + ctx.redirect(`/extensions/${filename.replace('extension:', '')}`) + } else { + throw new Error(`extension not found [${extensionName}]`) + } + } else { + ctx.body = await fs.readFile(path.join(USER_THEME_DIR, filename)) + } + } catch (error) { + console.error(error) + + await fs.writeFile( + path.join(USER_THEME_DIR, defaultCss), + await fs.readFile(path.join(RESOURCES_DIR, defaultCss)) + ) + + config.set(configKey, defaultCss) + ctx.body = await fs.readFile(path.join(USER_THEME_DIR, defaultCss)) + } + } else { + await next() + } +} + const setting = async (ctx: any, next: any) => { if (ctx.path.startsWith('/api/settings')) { if (ctx.method === 'GET') { - ctx.body = result('ok', '获取成功', config.getAll()) + const getSettings = () => { + if (isAdmin(ctx)) { + return config.getAll() + } else { + const data = { ...config.getAll() } + data.repositories = {} + data.mark = [] + + // remove sensitive data + Object.keys(data).forEach((key) => { + if (key.endsWith('-token') || key.endsWith('-secret')) { + delete data[key] + } + }) + + delete data.license + delete data.extensions + return data + } + } + + if (ctx.path.endsWith('js')) { + ctx.type = 'application/javascript; charset=utf-8' + noCache(ctx) + ctx.body = '_INIT_SETTINGS = ' + JSON.stringify(getSettings()) + } else { + ctx.body = result('ok', 'success', getSettings()) + } } else if (ctx.method === 'POST') { - const data = { ...config.getAll(), ...ctx.request.body } + const oldConfig = config.getAll() + const data = { ...oldConfig, ...ctx.request.body } config.setAll(data) - ctx.body = result('ok', '设置成功') + + const changedKeys = uniq([...Object.keys(oldConfig), ...Object.keys(data)]) + .filter((key) => !isEqual(data[key], oldConfig[key])) + + if (oldConfig.language !== data.language) { + getAction('i18n.change-language')(data.language) + } + + if (oldConfig['updater.source'] !== data['updater.source'] && data['updater.source']) { + getAction('updater.change-source')(data['updater.source']) + } + + getAction('proxy.reload')(data) + getAction('envs.reload')(data) + getAction('shortcuts.reload')(changedKeys) + + ctx.body = result('ok', 'success') } } else { await next() @@ -222,7 +624,7 @@ const choose = async (ctx: any, next: any) => { const body = ctx.request.body if (chooseLock) { - throw new Error('当前正在选择文件') + throw new Error('Busy') } chooseLock = true @@ -230,8 +632,87 @@ const choose = async (ctx: any, next: any) => { const data = await getAction('show-open-dialog')(body) from === 'browser' && getAction('hide-main-window')() chooseLock = false - ctx.body = result('ok', '完成', data) + ctx.body = result('ok', 'success', data) + } + } else { + await next() + } +} + +const rpc = async (ctx: any, next: any) => { + if (ctx.path.startsWith('/api/rpc') && ctx.method === 'POST') { + const { code } = ctx.request.body + const AsyncFunction = Object.getPrototypeOf(async () => 0).constructor + const fn = new AsyncFunction('require', code) + const nodeRequire = (id: string) => id.startsWith('.') + ? require(path.resolve(__dirname, '..', id)) + : require(id) + ctx.body = result('ok', 'success', await fn(nodeRequire)) + } else { + await next() + } +} + +const sendFile = async (ctx: any, next: any, filePath: string, fullback = true) => { + if (!fs.existsSync(filePath)) { + if (fullback) { + await sendFile(ctx, next, path.resolve(STATIC_DIR, 'index.html'), false) + } else { + next() + } + + return false + } + + const fileStat = fs.statSync(filePath) + if (fileStat.isDirectory()) { + await sendFile(ctx, next, path.resolve(filePath, 'index.html')) + return true + } + + ctx.body = await promisify(fs.readFile)(filePath) + ctx.set('Content-Length', fileStat.size) + ctx.set('Last-Modified', fileStat.mtime.toUTCString()) + ctx.set('Cache-Control', 'max-age=0') + ctx.set('X-XSS-Protection', '0') + ctx.type = path.extname(filePath) + + return true +} + +const userExtension = async (ctx: any, next: any) => { + if (ctx.method === 'GET') { + if (ctx.path.startsWith('/api/extensions')) { + ctx.body = result('ok', 'success', await extension.list()) + } else if (ctx.path.startsWith('/extensions/') && ctx.method === 'GET') { + const filePath = path.join(USER_EXTENSION_DIR, ctx.path.replace('/extensions', '')) + await sendFile(ctx, next, filePath, false) + } else { + await next() + } + } else { + const id = ctx.query.id + if (ctx.path.startsWith('/api/extensions/install')) { + ctx.body = result('ok', 'success', await extension.install(id, ctx.query.url)) + } else if (ctx.path.startsWith('/api/extensions/uninstall')) { + ctx.body = result('ok', 'success', await extension.uninstall(id)) + } else if (ctx.path.startsWith('/api/extensions/enable')) { + ctx.body = result('ok', 'success', await extension.enable(id)) + } else if (ctx.path.startsWith('/api/extensions/disable')) { + ctx.body = result('ok', 'success', await extension.disable(id)) + } else if (ctx.path.startsWith('/api/extensions/abort-installation')) { + ctx.body = result('ok', 'success', await extension.abortInstallation()) + } else { + await next() } + } +} + +const premiumManage = async (ctx: any, next: any) => { + if (ctx.method === 'POST' && ctx.path.startsWith('/api/premium')) { + const { method, payload } = ctx.request.body + const data = await (premium as any)[method](payload) + ctx.body = result('ok', 'success', data) } else { await next() } @@ -240,8 +721,10 @@ const choose = async (ctx: any, next: any) => { const wrapper = async (ctx: any, next: any, fun: any) => { try { await fun(ctx, next) - } catch (error) { + } catch (error: any) { console.error(error) + ctx.set('x-yank-note-api-status', 'error') + ctx.set('x-yank-note-api-message', encodeURIComponent(error.message)) ctx.body = result('error', error.message) } } @@ -249,11 +732,14 @@ const wrapper = async (ctx: any, next: any, fun: any) => { const server = (port = 3000) => { const app = new Koa() + app.use(async (ctx: any, next: any) => await wrapper(ctx, next, checkPermission)) + app.use(async (ctx: any, next: any) => await wrapper(ctx, next, proxy)) + app.use(bodyParser({ multipart: true, - formLimit: '20mb', - jsonLimit: '20mb', - textLimit: '20mb', + formLimit: '50mb', + jsonLimit: '50mb', + textLimit: '50mb', formidable: { maxFieldsSize: 268435456 } @@ -261,53 +747,34 @@ const server = (port = 3000) => { app.use(async (ctx: any, next: any) => await wrapper(ctx, next, fileContent)) app.use(async (ctx: any, next: any) => await wrapper(ctx, next, attachment)) - app.use(async (ctx: any, next: any) => await wrapper(ctx, next, open)) app.use(async (ctx: any, next: any) => await wrapper(ctx, next, plantumlGen)) app.use(async (ctx: any, next: any) => await wrapper(ctx, next, runCode)) app.use(async (ctx: any, next: any) => await wrapper(ctx, next, convertFile)) app.use(async (ctx: any, next: any) => await wrapper(ctx, next, searchFile)) - app.use(async (ctx: any, next: any) => await wrapper(ctx, next, repository)) - app.use(async (ctx: any, next: any) => await wrapper(ctx, next, proxy)) app.use(async (ctx: any, next: any) => await wrapper(ctx, next, readme)) - app.use(async (ctx: any, next: any) => await wrapper(ctx, next, markFile)) app.use(async (ctx: any, next: any) => await wrapper(ctx, next, userPlugin)) + app.use(async (ctx: any, next: any) => await wrapper(ctx, next, customCss)) + app.use(async (ctx: any, next: any) => await wrapper(ctx, next, userExtension)) + app.use(async (ctx: any, next: any) => await wrapper(ctx, next, premiumManage)) app.use(async (ctx: any, next: any) => await wrapper(ctx, next, setting)) app.use(async (ctx: any, next: any) => await wrapper(ctx, next, choose)) + app.use(async (ctx: any, next: any) => await wrapper(ctx, next, tmpFile)) + app.use(async (ctx: any, next: any) => await wrapper(ctx, next, userFile)) + app.use(async (ctx: any, next: any) => await wrapper(ctx, next, rpc)) - // 静态文件 + // static file app.use(async (ctx: any, next: any) => { const urlPath = decodeURIComponent(ctx.path).replace(/^(\/static\/|\/)/, '') - const sendFile = async (filePath: string, fullback = true) => { - if (!fs.existsSync(filePath)) { - if (fullback) { - await sendFile(path.resolve(STATIC_DIR, 'index.html'), false) - } else { - next() - } - - return - } - - const fileStat = fs.statSync(filePath) - if (fileStat.isDirectory()) { - await sendFile(path.resolve(filePath, 'index.html')) - return - } - ctx.body = await promisify(fs.readFile)(filePath) - ctx.set('Content-Length', fileStat.size) - ctx.set('Last-Modified', fileStat.mtime.toUTCString()) - ctx.set('Cache-Control', 'max-age=0') - ctx.type = path.extname(filePath) + if (!(await sendFile(ctx, next, path.resolve(STATIC_DIR, urlPath), false))) { + await sendFile(ctx, next, path.resolve(USER_THEME_DIR, urlPath), true) } - - await sendFile(path.resolve(STATIC_DIR, urlPath), true) }) const callback = app.callback() if (FLAG_DISABLE_SERVER) { - return callback + return { callback } } // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -315,34 +782,64 @@ const server = (port = 3000) => { // eslint-disable-next-line @typescript-eslint/no-var-requires const io = require('socket.io')(server, { path: '/ws' }) // eslint-disable-next-line @typescript-eslint/no-var-requires - const pty = require('node-pty') + + let pty: typeof NodePty | null = null + + try { + pty = require('node-pty') + } catch (error) { + console.error(error) + } io.on('connection', (socket: any) => { - const ptyProcess = pty.spawn(shell.getShell(), [], { - name: 'xterm-color', - cols: 80, - rows: 24, - cwd: HOME_DIR, - env: process.env - }) - ptyProcess.onData((data: any) => socket.emit('output', data)) - ptyProcess.onExit(() => socket.disconnect()) - socket.on('input', (data: any) => { - if (data.startsWith(shell.CD_COMMAND_PREFIX)) { - ptyProcess.write(shell.transformCdCommand(data.toString())) - } else { - ptyProcess.write(data) + if (!isLocalhost(socket.client.conn.remoteAddress)) { + socket.disconnect() + return + } + + if (pty) { + const ptyProcess = pty.spawn(shell.getShell(), [], { + name: 'xterm-color', + cols: 80, + rows: 24, + cwd: socket.handshake.query.cwd || HOME_DIR, + env: process.env, + useConpty: false, + }) + + const kill = () => { + ptyProcess.kill() } - }) - socket.on('resize', (size: any) => ptyProcess.resize(size[0], size[1])) - socket.on('disconnect', () => ptyProcess.kill()) + + ptyProcess.onData((data: any) => socket.emit('output', data)) + ptyProcess.onExit(() => { + console.log('ptyProcess exit') + socket.disconnect() + process.off('exit', kill) + }) + + socket.on('input', (data: any) => { + if (data.startsWith(shell.CD_COMMAND_PREFIX)) { + ptyProcess.write(shell.transformCdCommand(data.toString())) + } else { + ptyProcess.write(data) + } + }) + socket.on('resize', (size: any) => ptyProcess.resize(size[0], size[1])) + socket.on('disconnect', kill) + + process.on('exit', kill) + } else { + socket.emit('output', 'node-pty is not compatible with this platform. Please install another version from GitHub https://github.com/purocean/yn/releases') + } }) - server.listen(port, 'localhost') + const host = config.get('server.host', '127.0.0.1') + server.listen(port, host) - console.log(`访问地址:http://localhost:${port}`) + console.log(`Address: http://${host}:${port}`) - return callback + return { callback, server } } export default server diff --git a/src/main/server/mark.ts b/src/main/server/mark.ts deleted file mode 100644 index eb96b068a..000000000 --- a/src/main/server/mark.ts +++ /dev/null @@ -1,29 +0,0 @@ -import config from '../config' - -export interface MarkedFile { - repo: string; - path: string; -} - -const configKey = 'mark' -const defaultVal: MarkedFile[] = [] - -const list = () => { - return config.get(configKey, defaultVal) as MarkedFile[] -} - -const remove = (file: MarkedFile) => { - config.set(configKey, list().filter(x => !(x.path === file.path && x.repo === file.repo))) -} - -const add = (file: MarkedFile) => { - remove(file) - - config.set(configKey, [file].concat(list())) -} - -export default { - list, - add, - remove, -} diff --git a/src/main/server/plantuml.ts b/src/main/server/plantuml.ts index beecab0a5..bd6796bef 100644 --- a/src/main/server/plantuml.ts +++ b/src/main/server/plantuml.ts @@ -1,34 +1,113 @@ -import request from 'request' -import fs from 'fs' +import * as crypto from 'crypto' +import fs from 'fs-extra' import path from 'path' +import pako from 'pako' import { PlantUmlPipe } from 'plantuml-pipe' import commandExists from 'command-exists' -import { addDefaultsToOptions } from 'plantuml-pipe/dist/plantuml_pipe_options' import config from '../config' -import { convertAppPath } from '../helper' -import { ASSETS_DIR } from '../constant' - -export default async function (data: any) { - try { - await commandExists('java') - } catch { - throw fs.createReadStream(path.join(ASSETS_DIR, 'no-java-runtime.png')) +import { ASSETS_DIR, BIN_DIR, CACHE_DIR } from '../constant' +import { getAction } from '../action' +import { request } from 'undici' + +function plantumlBase64 (base64: string) { + // eslint-disable-next-line quote-props + const map: any = { 'A': '0', 'B': '1', 'C': '2', 'D': '3', 'E': '4', 'F': '5', 'G': '6', 'H': '7', 'I': '8', 'J': '9', 'K': 'A', 'L': 'B', 'M': 'C', 'N': 'D', 'O': 'E', 'P': 'F', 'Q': 'G', 'R': 'H', 'S': 'I', 'T': 'J', 'U': 'K', 'V': 'L', 'W': 'M', 'X': 'N', 'Y': 'O', 'Z': 'P', 'a': 'Q', 'b': 'R', 'c': 'S', 'd': 'T', 'e': 'U', 'f': 'V', 'g': 'W', 'h': 'X', 'i': 'Y', 'j': 'Z', 'k': 'a', 'l': 'b', 'm': 'c', 'n': 'd', 'o': 'e', 'p': 'f', 'q': 'g', 'r': 'h', 's': 'i', 't': 'j', 'u': 'k', 'v': 'l', 'w': 'm', 'x': 'n', 'y': 'o', 'z': 'p', '0': 'q', '1': 'r', '2': 's', '3': 't', '4': 'u', '5': 'v', '6': 'w', '7': 'x', '8': 'y', '9': 'z', '+': '-', '/': '_', '=': '' } + return base64.split('').map(x => map[x] || '').join('') +} + +function getCacheKey (api: string, type: string, data: string) { + return crypto.createHash('sha256').update(api + type + data).digest('hex') +} + +async function gcCache (cacheDir: string) { + const files = await fs.readdir(cacheDir) + if (files.length < 4000) { + return + } + + const stats = await Promise.all(files.map(file => fs.stat(path.join(cacheDir, file)))) + stats.sort((a, b) => a.atimeMs - b.atimeMs) + + for (let i = 0; i < stats.length / 2; i++) { + await fs.remove(path.join(cacheDir, files[i])) } +} + +async function getCacheData (key: string, gen: () => Promise) { + const cacheDir = path.join(CACHE_DIR, 'plantuml') + await fs.ensureDir(cacheDir) + + gcCache(cacheDir) - const api = config.get('plantuml-api', 'local') + const cacheFile = path.join(cacheDir, key) + if (await fs.pathExists(cacheFile)) { + const stat = await fs.stat(cacheFile) + if (stat.size) { + return fs.createReadStream(cacheFile) + } + } + + const data = await gen() + if (!data) { + throw new Error('No data') + } - if (api === 'local') { - const puml = new PlantUmlPipe({ - outputFormat: 'png', - plantUmlArgs: ['-charset', 'UTF-8'], - jarPath: convertAppPath(addDefaultsToOptions({}).jarPath) + if (typeof data.pipe === 'function') { + await new Promise((resolve, reject) => { + data.on('end', resolve) + data.on('error', reject) + data.pipe(fs.createWriteStream(cacheFile)) }) + return fs.createReadStream(cacheFile) + } else { + await fs.writeFile(cacheFile, data) + return fs.createReadStream(cacheFile) + } +} + +export default async function (data: string): Promise<{ content: any, type: string }> { + const api: string = config.get('plantuml-api', 'local') + + if (api.startsWith('local')) { + try { + await commandExists('java') + } catch { + throw fs.createReadStream(path.join(ASSETS_DIR, 'no-java-runtime.png')) + } + + const format = api.split('-')[1] || 'png' + const type = format === 'png' ? 'image/png' : 'image/svg+xml' + + const cacheKey = getCacheKey(api, type, data) + const content = await getCacheData(cacheKey, async () => { + const jarPath = path.join(BIN_DIR, 'plantuml.jar') - puml.in.write(data) - puml.in.end() + const puml = new PlantUmlPipe({ + split: format === 'svg', + outputFormat: format as 'png' | 'svg', + plantUmlArgs: ['-charset', 'UTF-8'], + jarPath, + }) - return puml.out + puml.in.write(pako.inflateRaw(Buffer.from(data, 'base64'))) + puml.in.end() + + return puml.out + }) + + return { content, type } } else { - return request(api.replace('{data}', encodeURIComponent(data))) + const url = api.replace('{data}', plantumlBase64(data)) + const dispatcher = await getAction('get-proxy-dispatcher')(url) + let type = api.includes('/svg/') ? 'image/svg+xml' : 'image/png' + + const cacheKey = getCacheKey(api, type, data) + const content = await getCacheData(cacheKey, async () => { + const res = await request(url, { dispatcher }) + type = res.headers['content-type'] as string + return res.body + }) + + return { content, type } } } diff --git a/src/main/server/premium.ts b/src/main/server/premium.ts new file mode 100644 index 000000000..9bbc88413 --- /dev/null +++ b/src/main/server/premium.ts @@ -0,0 +1,69 @@ +import { AppLicenseClient, decodeDevice } from 'app-license' +import { request } from 'undici' +import { API_BASE_URL, PREMIUM_PUBLIC_KEY } from '../../share/misc' +import { getAction } from '../action' + +const SERVER_BASE_URL = API_BASE_URL + '/api/premium' + +export async function fetchApi (url: string, payload: any) { + const dispatcher = await getAction('get-proxy-dispatcher')(url) + const res = await request(SERVER_BASE_URL + url, { + dispatcher, + method: 'POST', + body: JSON.stringify(payload), + headers: { 'Content-Type': 'application/json' }, + }) + + const body: any = await res.body.json() + + if (!body || !body.status) { + throw new Error('Invalid response') + } + + const { data, message, status } = body + if (status !== 'ok') { + throw new Error(message) + } + + return data +} + +const client = new AppLicenseClient({ + publicKey: PREMIUM_PUBLIC_KEY, + async fetchAdapter (method: string, payload: any): Promise { + return fetchApi(`/${method}`, payload) + }, +}) + +export async function fetchToken (payload: {licenseId: string}): Promise { + const data = await client.fetchToken(payload.licenseId) + return data.toString() +} + +export function removeDevice (payload: {licenseId: string, device: string}) { + return client.removeDevice(payload.licenseId, payload.device) +} + +export function addDevice (payload: {licenseId: string}) { + return client.addDevice(payload.licenseId) +} + +export function fetchDevices (payload: {licenseId: string}) { + return client.fetchDevices(payload.licenseId) +} + +export function upgradeLicense (payload: {oldLicense: string, locale: string}) { + return fetchApi('/upgrade-license', payload) +} + +export async function checkDevice (payload: { device: string }) { + const device = decodeDevice(await genDeviceString()) + const _device = decodeDevice(payload.device) + if (device.id !== _device.id || device.platform !== _device.platform) { + throw new Error('INVALID_LICENSE') + } +} + +export function genDeviceString () { + return client.genDeviceString() +} diff --git a/src/main/server/repository.ts b/src/main/server/repository.ts index 42735e2ff..56daf478b 100644 --- a/src/main/server/repository.ts +++ b/src/main/server/repository.ts @@ -1,6 +1,6 @@ import * as path from 'path' +import * as fs from 'fs-extra' import config from '../config' -import { TRASH_DIR } from '../constant' import { isWsl, toWslPath } from '../wsl' const configKey = 'repositories' @@ -23,15 +23,16 @@ const getPath = (name: string) => { p = /^[a-zA-Z]:/.test(p) ? toWslPath(p) : p } - return path.isAbsolute(p) ? p : path.resolve(p) -} + p = path.isAbsolute(p) ? p : path.resolve(p) + + if (!fs.pathExistsSync(p)) { + return null + } -const getTrashPath = (name: string) => { - return path.join(TRASH_DIR, name) + return p } export default { list, getPath, - getTrashPath, } diff --git a/src/main/server/run.ts b/src/main/server/run.ts index beeb22ca0..34c3f0b4d 100644 --- a/src/main/server/run.ts +++ b/src/main/server/run.ts @@ -1,62 +1,123 @@ -import * as fs from 'fs' +import * as fs from 'fs-extra' import * as os from 'os' import * as path from 'path' -import { execFileSync } from 'child_process' -import * as wsl from '../wsl' +import * as cp from 'child_process' +import glob from 'glob' import config from '../config' +import { mergeStreams } from '../helper' -const isWsl = wsl.isWsl const isWin = os.platform() === 'win32' -const runCode = (language: string, code: string) => { +function execFile (file: string, args: string[], options?: cp.ExecFileOptions) { + return new Promise((resolve) => { + const process = cp.execFile(file, args, { timeout: 300 * 1000, ...options }) + + process.on('error', error => { + resolve(error.message) + }) + + process.on('spawn', () => { + const output = mergeStreams([process.stdout, process.stderr].filter(Boolean) as NodeJS.ReadableStream[]) + + output.on('close', () => { + console.log('execFile process closed') + process.kill() + }) + + resolve(output) + }) + }) +} + +function execCmd (cmd: string, options?: cp.ExecOptions, onComplete?: () => void) { + return new Promise((resolve) => { + const process = cp.exec(cmd, { timeout: 300 * 1000, ...options }) + + process.on('error', error => { + resolve(error.message) + }) + + process.on('spawn', () => { + const output = mergeStreams([process.stdout, process.stderr].filter(Boolean) as NodeJS.ReadableStream[]) + + output.on('close', () => { + console.log('execCmd process closed') + onComplete && onComplete() + process.kill() + }) + + resolve(output) + }) + }) +} + +const runCode = async (cmd: { cmd: string, args: string[] } | string, code: string): Promise => { try { - const languageMap = { - sh: { cmd: 'sh', args: ['-c'] }, - shell: { cmd: 'sh', args: ['-c'] }, - bash: { cmd: 'bash', args: ['-c'] }, - php: { cmd: 'php', args: ['-r'] }, - python: { cmd: 'python3', args: ['-c'] }, - py: { cmd: 'python3', args: ['-c'] }, - js: { cmd: 'node', args: ['-e'] }, - node: { cmd: 'node', args: ['-e'] }, - } as {[key: string]: {cmd: string; args: string[]}} - - if (language === 'bat' && isWin) { - const fileName = 'yn-run-cmd.bat' - code = code.split('\n').slice(1).join('\n') // 去掉第一行的注释 - if (isWsl) { - const tmpFile = path.join(wsl.toWslPath(wsl.getWinTempPath()), fileName) - fs.writeFileSync(tmpFile, code) - return execFileSync('cmd.exe', ['/c', `${wsl.toWinPath(tmpFile).replace(/\\/g, '/')}`]).toString() + const env = { ...process.env } + const useWsl = isWin && config.get('runCodeUseWsl', false) + + if (typeof cmd === 'string') { + const useTplFile = cmd.includes('$tmpFile') + if (useTplFile) { + const extMatch = cmd.match(/\$tmpFile(\.[a-zA-Z0-9]+)/) + const ext = extMatch ? extMatch[1] : '' + const tmpFileWithoutExt = path.join(os.tmpdir(), `yn-run-${Math.random().toString(36).substring(2)}`) + const tmpFile = tmpFileWithoutExt + ext + await fs.writeFile(tmpFile, code) + + const removeTmpFiles = () => { + glob(tmpFileWithoutExt + '*', {}, (err, files) => { + if (!err) { + files.forEach(file => { + fs.remove(file) + }) + } + }) + } + + try { + return execCmd(cmd.replaceAll('$tmpFile', tmpFileWithoutExt), { env }, removeTmpFiles) + } catch (error) { + removeTmpFiles() + throw error + } } else { - const tmpFile = path.join(os.tmpdir(), fileName) - fs.writeFileSync(tmpFile, code) - return execFileSync('cmd', ['/c', tmpFile]).toString() + return execCmd(cmd, { env }) } - } + } else { + if (useWsl) { + const args = [] + if (typeof useWsl === 'string') { + args.push('--distribution', useWsl) + } - const runParams = languageMap[language] - if (!runParams) { - return `不支持 ${language} 语言` - } + return execFile('wsl.exe', [...args, '--', cmd.cmd].concat(cmd.args).concat([code])) + } - const useWsl = isWin && config.get('runCodeUseWsl', false) - if (useWsl) { - return execFileSync('wsl.exe', ['--', runParams.cmd].concat(runParams.args).concat([code])).toString() - } + if (isWin) { + if (!env.PYTHONIOENCODING) { + // use utf-8 encoding for python on windows + env.PYTHONIOENCODING = 'utf-8' + } + } else { + const extPath = '/usr/local/bin' + if (env.PATH && env.PATH.indexOf(extPath) < 0) { + env.PATH = `${extPath}:${env.PATH}` + } - const env = { ...process.env } - const extPath = '/usr/local/bin' - if (!isWin && env.PATH && env.PATH.indexOf(extPath) < 0) { - env.PATH = `${extPath}:${env.PATH}` - } + if (!env.LANG && !env.LC_ALL) { + env.LANG = 'en_US.UTF-8' + env.LC_ALL = 'en_US.UTF-8' + } + } - return execFileSync( - runParams.cmd, - runParams.args.concat([code]), - { env } - ).toString() - } catch (e) { + return execFile( + cmd.cmd, + cmd.args.concat([code]), + { env } + ) + } + } catch (e: any) { return e.message } } diff --git a/src/main/server/search.ts b/src/main/server/search.ts new file mode 100644 index 000000000..0907b8efe --- /dev/null +++ b/src/main/server/search.ts @@ -0,0 +1,46 @@ +import os from 'os' +import { CancellationTokenSource, ITextQuery, TextSearchEngineAdapter } from 'ripgrep-wrapper' +import { rgPath } from '@vscode/ripgrep' +import { BIN_DIR } from '../constant' +import { convertAppPath, createStreamResponse } from '../helper' + +let rgDiskPath: string +if (os.platform() === 'darwin') { + rgDiskPath = BIN_DIR + '/rg-darwin-' + os.arch() +} else { + rgDiskPath = convertAppPath(rgPath) +} + +export async function search (query: ITextQuery) { + if (!query.maxFileSize) { + query.maxFileSize = 3 * 1024 * 1024 // limit to 3MB + } + + const cts = new CancellationTokenSource() + const cancel = () => { + cts.cancel() + } + + const { close, enqueue, response } = createStreamResponse(() => cts.token.isCancellationRequested) + + const adapter = new TextSearchEngineAdapter(rgDiskPath, query) + + adapter.search(cts.token, (res) => { + enqueue('result', res) + }, message => { + enqueue('message', message) + }).then((success) => { + enqueue('done', success) + enqueue('null', null) + close() + }, (err) => { + enqueue('error', err) + enqueue('null', null) + close() + }) + + response.once('close', cancel) + response.once('error', cancel) + + return response +} diff --git a/src/main/server/watch-worker.ts b/src/main/server/watch-worker.ts new file mode 100644 index 000000000..fb355c6eb --- /dev/null +++ b/src/main/server/watch-worker.ts @@ -0,0 +1,125 @@ +import * as fs from 'fs-extra' +import chokidar from 'chokidar' +import path from 'path' +import { isMarkdownFile } from '../../share/misc' + +export type Message = { id: number, type: 'init' | 'stop' | 'enqueue', payload?: any } +export type WatchOpts = chokidar.WatchOptions & { mdContent?: boolean, mdFilesOnly?: boolean } + +function init (id: number, filePath: string | string[], options: WatchOpts) { + console.log(`watch process ${id} >`, filePath, 'init') + + try { + const ignoredRegexStr = options.ignored as string + if (ignoredRegexStr && typeof ignoredRegexStr === 'string') { + const ignoredRegex = new RegExp(ignoredRegexStr) + options.ignored = (str: string) => { + return str.split(path.sep) + .some((x, i, arr) => ignoredRegex.test(i === arr.length - 1 ? x : x + '/')) + } + } + } catch (error) { + console.error(`watch process ${id} >`, filePath, 'ignored error', error) + } + + const watcher = chokidar.watch(filePath, options) + + const promiseQueue: Promise[] = [] + + let queueIsRunning = false + const triggerPromiseQueue = async () => { + if (queueIsRunning) { + return + } + + queueIsRunning = true + while (promiseQueue.length) { + const promise = promiseQueue.pop() + if (promise) { + try { + enqueue('result', await promise) + } catch (error) { + console.error(`watch process ${id} >`, filePath, 'promise error', error) + } + } + } + queueIsRunning = false + } + + watcher.on('all', async (eventName, path, stats) => { + const isMdFile = isMarkdownFile(path) + + if (options.mdFilesOnly && !isMdFile) { + return + } + + promiseQueue.unshift(new Promise(resolve => { + const result = { + eventName, + path, + content: null as string | null, + stats: stats ? { + ...stats, + isFile: stats?.isFile(), + isDirectory: stats.isDirectory(), + } : undefined + } + + if (options.mdContent && isMdFile && (eventName === 'add' || eventName === 'change')) { + fs.readFile(path, 'utf-8').then(content => { + result.content = content + resolve(result) + }).catch(() => { + resolve(result) + }) + } else { + resolve(result) + } + })) + + triggerPromiseQueue() + }) + + watcher.on('ready', () => { + promiseQueue.unshift(Promise.resolve({ eventName: 'ready' })) + triggerPromiseQueue() + }) + + watcher.on('error', err => { + console.error(`watch process ${id} >`, filePath, 'error', err) + enqueue('error', err) + }) + + function enqueue (type: string, data: any) { + process.send?.({ id, type: 'enqueue', payload: { type, data } } satisfies Message) + } + + function stop () { + console.log(`watch process ${id} >`, filePath, 'stop') + promiseQueue.length = 0 + watcher.close() + process.off('message', onMessage) + process.off('exit', stop) + } + + function onMessage (message: Message) { + if (message.id === id && message.type === 'stop') { + stop() + } + } + + process.on('message', onMessage) + process.on('exit', stop) +} + +process.on('message', (message: Message) => { + if (message.type === 'init') { + const { filePath, options } = message.payload! + init(message.id, filePath, options) + } +}) + +process.on('disconnect', () => { + console.log('Parent process exited, shutting down...') + process.exit() +}) diff --git a/src/main/shell.ts b/src/main/shell.ts index e3015d042..88d0d48a6 100644 --- a/src/main/shell.ts +++ b/src/main/shell.ts @@ -8,12 +8,12 @@ const CD_COMMAND_PREFIX = '--yank-note-run-command-cd--' const defaultShell = os.platform() === 'win32' ? 'cmd.exe' : (process.env.SHELL || 'bash') const getShell = () => { - const shell = config.get(configKey, defaultShell) + const shell = (config.get(configKey, defaultShell) || '').trim() || defaultShell - // 使用全路径,不然 appx 运行报找不到文件 - // TODO 这里可以使用更好的路径查找方式 + // use full path + // TODO better way. if (os.platform() === 'win32') { - if (shell.toLocaleLowerCase() === 'cmd.exe' || shell.toLocaleLowerCase() === 'wsl.exe') { + if (shell.toLowerCase() === 'cmd.exe' || shell.toLowerCase() === 'wsl.exe') { return `C:\\Windows\\System32\\${shell}` } } @@ -28,13 +28,18 @@ const transformCdCommand = (command: string) => { return `cd '${path.replace(/'/g, '\\\'')}'` } - // 使用 wsl 做 shell,需要先转换路径 - if (getShell().indexOf('wsl.exe') > -1) { + const shell = getShell() + // transform path for WSL shell. + if (shell.endsWith('wsl.exe')) { return `cd '${toWslPath(path).replace(/'/g, '\\\'')}'` } - // windows 下的切换目录 - return `cd /d "${path}"` + if (shell.endsWith('powershell.exe')) { + return `cd '${path}'\r\n` + } + + // change dir for Windows. + return `cd /d "${path}"\r` } export default { diff --git a/src/main/shortcut.ts b/src/main/shortcut.ts index 58569387e..0146391b7 100644 --- a/src/main/shortcut.ts +++ b/src/main/shortcut.ts @@ -1,35 +1,77 @@ import * as os from 'os' -import { globalShortcut, dialog } from 'electron' +import { dialog, globalShortcut } from 'electron' import { FLAG_DISABLE_SERVER } from './constant' +import { getAction, registerAction } from './action' +import config from './config' +import { getDefaultApplicationAccelerators } from '../share/misc' + const platform = os.platform() -type AcceleratorType = 'show-main-window' | 'open-in-browser' -export const getAccelerator = (type: AcceleratorType) => { - return { - 'show-main-window': platform === 'darwin' ? undefined : 'Super+N', - 'open-in-browser': 'Super+Shift+B' - }[type] +const accelerators = getDefaultApplicationAccelerators(platform) +type AcceleratorCommand = (typeof accelerators)[0]['command'] + +let currentCommands: {[key in AcceleratorCommand]?: () => void} + +export const getAccelerator = (command: AcceleratorCommand): string | undefined => { + const customKeybinding = config.get('keybindings', []) + .find((item: any) => item.type === 'application' && item.command === command) + + if (customKeybinding) { + const keys = customKeybinding.keys + + if (keys) { + return keys.replace(/(Arrow|Key|Digit)/ig, '') + .replace(/NumpadAdd/ig, 'numadd') + .replace(/NumpadSubtract/ig, 'numsub') + .replace(/NumpadMultiply/ig, 'nummult') + .replace(/NumpadDivide/ig, 'numdiv') + .replace(/NumpadDecimal/ig, 'numdec') + .replace(/Numpad/ig, 'num') + } else { + return undefined + } + } + + return accelerators.find(a => a.command === command)?.accelerator || undefined } -export const registerShortcut = (shortcuts: {[key in AcceleratorType]?: () => void}) => { +export const registerShortcut = (commands: typeof currentCommands, showAlert = false) => { + currentCommands = { ...commands } + if (FLAG_DISABLE_SERVER) { - delete shortcuts['open-in-browser'] + delete commands['open-in-browser'] } - (Object.keys(shortcuts) as AcceleratorType[]).forEach(key => { + globalShortcut.unregisterAll() + + ;(Object.keys(commands) as AcceleratorCommand[]).forEach(key => { const accelerator = getAccelerator(key) - if (!accelerator || !shortcuts[key]) { + if (!accelerator || !commands[key]) { return } - globalShortcut.register(accelerator, shortcuts[key]!) - - if (!globalShortcut.isRegistered(accelerator)) { - dialog.showMessageBox({ - type: 'error', - title: 'Yank Note 注册快捷键失败', - message: `[${accelerator}] 快捷键有冲突`, - }) + try { + console.log('register shortcut', accelerator, key) + globalShortcut.register(accelerator, commands[key]!) + if (!globalShortcut.isRegistered(accelerator)) { + throw new Error('Failed to register shortcut') + } + } catch (error) { + console.error(error) + if (showAlert) { + dialog.showErrorBox('Error', `Failed to register shortcut: ${accelerator}`) + } } }) + + getAction('refresh-menus')() } + +function reload (changedKeys: string[]) { + if (changedKeys.includes('keybindings')) { + console.log('reload keybindings') + registerShortcut(currentCommands, true) + } +} + +registerAction('shortcuts.reload', reload) diff --git a/src/main/startup.ts b/src/main/startup.ts index 621df73f3..a67e49f99 100644 --- a/src/main/startup.ts +++ b/src/main/startup.ts @@ -1,15 +1,10 @@ -import * as fs from 'fs' +import * as fs from 'fs-extra' import * as path from 'path' -import { USER_DIR, TRASH_DIR, USER_PLUGIN_DIR } from './constant' +import { USER_DIR, USER_PLUGIN_DIR, USER_THEME_DIR, RESOURCES_DIR, BUILD_IN_STYLES, PANDOC_REFERENCE_FILE, HISTORY_DIR, USER_EXTENSION_DIR } from './constant' import './updater' export default function () { - if (!fs.existsSync(USER_DIR)) { - fs.mkdirSync(USER_DIR) - } - if (!fs.existsSync(TRASH_DIR)) { - fs.mkdirSync(TRASH_DIR) - } + fs.ensureDirSync(USER_DIR) if (!fs.existsSync(USER_PLUGIN_DIR)) { fs.mkdirSync(USER_PLUGIN_DIR) fs.writeFileSync(path.join(USER_PLUGIN_DIR, 'plugin-example.js'), ` @@ -25,4 +20,21 @@ window.registerPlugin({ }); `.trim()) } + + fs.ensureDirSync(USER_THEME_DIR) + fs.ensureDirSync(HISTORY_DIR) + fs.ensureDirSync(USER_EXTENSION_DIR) + + BUILD_IN_STYLES.forEach(style => { + fs.writeFileSync( + path.join(USER_THEME_DIR, style), + fs.readFileSync(path.join(RESOURCES_DIR, style)) + ) + }) + + const docxTplPath = path.join(USER_DIR, PANDOC_REFERENCE_FILE) + if (!fs.existsSync(docxTplPath)) { + fs.createReadStream(path.join(RESOURCES_DIR, PANDOC_REFERENCE_FILE)) + .pipe(fs.createWriteStream(docxTplPath)) + } } diff --git a/src/main/updater.ts b/src/main/updater.ts index b135411c1..f265cfcd8 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,41 +1,127 @@ -import { dialog, app } from 'electron' -import { autoUpdater, CancellationToken } from 'electron-updater' +import type { RequestOptions } from 'http' +import { dialog, app, shell } from 'electron' +import { dirname } from 'path' +import { readdirSync } from 'fs' +import { autoUpdater, CancellationToken, UpdateInfo } from 'electron-updater' +import { resolveFiles } from 'electron-updater/out/providers/Provider' +import { GitHubProvider } from 'electron-updater/out/providers/GitHubProvider' import logger from 'electron-log' import ProgressBar from 'electron-progressbar' -import opn from 'opn' +import { HOMEPAGE_URL } from '../share/misc' import store from './storage' +import { GITHUB_URL } from './constant' +import { $t } from './i18n' +import { registerAction } from './action' +import config from './config' + +type Source = 'auto' | 'github' | 'yank-note' logger.transports.file.level = 'info' autoUpdater.logger = logger let progressBar: any = null -// 否是从微软应用商店安装,简单的判断路径中是否包含 WindowsApps const isAppx = app.getAppPath().indexOf('\\WindowsApps\\') > -1 -const disabled = isAppx || process.mas +let disabled = isAppx || (process as any).mas + +class UpdateProvider extends GitHubProvider { + constructor (options: any, updater: any, runtimeOptions: any) { + super(options, updater, runtimeOptions) + + const request = this.executor.request.bind(this.executor) + this.executor.request = (options: RequestOptions, ...args: any[]) => { + if (!this.isGithub()) { + const _url = new URL(HOMEPAGE_URL) + if (options.path === '/purocean/yn/releases.atom') { + options.hostname = _url.hostname + options.path = '/api/update-info/releases.atom' + } else if (options.path === '/purocean/yn/releases/latest') { + options.hostname = _url.hostname + options.path = '/api/update-info/latest' + } else if (options.path?.startsWith('/purocean/yn/releases/download')) { + options.hostname = _url.hostname + options.path = options.path.replace(/\/purocean\/yn\/releases\/download\/v[^/]+\//, '/download/') + } + + console.log('request', options.protocol + '//' + options.hostname + options.path) + } + + return request(options, ...args) + } + } + + private getSource (): Exclude { + let source: Source = config.get('updater.source', 'auto') + + if (source !== 'github' && source !== 'yank-note') { + source = 'auto' + } + + if (source === 'auto') { + if (app.getLocale().toLowerCase().includes('zh-cn')) { + source = 'yank-note' + } else { + source = 'github' + } + } + + return source + } + + private isGithub () { + return this.getSource() === 'github' + } -const init = (call: () => void) => { + resolveFiles (updateInfo: UpdateInfo): ReturnType { + if (this.isGithub()) { + return super.resolveFiles(updateInfo as any) + } + + const baseUrl = new URL(HOMEPAGE_URL) + + // still replace space to - due to backward compatibility + return resolveFiles(updateInfo, baseUrl, p => '/download/' + p.replace(/ /g, '-')) + } +} + +const init = (call?: () => void) => { if (disabled) { return } - autoUpdater.setFeedURL({ provider: 'github', owner: 'purocean', repo: 'yn' }) + if (process.platform === 'win32') { + const fileList = readdirSync(dirname(app.getPath('exe'))) + const hasUninstall = fileList.some(x => x.includes('Uninstall')) + if (!hasUninstall) { + disabled = true + console.log('updater >', 'No uninstaller found, updater disabled') + return + } + } + + autoUpdater.setFeedURL({ provider: 'custom', owner: 'purocean', repo: 'yn', updateProvider: UpdateProvider as any }) autoUpdater.autoDownload = false autoUpdater.on('update-available', async info => { const { response } = await dialog.showMessageBox({ cancelId: 999, type: 'question', - buttons: ['下载更新', '查看更新内容', '取消', '不再提醒'], - title: 'Yank Note 发现新版本', - message: `当前版本 ${app.getVersion()}\n最新版本:${info.version}` + title: $t('app.updater.found-dialog.title'), + message: $t('app.updater.found-dialog.desc', app.getVersion(), info.version), + buttons: [ + $t('app.updater.found-dialog.buttons.download'), + $t('app.updater.found-dialog.buttons.view-changes'), + $t('app.updater.found-dialog.buttons.download-and-view-changes'), + $t('app.updater.found-dialog.buttons.cancel'), + $t('app.updater.found-dialog.buttons.ignore') + ], }) - if (response === 0) { + if (response === 0 || response === 2) { progressBar = new ProgressBar({ - title: 'Yank Note 下载更新', + title: $t('app.updater.progress-bar.title'), text: `${info.version}`, - detail: '正在下载新版本 ', + detail: $t('app.updater.progress-bar.detail', ''), indeterminate: false, closeOnComplete: false, browserWindow: { @@ -52,7 +138,7 @@ const init = (call: () => void) => { if (e.message !== 'Cancelled') { dialog.showMessageBox({ type: 'info', - title: 'Yank Note 出现一点错误', + title: $t('app.updater.failed-dialog.title'), message: e.message, }) } @@ -62,16 +148,20 @@ const init = (call: () => void) => { console.log('cancel download') cancellationToken.cancel() }) - } else if (response === 1) { - opn('https://github.com/purocean/yn#%E6%9B%B4%E6%96%B0%E6%97%A5%E5%BF%97') - } else if (response === 3) { + } + + if (response === 1 || response === 2) { + shell.openExternal(GITHUB_URL + '/releases') + } + + if (response === 4) { store.set('dontCheckUpdates', true) } }) autoUpdater.on('error', e => { try { - progressBar && (progressBar.detail = '下载失败: ' + e) + progressBar && (progressBar.detail = $t('app.updater.progress-bar.failed', e.toString())) } catch (error) { console.error(error) } @@ -80,7 +170,7 @@ const init = (call: () => void) => { autoUpdater.on('download-progress', e => { if (progressBar) { progressBar.value = e.percent - progressBar.detail = '下载中…… ' + e.percent.toFixed(2) + '%' + progressBar.detail = $t('app.updater.progress-bar.detail', e.percent.toFixed(2) + '%') } }) @@ -90,15 +180,18 @@ const init = (call: () => void) => { dialog.showMessageBox({ cancelId: 999, type: 'question', - title: 'Yank Note 下载完成', - buttons: ['立即安装', '推迟'], + title: $t('app.updater.install-dialog.title'), + message: $t('app.updater.install-dialog.desc'), + buttons: [ + $t('app.updater.install-dialog.buttons.install'), + $t('app.updater.install-dialog.buttons.delay') + ], defaultId: 0, - message: '新版本下载完成,是否要立即安装?' }).then(result => { if (result.response === 0) { setTimeout(() => { autoUpdater.quitAndInstall() - call() + call?.() }, 500) } }) @@ -114,8 +207,8 @@ export function checkForUpdates () { autoUpdater.once('update-not-available', () => { dialog.showMessageBox({ type: 'info', - title: 'Yank Note 无新版本', - message: '当前已经是最新版本', + title: $t('app.updater.no-newer-dialog.title'), + message: $t('app.updater.no-newer-dialog.desc'), }) }) @@ -132,15 +225,20 @@ export function autoCheckForUpdates () { } } +export function changeSource () { + autoUpdater.checkForUpdates() +} + app.whenReady().then(() => { init(() => { - // 立即升级,退出程序 setTimeout(() => { app.exit(0) - }, process.platform === 'darwin' ? 3500 : 0) + }, process.platform === 'darwin' ? 8000 : 0) }) setTimeout(() => { autoCheckForUpdates() }, 1000) }) + +registerAction('updater.change-source', changeSource) diff --git a/src/renderer/App.vue b/src/renderer/App.vue deleted file mode 100644 index 98240aef8..000000000 --- a/src/renderer/App.vue +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/src/renderer/Main.vue b/src/renderer/Main.vue new file mode 100644 index 000000000..8a46bbaa7 --- /dev/null +++ b/src/renderer/Main.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/src/renderer/__tests__/core/hook.ts b/src/renderer/__tests__/core/hook.ts new file mode 100644 index 000000000..6522f1899 --- /dev/null +++ b/src/renderer/__tests__/core/hook.ts @@ -0,0 +1,117 @@ +import * as hook from '@fe/core/hook' +import * as ioc from '@fe/core/ioc' + +jest.mock('@fe/support/args', () => ({ + FLAG_DEMO: false, +})) + +jest.mock('@fe/utils', () => ({ + getLogger: () => new Proxy({}, { get: () => () => 0 }) +})) + +afterEach(() => { + ioc.removeAll('ACTION_AFTER_RUN') +}) + +test('hook usage 1', async () => { + const fn1 = jest.fn() + hook.registerHook('ACTION_AFTER_RUN', fn1) + const res = await hook.triggerHook('ACTION_AFTER_RUN', { name: 'test' }) + expect(fn1).toHaveBeenCalledWith({ name: 'test' }) + expect(res).toBe(undefined) +}) + +test('hook usage 2', async () => { + const fn1 = jest.fn().mockReturnValue(true) + const fn2 = jest.fn().mockReturnValue(true) + hook.registerHook('ACTION_AFTER_RUN', fn1) + hook.registerHook('ACTION_AFTER_RUN', fn2) + hook.triggerHook('ACTION_AFTER_RUN', { name: 'test' }) + expect(fn1).toHaveBeenCalledWith({ name: 'test' }) + expect(fn2).toHaveBeenCalledWith({ name: 'test' }) +}) + +test('hook usage 3', async () => { + const fn1 = jest.fn().mockReturnValue(true) + const fn2 = jest.fn().mockReturnValue(true) + hook.registerHook('ACTION_AFTER_RUN', fn1) + hook.registerHook('ACTION_AFTER_RUN', fn2) + const res = await hook.triggerHook('ACTION_AFTER_RUN', { name: 'test' }, { breakable: true }) + expect(res).toBe(true) + expect(fn1).toHaveBeenCalledWith({ name: 'test' }) + expect(fn2).toBeCalledTimes(0) +}) + +test('hook usage 4', async () => { + const fn1 = jest.fn().mockReturnValue(Promise.resolve(true)) + const fn2 = jest.fn().mockReturnValue(true) + hook.registerHook('ACTION_AFTER_RUN', fn1) + hook.registerHook('ACTION_AFTER_RUN', fn2) + const res = await hook.triggerHook('ACTION_AFTER_RUN', { name: 'test' }, { breakable: true }) + expect(res).toBe(true) + expect(fn1).toHaveBeenCalledWith({ name: 'test' }) + expect(fn2).toBeCalledTimes(0) +}) + +test('hook usage 5', async () => { + const fn1 = jest.fn().mockReturnValue(false) + const fn2 = jest.fn().mockReturnValue(true) + hook.registerHook('ACTION_AFTER_RUN', fn1) + hook.registerHook('ACTION_AFTER_RUN', fn2) + const res = await hook.triggerHook('ACTION_AFTER_RUN', { name: 'test' }, { breakable: true }) + expect(res).toBe(true) + expect(fn1).toHaveBeenCalledWith({ name: 'test' }) + expect(fn2).toHaveBeenCalledWith({ name: 'test' }) +}) + +test('hook usage 6', async () => { + const fn1 = jest.fn().mockReturnValue(Promise.resolve(false)) + const fn2 = jest.fn().mockReturnValue(true) + hook.registerHook('ACTION_AFTER_RUN', fn1) + hook.registerHook('ACTION_AFTER_RUN', fn2) + const res = await hook.triggerHook('ACTION_AFTER_RUN', { name: 'test' }, { breakable: true }) + expect(res).toBe(true) + expect(fn1).toHaveBeenCalledWith({ name: 'test' }) + expect(fn2).toHaveBeenCalledWith({ name: 'test' }) +}) + +test('hook usage 7', async () => { + const fn1 = jest.fn().mockReturnValue(Promise.resolve(false)) + const fn2 = jest.fn().mockReturnValue(false) + hook.registerHook('ACTION_AFTER_RUN', fn1) + hook.registerHook('ACTION_AFTER_RUN', fn2) + const res = await hook.triggerHook('ACTION_AFTER_RUN', { name: 'test' }, { breakable: true }) + expect(res).toBe(false) + expect(fn1).toHaveBeenCalledWith({ name: 'test' }) + expect(fn2).toHaveBeenCalledWith({ name: 'test' }) +}) + +test('hook usage 8', async () => { + const fn1 = jest.fn() + hook.registerHook('ACTION_AFTER_RUN', fn1) + await hook.triggerHook('ACTION_AFTER_RUN', { name: 'test' }) + await hook.triggerHook('ACTION_AFTER_RUN', { name: 'test' }) + expect(fn1).toBeCalledTimes(2) +}) + +test('hook usage 8', async () => { + const fn1: any = jest.fn() + hook.registerHook('ACTION_BEFORE_RUN', fn1) + hook.registerHook('ACTION_AFTER_RUN', fn1, true) + await hook.triggerHook('ACTION_AFTER_RUN', { name: 'test' }) + await hook.triggerHook('ACTION_AFTER_RUN', { name: 'test' }) + await hook.triggerHook('ACTION_BEFORE_RUN', { name: 'test' }) + await hook.triggerHook('ACTION_BEFORE_RUN', { name: 'test' }) + expect(fn1).toBeCalledTimes(3) +}) + +test('hook usage 9', async () => { + const fn1 = () => { throw new Error('test') } + const fn2 = () => Promise.reject(new Error('test')) + hook.registerHook('ACTION_BEFORE_RUN', fn1) + hook.registerHook('ACTION_AFTER_RUN', fn2) + await hook.triggerHook('ACTION_BEFORE_RUN', { name: 'test' }, { ignoreError: true }) + await hook.triggerHook('ACTION_AFTER_RUN', { name: 'test' }, { breakable: true, ignoreError: true }) + await expect(hook.triggerHook('ACTION_BEFORE_RUN', { name: 'test' })).rejects.toThrow() + await expect(hook.triggerHook('ACTION_AFTER_RUN', { name: 'test' }, { breakable: true })).rejects.toThrow() +}) diff --git a/src/renderer/__tests__/core/ioc.ts b/src/renderer/__tests__/core/ioc.ts new file mode 100644 index 000000000..8d1a9d326 --- /dev/null +++ b/src/renderer/__tests__/core/ioc.ts @@ -0,0 +1,72 @@ +import * as ioc from '@fe/core/ioc' + +test('ioc usage', () => { + expect(ioc.get('ACTION_AFTER_RUN')).toEqual([]) + ioc.remove('ACTION_AFTER_RUN', null) + ioc.register('ACTION_AFTER_RUN', 'test') + ioc.register('ACTION_AFTER_RUN', 'test1') + expect(ioc.get('ACTION_AFTER_RUN')).toEqual(['test', 'test1']) + ioc.remove('ACTION_AFTER_RUN', 'test2') + expect(ioc.get('ACTION_AFTER_RUN')).toEqual(['test', 'test1']) + ioc.remove('ACTION_AFTER_RUN', 'test') + expect(ioc.get('ACTION_AFTER_RUN')).toEqual(['test1']) + ioc.removeAll('ACTION_AFTER_RUN') + + ioc.register('ACTION_AFTER_RUN', 'test') + ioc.register('ACTION_AFTER_RUN', 'test1') + ioc.register('ACTION_AFTER_RUN', 'test2') + ioc.register('ACTION_AFTER_RUN', 'test3') + ioc.register('ACTION_AFTER_RUN', 'test4') + + ioc.removeWhen('ACTION_AFTER_RUN', item => item === 'test' || item === 'test3') + expect(ioc.get('ACTION_AFTER_RUN')).toEqual(['test1', 'test2', 'test4']) + + ioc.removeAll('ACTION_AFTER_RUN') + ioc.register('ACTION_AFTER_RUN', 'test') + ioc.register('ACTION_AFTER_RUN', 'test1') + ioc.register('ACTION_AFTER_RUN', 'test2') + ioc.register('ACTION_AFTER_RUN', 'test3') + ioc.register('ACTION_AFTER_RUN', 'test4') + + for (const item of ioc.get('ACTION_AFTER_RUN')) { + ioc.remove('ACTION_AFTER_RUN', item) + } + + expect(ioc.get('ACTION_AFTER_RUN')).toEqual([]) + + // test getRaw + expect(ioc.getRaw('ACTION_AFTER_RUN_XXX' as any) === undefined) + ioc.register('ACTION_AFTER_RUN', 'test1') + const content1 = ioc.get('ACTION_AFTER_RUN') + const raw1 = ioc.getRaw('ACTION_AFTER_RUN') + ioc.register('ACTION_AFTER_RUN', 'test2') + const content2 = ioc.get('ACTION_AFTER_RUN') + const raw2 = ioc.getRaw('ACTION_AFTER_RUN') + expect(content1 === content2).toBe(false) + expect(raw1 === raw2).toBe(true) + expect(content1).toEqual(['test1']) + expect(content2).toEqual(['test1', 'test2']) + expect([...raw1!]).toEqual(['test1', 'test2']) + expect([...raw2!]).toEqual(['test1', 'test2']) + ioc.removeAll('ACTION_AFTER_RUN') + expect(raw1 === raw2).toBe(true) + expect(raw1?.length).toEqual(0) + + // test version + expect(ioc.getRaw('ACTION_BEFORE_RUN') === undefined).toBe(true) + ioc.register('ACTION_BEFORE_RUN', 'test1') + expect(ioc.getRaw('ACTION_BEFORE_RUN')?._version).toBe(1) + ioc.register('ACTION_BEFORE_RUN', 'test2') + expect(ioc.getRaw('ACTION_BEFORE_RUN')?._version).toBe(2) + ioc.register('ACTION_BEFORE_RUN', 'test3') + expect(ioc.getRaw('ACTION_BEFORE_RUN')?._version).toBe(3) + ioc.remove('ACTION_BEFORE_RUN', 'test1') + expect(ioc.getRaw('ACTION_BEFORE_RUN')?._version).toBe(4) + ioc.removeWhen('ACTION_BEFORE_RUN', item => item === 'test2') + expect(ioc.getRaw('ACTION_BEFORE_RUN')?._version).toBe(5) + ioc.removeAll('ACTION_BEFORE_RUN') + expect(ioc.getRaw('ACTION_BEFORE_RUN')?._version).toBe(6) + ioc.register('ACTION_BEFORE_RUN', 'test1') + expect(ioc.getRaw('ACTION_BEFORE_RUN')?._version).toBe(7) + expect(ioc.getRaw('ACTION_BEFORE_RUN')?._version === undefined).toBe(false) +}) diff --git a/src/renderer/__tests__/core/plugin.ts b/src/renderer/__tests__/core/plugin.ts new file mode 100644 index 000000000..2d34219dd --- /dev/null +++ b/src/renderer/__tests__/core/plugin.ts @@ -0,0 +1,29 @@ +import * as plugin from '@fe/core/plugin' + +jest.mock('@fe/utils', () => ({ + getLogger: () => new Proxy({}, { get: () => () => 0 }) +})) + +test('plugin usage', () => { + const fn = jest.fn() + plugin.register({ + name: 'test', + register: fn, + }, 123) + + expect(fn).toHaveBeenCalledWith(123) + + plugin.register({ + name: 'test', + register: fn, + }, 123) + + expect(fn).toHaveBeenCalledTimes(1) + + plugin.register({ + name: 'test2', + register: () => '12345', + }, 123) + + expect(plugin.getApi('test2')).toBe('12345') +}) diff --git a/src/renderer/__tests__/others/extension.ts b/src/renderer/__tests__/others/extension.ts new file mode 100644 index 000000000..89c8e15a0 --- /dev/null +++ b/src/renderer/__tests__/others/extension.ts @@ -0,0 +1,184 @@ +import * as extension from '@fe/others/extension' + +jest.mock('@fe/support/api', () => ({})) +jest.mock('@fe/services/theme', () => ({})) +jest.mock('@fe/services/view', () => ({})) +jest.mock('js-untar', () => ({})) +jest.mock('@fe/support/args', () => ({ + FLAG_DEMO: false, +})) + +jest.mock('@fe/utils', () => ({ + getLogger: console.log +})) + +jest.mock('@fe/services/i18n', () => ({ + getCurrentLanguage: () => 'zh-CN' +})) + +jest.mock('@fe/core/action', () => ({ + getActionHandler: () => () => 0 +})) + +;(global as any).__APP_VERSION__ = '3.29.0' + +test('readInfoFromJson', () => { + expect(extension.readInfoFromJson(undefined)).toBeNull() + expect(extension.readInfoFromJson({})).toBeNull() + expect(extension.readInfoFromJson({ name: 'test' })).toBeNull() + + expect(extension.readInfoFromJson({ name: 'test', version: '1.1.2' })).toStrictEqual({ + id: 'test', + author: { name: '' }, + displayName: 'test', + main: '', + style: '', + description: '', + version: '1.1.2', + themes: [], + origin: 'unknown', + dist: { tarball: '', unpackedSize: 0 }, + icon: '', + homepage: '', + license: '', + readmeUrl: '', + changelogUrl: '', + compatible: { + reason: 'Not yank note extension.', + value: false, + }, + requirements: {}, + }) + + expect(extension.readInfoFromJson({ + name: 'test', + version: '1.1.2', + license: 'MIT', + engines: { + 'yank-note': '>=3.29.0', + }, + })).toStrictEqual({ + id: 'test', + author: { name: '' }, + displayName: 'test', + main: '', + style: '', + description: '', + version: '1.1.2', + themes: [], + origin: 'unknown', + dist: { tarball: '', unpackedSize: 0 }, + icon: '', + homepage: '', + license: 'MIT', + compatible: { + reason: 'Compatible', + value: true, + }, + readmeUrl: '', + changelogUrl: '', + requirements: {}, + }) + + expect(extension.readInfoFromJson({ + name: 'test', + version: '1.1.2', + engines: { + 'yank-note': '>=3.30.0', + }, + requirements: { premium: true, terminal: false } + })).toStrictEqual({ + id: 'test', + author: { name: '' }, + displayName: 'test', + main: '', + style: '', + description: '', + version: '1.1.2', + themes: [], + origin: 'unknown', + dist: { tarball: '', unpackedSize: 0 }, + icon: '', + homepage: '', + license: '', + requirements: { premium: true, terminal: false }, + compatible: { + reason: 'Need Yank Note [>=3.30.0].', + value: false, + }, + readmeUrl: '', + changelogUrl: '', + }) + + expect(extension.readInfoFromJson({ + name: 'test', + author: 'test ', + version: '1.1.2', + description: 'HELLO!', + displayName: 'HELLO', + })).toStrictEqual({ + id: 'test', + author: { name: 'test', email: 'test@t.t' }, + main: '', + style: '', + displayName: 'HELLO', + description: 'HELLO!', + version: '1.1.2', + themes: [], + origin: 'unknown', + dist: { tarball: '', unpackedSize: 0 }, + icon: '', + homepage: '', + license: '', + compatible: { + reason: 'Not yank note extension.', + value: false, + }, + readmeUrl: '', + changelogUrl: '', + requirements: {}, + }) + + expect(extension.readInfoFromJson({ + name: 'test', + version: '1.1.2', + author: { name: 'hello', email: 'xxx@email.com' }, + description: 'HELLO!', + displayName: 'HELLO', + 'description_ZH-CN': '你好!', + 'displayName_ZH-CN': '你好', + main: 'test.js', + style: 'test.css', + themes: [ + { name: 'a', css: './a.css' }, + { name: 'b', css: './b.css' }, + ], + readmeUrl: 'readmeUrl', + changelogUrl: 'changelogUrl', + origin: 'official', + })).toStrictEqual({ + id: 'test', + author: { name: 'hello', email: 'xxx@email.com' }, + main: 'test.js', + style: 'test.css', + displayName: '你好', + description: '你好!', + version: '1.1.2', + themes: [ + { name: 'a', css: './a.css' }, + { name: 'b', css: './b.css' }, + ], + origin: 'official', + dist: { tarball: '', unpackedSize: 0 }, + icon: '', + homepage: '', + license: '', + compatible: { + reason: 'Not yank note extension.', + value: false, + }, + readmeUrl: 'readmeUrl', + changelogUrl: 'changelogUrl', + requirements: {}, + }) +}) diff --git a/src/renderer/__tests__/services/status-bar.ts b/src/renderer/__tests__/services/status-bar.ts new file mode 100644 index 000000000..1585f1343 --- /dev/null +++ b/src/renderer/__tests__/services/status-bar.ts @@ -0,0 +1,42 @@ +import * as ioc from '@fe/core/ioc' +import * as statusBar from '@fe/services/status-bar' + +jest.mock('lodash-es', () => ({ + debounce: (fn: any) => () => fn() +})) + +jest.mock('@fe/core/action', () => ({ + getActionHandler: (name: string) => () => { + if (name !== 'status-bar.refresh-menu') { + throw Error('action name error') + } + } +})) + +afterEach(() => { + ioc.removeAll('STATUS_BAR_MENU_TAPPERS') +}) + +test('refresh menu', () => { + statusBar.refreshMenu() +}) + +test('menu', () => { + statusBar.tapMenus(menus => { + menus.test1 = { id: 'test1', position: 'left' } as statusBar.Menu + }) + + statusBar.tapMenus(menus => { + menus.test2 = { id: 'test2', position: 'right' } as statusBar.Menu + }) + + statusBar.tapMenus(menus => { + menus.test3 = { id: 'test3', position: 'right' } as statusBar.Menu + }) + + const menus = statusBar.getMenus('right') + expect(menus).toStrictEqual([ + { id: 'test2', position: 'right' }, + { id: 'test3', position: 'right' }, + ]) +}) diff --git a/src/renderer/assets/qrcode-wechat.jpg b/src/renderer/assets/qrcode-wechat.jpg new file mode 100644 index 000000000..d3e24bff5 Binary files /dev/null and b/src/renderer/assets/qrcode-wechat.jpg differ diff --git a/src/renderer/components/ActionBar.vue b/src/renderer/components/ActionBar.vue new file mode 100644 index 000000000..53fb34c3e --- /dev/null +++ b/src/renderer/components/ActionBar.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/src/renderer/components/ContextMenu.vue b/src/renderer/components/ContextMenu.vue index 78585265e..2db8650ec 100644 --- a/src/renderer/components/ContextMenu.vue +++ b/src/renderer/components/ContextMenu.vue @@ -1,41 +1,52 @@ - diff --git a/src/renderer/components/ControlCenter.vue b/src/renderer/components/ControlCenter.vue new file mode 100644 index 000000000..9abe5441c --- /dev/null +++ b/src/renderer/components/ControlCenter.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/src/renderer/components/CreateFilePanel.vue b/src/renderer/components/CreateFilePanel.vue new file mode 100644 index 000000000..55f1bb28e --- /dev/null +++ b/src/renderer/components/CreateFilePanel.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/src/renderer/components/DefaultEditor.vue b/src/renderer/components/DefaultEditor.vue new file mode 100644 index 000000000..cd2ecf0d5 --- /dev/null +++ b/src/renderer/components/DefaultEditor.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/src/renderer/components/DefaultPreviewer.vue b/src/renderer/components/DefaultPreviewer.vue new file mode 100644 index 000000000..0d5a2e1e9 --- /dev/null +++ b/src/renderer/components/DefaultPreviewer.vue @@ -0,0 +1,465 @@ + + + + + diff --git a/src/renderer/components/DefaultPreviewerRender.ce.vue b/src/renderer/components/DefaultPreviewerRender.ce.vue new file mode 100644 index 000000000..cf7434705 --- /dev/null +++ b/src/renderer/components/DefaultPreviewerRender.ce.vue @@ -0,0 +1,540 @@ + + + + + + + diff --git a/src/renderer/components/DocHistory.vue b/src/renderer/components/DocHistory.vue new file mode 100644 index 000000000..031eb5861 --- /dev/null +++ b/src/renderer/components/DocHistory.vue @@ -0,0 +1,596 @@ + + + + + diff --git a/src/renderer/components/Editor.vue b/src/renderer/components/Editor.vue index 31d4cdda0..a554b623f 100644 --- a/src/renderer/components/Editor.vue +++ b/src/renderer/components/Editor.vue @@ -1,167 +1,138 @@ - - - diff --git a/src/renderer/components/ExportPanel.vue b/src/renderer/components/ExportPanel.vue new file mode 100644 index 000000000..2e18688c3 --- /dev/null +++ b/src/renderer/components/ExportPanel.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/src/renderer/components/ExtensionManager.vue b/src/renderer/components/ExtensionManager.vue new file mode 100644 index 000000000..bfcdd7955 --- /dev/null +++ b/src/renderer/components/ExtensionManager.vue @@ -0,0 +1,1019 @@ +