diff --git a/.github/workflows/release.yml b/.forgejo/workflows/build.yml similarity index 60% rename from .github/workflows/release.yml rename to .forgejo/workflows/build.yml index 49834e5..4bfcc57 100644 --- a/.github/workflows/release.yml +++ b/.forgejo/workflows/build.yml @@ -1,7 +1,9 @@ -name: Create Release on Tag Push +name: Build on Push and create Release on Tag on: push: + branches: + - main tags: - "v*" @@ -20,7 +22,7 @@ jobs: node-version: 20 registry-url: "https://registry.npmjs.org" - # Install pnpm + # Install pnpm - name: Install pnpm uses: pnpm/action-setup@v4 id: pnpm-install @@ -28,6 +30,12 @@ jobs: version: 8 run_install: false + # Validate Tag Matches JSON Versions + - name: Validate Tag Matches JSON Versions + if: github.ref_type == 'tag' + run: | + node scripts/validate_tag.cjs ${{ github.ref }} + # Get pnpm store directory - name: Get pnpm store directory id: pnpm-cache @@ -52,11 +60,22 @@ jobs: - name: Build for production run: pnpm build - - name: Release - uses: ncipollo/release-action@v1 + # Move file + - name: Move file + run: mkdir built; mv package.zip built/package.zip + + # Upload artifacts + - name: Upload artifacts + uses: actions/upload-artifact@v3 with: - allowUpdates: true - artifactErrorsFailBuild: true - artifacts: "package.zip" - token: ${{ secrets.GITHUB_TOKEN }} - prerelease: false + path: built/package.zip + overwrite: true + + # Create Forgejo Release + - name: Create Forgejo Release + if: github.ref_type == 'tag' + uses: actions/forgejo-release@v1 + with: + direction: upload + release-dir: built + token: ${{ secrets.FORGE_TOKEN }} diff --git a/README.md b/README.md index 2cc772c..5eddf38 100644 --- a/README.md +++ b/README.md @@ -1,282 +1,50 @@ -# SiYuan plugin sample with vite and svelte +# 📝 SySpell - Grammar and Spell Checker for SiYuan -[中文版](./README_zh_CN.md) +This plugin adds a fully featured grammar and spell checker for SiYuan, powered by [LanguageTool](https://languagetool.org/). -> Consistent with [siyuan/plugin-sample](https://github.com/siyuan-note/plugin-sample) [v0.4.1](https://github.com/siyuan-note/plugin-sample/tree/v0.4.1) +## 📃 Usage instructions +1. Install the plugin from the SiYuan plugin store and enable it +2. (Optional) Visit the plugin settings to configure it for your needs +3. Words and phrases which are recognized as wrong will be underlined in red +4. Right-click over an underlined word (or click the Menu keyboard button), hover on "Plugin", then... + - Click the error message to get a detailed explanation of the error + - Click "Add to dictionary" to permanently ignore the error + - Click any of the corrections to apply them +## ☑️ Features +- [x] Spell checker +- [x] Grammar checker like Grammarly +- [x] Free and open-source +- [x] [Self-hostable](https://dev.languagetool.org/http-server) grammar checking server +- [x] Underlines are not edited into your notes +
+ Why does this matter? + The plugin's underlines are not rendered by altering the content of your note, but as an overlay. + This way, when exporting notes from SiYuan to HTML or Markdown, the underlines aren't shown, and they don't interfere with your writing. + It's just like how the [Grammarly](https://www.grammarly.com/blog/engineering/making-grammarly-feel-native-on-every-website/) web extensions works! -1. Using vite for packaging -2. Use symbolic linking instead of putting the project into the plugins directory program development -3. Built-in support for the svelte framework +
- > **If don't want svelte, turn to this template**: [frostime/plugin-sample-vite](https://github.com/frostime/plugin-sample-vite) - > - > **We also provide with a vite+solidjs template**: [frostime/plugin-sample-vite-solidjs](https://github.com/frostime/plugin-sample-vite-solidjs) +Check out the [Projects](https://git.massive.box/massivebox/siyuan-spellchecker/projects) for the planned features! -4. Provides a github action template to automatically generate package.zip and upload to new release +## 🛠 Contributing +I'm respecting LanguageTool's guidelines by running a LanguageTool server, on my own hardware, for the benefit of this +plugin's users, instead of having them use the official one. +I'm providing this service for free, but it's not free for me to run it: the LanguageTool server is very resource intensive. +If you can, consider [donating](https://s.massive.box/siyuan-plugin-donate) to help me keep it up forever. Thanks! +You can also contribute by opening an issue or a pull request. Thanks! -> [!NOTE] -> The current template case is based on `svelte4` version, we maintain an experimental template in the `svelte5` branch, which upgrades Svelte to 5.x version. -> -> In the future, we will switch the default main branch to svelte5. +## 🤗 Thanks to +This project couldn't have been possible without (in no particular order): +- The [SiYuan](https://github.com/siyuan-note/siyuan) project +- [SiYuan plugin sample with vite and svelte](https://github.com/siyuan-note/plugin-sample-vite-svelte) +- [LanguageTool](https://languagetool.org/) -## Get started - -1. Use the Use this template button to make a copy of this repo as a template. Note that the repository name should match the plugin name, and the default branch must be `main`. -2. Clone your repository to the local development folder. - * Note: Unlike `plugin-sample`, this example does not recommend directly downloading the code to `{workspace}/data/plugins/`. -3. Install [NodeJS](https://nodejs.org/en/download) and [pnpm](https://pnpm.io/installation), then run `pnpm i` in the development folder to install the required dependencies. -4. Run the `pnpm run make-link` command to create a symbolic link (Windows developers, please refer to the "make-link on Windows" section below). -5. Execute `pnpm run dev` for real-time compilation. -6. Open the marketplace in SiYuan and enable the plugin in the download tab. - -> [!TIP] -> You can also use our maintained [siyuan-plugin-cli](https://www.npmjs.com/package/siyuan-plugin-cli) command-line tool to directly build plugins in your local terminal. -> -> Additionally, for the `make-link` related commands mentioned in this plugin, all future updates will be made in [siyuan-plugin-cli](https://www.npmjs.com/package/siyuan-plugin-cli). -> -> The built-in `make-link` scripts may also be removed in a future version, in favor of using the `siyuan-plugin-cli` tool, aiming to simplify the workload of maintaining multiple plugin templates. - -### Setting the Target Directory for the make-link Command - -The `make-link` command creates a symbolic link that binds your `dev` directory to the SiYuan plugin directory. You can configure the target SiYuan workspace and create the symbolic link in three ways: - -1. **Select Workspace** - - Open SiYuan, ensure the SiYuan kernel is running. - - Run `pnpm run make-link`, the script will automatically detect all SiYuan workspaces, please manually enter the number to select the workspace. - ```bash - >>> pnpm run make-link - > plugin-sample-vite-svelte@0.0.3 make-link H:\SrcCode\开源项目\plugin-sample-vite-svelte - > node --no-warnings ./scripts/make_dev_link.js - - "targetDir" is empty, try to get SiYuan directory automatically.... - Got 2 SiYuan workspaces - [0] H:\Media\SiYuan - [1] H:\临时文件夹\SiYuanDevSpace - Please select a workspace[0-1]: 0 - Got target directory: H:\Media\SiYuan/data/plugins - Done! Created symlink H:\Media\SiYuan/data/plugins/plugin-sample-vite-svelte - ``` -2. **Manually Configure Target Directory** - - Open the `./scripts/make_dev_link.js` file, change `targetDir` to the SiYuan plugin directory `/data/plugins`. - - Run the `pnpm run make-link` command. If you see a message similar to the one below, it indicates successful creation: - -3. **Set Environment Variable to Create Symbolic Link** - - Set the system environment variable `SIYUAN_PLUGIN_DIR` to the path `workspace/data/plugins`. - -### make-link on Windows - -Due to SiYuan upgrading to Go 1.23, the old version of junction links cannot be recognized normally on Windows, so it has been changed to create `dir` symbolic links. - -> https://github.com/siyuan-note/siyuan/issues/12399 - -However, creating directory symbolic links on Windows using NodeJs may require administrator privileges. You have the following options: - -1. Run `pnpm run make-link` in a command line with administrator privileges. -2. Configure Windows settings, enable developer mode in [System Settings - Update & Security - Developer Mode] then run `pnpm run make-link`. -3. Run `pnpm run make-link-win`, this command will use a PowerShell script to request administrator privileges, requiring the system to enable PowerShell script execution permissions. - -## I18n - -In terms of internationalization, our main consideration is to support multiple languages. Specifically, we need to -complete the following tasks: - -* Meta information about the plugin itself, such as plugin description and readme - * `description` and `readme` fields in plugin.json, and the corresponding README*.md file -* Text used in the plugin, such as button text and tooltips - * public/i18n/*.json language configuration files - * Use `this.i18.key` to get the text in the code -* YAML Support - * This template specifically supports I18n based on YAML syntax, see `public/i18n/zh_CN.yaml` - * During compilation, the defined YAML files will be automatically translated into JSON files and placed in the dist or dev directory. - -It is recommended that the plugin supports at least English and Simplified Chinese, so that more people can use it more -conveniently. - -## plugin.json - -```json -{ - "name": "plugin-sample-vite-svelte", - "author": "frostime", - "url": "https://github.com/siyuan-note/plugin-sample-vite-svelte", - "version": "0.1.3", - "minAppVersion": "2.8.8", - "backends": ["windows", "linux", "darwin"], - "frontends": ["desktop"], - "displayName": { - "en_US": "Plugin sample with vite and svelte", - "zh_CN": "插件样例 vite + svelte 版" - }, - "description": { - "en_US": "SiYuan plugin sample with vite and svelte", - "zh_CN": "使用 vite 和 svelte 开发的思源插件样例" - }, - "readme": { - "en_US": "README_en_US.md", - "zh_CN": "README.md" - }, - "funding": { - "openCollective": "", - "patreon": "", - "github": "", - "custom": [ - "https://ld246.com/sponsor" - ] - }, - "keywords": [ - "sample", "示例" - ] -} -``` - -* `name`: Plugin name, must be the same as the repo name, and must be unique globally (no duplicate plugin names in the - marketplace) -* `author`: Plugin author name -* `url`: Plugin repo URL -* `version`: Plugin version number, it is recommended to follow the [semver](https://semver.org/) specification -* `minAppVersion`: Minimum version number of SiYuan required to use this plugin -* `backends`: Backend environment required by the plugin, optional values are `windows`, `linux`, `darwin`, `docker`, `android`, `ios` and `all` - * `windows`: Windows desktop - * `linux`: Linux desktop - * `darwin`: macOS desktop - * `docker`: Docker - * `android`: Android APP - * `ios`: iOS APP - * `all`: All environments -* `frontends`: Frontend environment required by the plugin, optional values are `desktop`, `desktop-window`, `mobile`, `browser-desktop`, `browser-mobile` and `all` - * `desktop`: Desktop - * `desktop-window`: Desktop window converted from tab - * `mobile`: Mobile APP - * `browser-desktop`: Desktop browser - * `browser-mobile`: Mobile browser - * `all`: All environments -* `displayName`: Template display name, mainly used for display in the marketplace list, supports multiple languages - * `default`: Default language, must exist - * `zh_CN`, `en_US` and other languages: optional, it is recommended to provide at least Chinese and English -* `description`: Plugin description, mainly used for display in the marketplace list, supports multiple languages - * `default`: Default language, must exist - * `zh_CN`, `en_US` and other languages: optional, it is recommended to provide at least Chinese and English -* `readme`: readme file name, mainly used to display in the marketplace details page, supports multiple languages - * `default`: Default language, must exist - * `zh_CN`, `en_US` and other languages: optional, it is recommended to provide at least Chinese and English -* `funding`: Plugin sponsorship information - * `openCollective`: Open Collective name - * `patreon`: Patreon name - * `github`: GitHub login name - * `custom`: Custom sponsorship link list -* `keywords`: Search keyword list, used for marketplace search function - -## Package - -No matter which method is used to compile and package, we finally need to generate a package.zip, which contains at -least the following files: - -* i18n/* -* icon.png (160*160) -* index.css -* index.js -* plugin.json -* preview.png (1024*768) -* README*.md - -## List on the marketplace - -* `pnpm run build` to generate package.zip -* Create a new GitHub release using your new version number as the "Tag version". See here for an - example: https://github.com/siyuan-note/plugin-sample/releases -* Upload the file package.zip as binary attachments -* Publish the release - -If it is the first release, please create a pull request to -the [Community Bazaar](https://github.com/siyuan-note/bazaar) repository and modify the plugins.json file in it. This -file is the index of all community plugin repositories, the format is: - -```json -{ - "repos": [ - "username/reponame" - ] -} -``` - -After the PR is merged, the bazaar will automatically update the index and deploy through GitHub Actions. When releasing -a new version of the plugin in the future, you only need to follow the above steps to create a new release, and you -don't need to PR the community bazaar repo. - -Under normal circumstances, the community bazaar repo will automatically update the index and deploy every hour, -and you can check the deployment status at https://github.com/siyuan-note/bazaar/actions. - -## Use Github Action - -The github action is included in this sample, you can use it to publish your new realse to marketplace automatically: - -1. In your repo setting page `https://github.com/OWNER/REPO/settings/actions`, down to **Workflow Permissions** and open the configuration like this: - - ![](asset/action.png) - -2. Push a tag in the format `v*` and github will automatically create a new release with new bulit package.zip - -3. By default, it will only publish a pre-release, if you don't think this is necessary, change the settings in release.yml - - ```yaml - - name: Release - uses: ncipollo/release-action@v1 - with. - allowUpdates: true - artifactErrorsFailBuild: true - artifacts: 'package.zip' - token: ${{ secrets.GITHUB_TOKEN }} - prerelease: true # change this to false - ``` - - -## How to remove svelte dependencies - -> Pure vite without svelte: https://github.com/frostime/plugin-sample-vite - -This plugin is packaged in vite and provides a dependency on the svelte framework. However, in practice some developers may not want to use svelte and only want to use the vite package. - -In fact you can use this template without using svelte without any modifications at all. The compilation-related parts of the svelte compilation are loaded into the vite workflow as plugins, so even if you don't have svelte in your project, it won't matter much. - -If you insist on removing all svelte dependencies so that they do not pollute your workspace, you can perform the following steps. 1. - -1. delete the - ```json - { - "@sveltejs/vite-plugin-svelte": "^2.0.3", - "@tsconfig/svelte": "^4.0.1", - "svelte": "^3.57.0" - } - ``` -2. delete the `svelte.config.js` file -3. delete the following line from the `vite.config.js` file - - Line 6: `import { svelte } from "@sveltejs/vite-plugin-svelte"` - - Line 20: `svelte(),` -4. delete line 37 of `tsconfig.json` from `"svelte"` 5. -5. re-run `pnpm i` - -## Developer's Guide - -Developers of SiYuan need to pay attention to the following specifications. - -### 1. File Reading and Writing Specifications - -If plugins or external extensions require direct reading or writing of files under the `data` directory, please use the kernel API to achieve this. **Do not call `fs` or other electron or nodejs APIs directly**, as it may result in data loss during synchronization and cause damage to cloud data. - -Related APIs can be found at: `/api/file/*` (e.g., `/api/file/getFile`). - -### 2. Daily Note Attribute Specifications - -When creating a daily note in SiYuan, a custom-dailynote-yyyymmdd attribute will be automatically added to the document to distinguish it from regular documents. - -> For more details, please refer to [Github Issue #9807](https://github.com/siyuan-note/siyuan/issues/9807). - -Developers should pay attention to the following when developing the functionality to manually create Daily Notes: - -* If `/api/filetree/createDailyNote` is called to create a daily note, the attribute will be automatically added to the document, and developers do not need to handle it separately -* If a document is created manually by developer's code (e.g., using the `createDocWithMd` API to create a daily note), please manually add this attribute to the document +Make sure you check them out and support them as well! +## 📜 License +The original plugin framework is developed by SiYuan 思源笔记 and licensed under the MIT license. +All changes made by me are copyright MassiveBox 2025, and licensed under the MIT license. \ No newline at end of file diff --git a/README_zh_CN.md b/README_zh_CN.md deleted file mode 100644 index f5815fd..0000000 --- a/README_zh_CN.md +++ /dev/null @@ -1,276 +0,0 @@ - -# 使用 vite + svelte 的思源笔记插件示例 - -[English](./README.md) - - -> 本例同 [siyuan/plugin-sample](https://github.com/siyuan-note/plugin-sample) [v0.4.1](https://github.com/siyuan-note/plugin-sample/tree/v0.4.1) - -1. 使用 vite 打包 -2. 使用符号链接、而不是把项目放到插件目录下的模式进行开发 -3. 内置对 svelte 框架的支持 - - > **如果不想要 svelte,请移步这个模板:** [frostime/plugin-sample-vite](https://github.com/frostime/plugin-sample-vite) - > - > **这里还提供了一个 vite+solidjs 的模板**: [frostime/plugin-sample-vite-solidjs](https://github.com/frostime/plugin-sample-vite-solidjs) - -4. 提供一个github action 模板,能自动生成package.zip并上传到新版本中 - -> [!NOTE] -> 当前模板案例基于 `svelte4` 版本,我们在 `svelte5` 分支中维护了一个实验性模板,将 Svelte 升级至 5.x 版本。 -> -> 未来我们会将主分支默认版本切换至 svelte5。 - -## 开始 - -1. 通过 Use this template 按钮将该库文件复制到你自己的库中,请注意库名和插件名称一致,默认分支必须为 `main` -2. 将你的库克隆到本地开发文件夹中 - * 注意: 同 `plugin-sample` 不同, 本样例并不推荐直接把代码下载到 `{workspace}/data/plugins/` -3. 安装 [NodeJS](https://nodejs.org/en/download) 和 [pnpm](https://pnpm.io/installation),然后在开发文件夹下执行 `pnpm i` 安装所需要的依赖 -4. 运行 `pnpm run make-link` 命令创建符号链接 (Windows 下的开发者请参阅下方「Windows 下的 make-link」小节) -5. 执行 `pnpm run dev` 进行实时编译 -6. 在思源中打开集市并在下载选项卡中启用插件 - -> [!TIP] -> 你也可以使用我们维护的 [siyuan-plugin-cli](https://www.npmjs.com/package/siyuan-plugin-cli) 命令行工具,在本地终端中直接构建插件。 -> -> 此外,对于本插件以下提及到的 `make-link` 相关的命令,后续所有更新将在 [siyuan-plugin-cli](https://www.npmjs.com/package/siyuan-plugin-cli) 中进行。 -> -> 模板内置的 `make-link` 脚本也可能会在未来某个版本中移除,转而使用 `siyuan-plugin-cli` 工具,意在简化同时维护多个插件模板的工作量。 - -### 设置 make-link 命令的目标目录 - -make-link 命令会创建符号链接将你的 `dev` 目录绑定到思源的插件目录下。你可以有三种方式来配置目标的思源工作空间并创建符号链接: - -1. **选择工作空间** - - 打开思源笔记, 确保思源内核正在运行 - - 运行 `pnpm run make-link`, 脚本会自动检测所有思源的工作空间, 请在命令行中手动输入序号以选择工作空间 - ```bash - >>> pnpm run make-link - > plugin-sample-vite-svelte@0.0.3 make-link H:\SrcCode\开源项目\plugin-sample-vite-svelte - > node --no-warnings ./scripts/make_dev_link.js - - "targetDir" is empty, try to get SiYuan directory automatically.... - Got 2 SiYuan workspaces - [0] H:\Media\SiYuan - [1] H:\临时文件夹\SiYuanDevSpace - Please select a workspace[0-1]: 0 - Got target directory: H:\Media\SiYuan/data/plugins - Done! Created symlink H:\Media\SiYuan/data/plugins/plugin-sample-vite-svelte - ``` -2. **手动配置目标目录** - - 打开 `./scripts/make_dev_link.js` 文件,更改 `targetDir` 为思源的插件目录 `/data/plugins` - - 运行 `pnpm run make-link` 命令, 如果看到类似以下的消息,说明创建成功: - -3. **设置环境变量创建符号链接** - - 设置系统的环境变量 `SIYUAN_PLUGIN_DIR` 为 `工作空间/data/plugins` 的路径 - - -### Windows 下的 make-link - -由于思源升级了 Go 1.23,旧版创建的 junction link 在 windows 下无法被正常识别,故而改为创建 `dir` 符号链接。 - -> https://github.com/siyuan-note/siyuan/issues/12399 - - -不过 Windows 下使用 NodeJs 创建目录符号链接可能需要管理员权限,你可以有如下几种选择: - -1. 在具有管理员权限的命令行中运行 `pnpm run make-link` -2. 配置 Windows 设置,在 [系统设置-更新与安全-开发者模式] 中启用开发者模式,然后再运行 `pnpm run make-link` -3. 运行 `pnpm run make-link-win`,该命令会使用一个 powershell 脚本来寻求管理员权限,需要在系统中开启 PowerShell 脚本执行权限 - - -## 国际化 - -国际化方面我们主要考虑的是支持多语言,具体需要完成以下工作: - -* 插件自身的元信息,比如插件描述和自述文件 - * plugin.json 中的 `description` 和 `readme` 字段,以及对应的 README*.md 文件 -* 插件中使用的文本,比如按钮文字和提示信息 - * public/i18n/*.json 语言配置文件 - * 代码中使用 `this.i18.key` 获取文本 -* 最后在 plugin.json 中的 `i18n` 字段中声明该插件支持的语言 -* yaml 支持 - * 本模板特别支持基于 Yaml 语法的 I18n,见 `public/i18n/zh_CN.yaml` - * 编译时,会自动把定义的 yaml 文件翻译成 json 文件放到 dist 或 dev 目录下 - -建议插件至少支持英文和简体中文,这样可以方便更多人使用。 - -## plugin.json - -```json -{ - "name": "plugin-sample-vite-svelte", - "author": "frostime", - "url": "https://github.com/siyuan-note/plugin-sample-vite-svelte", - "version": "0.1.3", - "minAppVersion": "2.8.8", - "backends": ["windows", "linux", "darwin"], - "frontends": ["desktop"], - "displayName": { - "en_US": "Plugin sample with vite and svelte", - "zh_CN": "插件样例 vite + svelte 版" - }, - "description": { - "en_US": "SiYuan plugin sample with vite and svelte", - "zh_CN": "使用 vite 和 svelte 开发的思源插件样例" - }, - "readme": { - "en_US": "README_en_US.md", - "zh_CN": "README.md" - }, - "funding": { - "openCollective": "", - "patreon": "", - "github": "", - "custom": [ - "https://ld246.com/sponsor" - ] - }, - "keywords": [ - "sample", "示例" - ] -} -``` - -* `name`:插件名称,必须和库名一致,且全局唯一(集市中不能有重名插件) -* `author`:插件作者名 -* `url`:插件仓库地址 -* `version`:插件版本号,建议遵循 [semver](https://semver.org/lang/zh-CN/) 规范 -* `minAppVersion`:插件支持的最低思源笔记版本号 -* `backends`:插件需要的后端环境,可选值为 `windows`, `linux`, `darwin`, `docker`, `android`, `ios` and `all` - * `windows`:Windows 桌面端 - * `linux`:Linux 桌面端 - * `darwin`:macOS 桌面端 - * `docker`:Docker 端 - * `android`:Android 端 - * `ios`:iOS 端 - * `all`:所有环境 -* `frontends`:插件需要的前端环境,可选值为 `desktop`, `desktop-window`, `mobile`, `browser-desktop`, `browser-mobile` and `all` - * `desktop`:桌面端 - * `desktop-window`:桌面端页签转换的独立窗口 - * `mobile`:移动端 - * `browser-desktop`:桌面端浏览器 - * `browser-mobile`:移动端浏览器 - * `all`:所有环境 -* `displayName`:模板显示名称,主要用于模板集市列表中显示,支持多语言 - * `default`:默认语言,必须存在 - * `zh_CN`、`en_US` 等其他语言:可选,建议至少提供中文和英文 -* `description`:插件描述,主要用于插件集市列表中显示,支持多语言 - * `default`:默认语言,必须存在 - * `zh_CN`、`en_US` 等其他语言:可选,建议至少提供中文和英文 -* `readme`:自述文件名,主要用于插件集市详情页中显示,支持多语言 - * `default`:默认语言,必须存在 - * `zh_CN`、`en_US` 等其他语言:可选,建议至少提供中文和英文 -* `funding`:插件赞助信息 - * `openCollective`:Open Collective 名称 - * `patreon`:Patreon 名称 - * `github`:GitHub 登录名 - * `custom`:自定义赞助链接列表 -* `keywords`:搜索关键字列表,用于集市搜索功能 - -## 打包 - -无论使用何种方式编译打包,我们最终需要生成一个 package.zip,它至少包含如下文件: - -* i18n/* -* icon.png (160*160) -* index.css -* index.js -* plugin.json -* preview.png (1024*768) -* README*.md - -## 上架集市 - -* 执行 `pnpm run build` 生成 package.zip -* 在 GitHub 上创建一个新的发布,使用插件版本号作为 “Tag - version”,示例 https://github.com/siyuan-note/plugin-sample/releases -* 上传 package.zip 作为二进制附件 -* 提交发布 - -如果是第一次发布版本,还需要创建一个 PR 到 [Community Bazaar](https://github.com/siyuan-note/bazaar) 社区集市仓库,修改该库的 -plugins.json。该文件是所有社区插件库的索引,格式为: - -```json -{ - "repos": [ - "username/reponame" - ] -} -``` - -PR 被合并以后集市会通过 GitHub Actions 自动更新索引并部署。后续发布新版本插件时只需要按照上述步骤创建新的发布即可,不需要再 -PR 社区集市仓库。 - -正常情况下,社区集市仓库每隔 1 小时会自动更新索引并部署,可在 https://github.com/siyuan-note/bazaar/actions 查看部署状态。 - -## 使用 Github action 自动发布 - -样例中自带了 github action,可以自动打包发布,请遵循以下操作: - -1. 设置项目 `https://github.com/OWNER/REPO/settings/actions` 页面向下划到 **Workflow Permissions**,打开配置 - - ![](asset/action.png) - -2. 需要发布版本的时候,push 一个格式为 `v*` 的 tag,github 就会自动打包发布 release(包括 package.zip) - -3. 默认使用保守策略进行 pre-release 发布,如果觉得没有必要,可以更改 release.yml 中的设置: - - ```yaml - - name: Release - uses: ncipollo/release-action@v1 - with: - allowUpdates: true - artifactErrorsFailBuild: true - artifacts: 'package.zip' - token: ${{ secrets.GITHUB_TOKEN }} - prerelease: true # 把这个改为 false - ``` - -## 如何去掉 svelte 依赖 - -> 无 Svelte 依赖版: https://github.com/frostime/plugin-sample-vite - -本插件使用 vite 打包,并提供了 svelte 框架依赖。不过实际情况下可能有些开发者并不想要 svelte,只希望使用 vite 打包。 - -实际上你可以完全不做任何修改,就可以在不使用 svelte 的前提下使用这个模板。与 svelte 编译的编译相关的部分是以插件的形式载入到 vite 的工作流中,所以即使你的项目里面没有 svelte,也不会有太大的影响。 - -如果你执意希望删除掉所有 svelte 依赖以免它们污染你的工作空间,可以执行一下步骤: - -1. 删掉 package.json 中的 - ```json - { - "@sveltejs/vite-plugin-svelte": "^2.0.3", - "@tsconfig/svelte": "^4.0.1", - "svelte": "^3.57.0" - } - ``` -2. 删掉 `svelte.config.js` 文件 -3. 删掉 `vite.config.js` 文件中的 - - 第六行: `import { svelte } from "@sveltejs/vite-plugin-svelte"` - - 第二十行: `svelte(),` -4. 删掉 `tsconfig.json` 中 37 行 `"svelte"` -5. 重新执行 `pnpm i` - - -## 开发者须知 - -思源开发者需注意以下规范。 - -### 1. 读写文件规范 - -插件或者外部扩展如果有直接读取或者写入 data 下文件的需求,请通过调用内核 API 来实现,**不要自行调用 `fs` 或者其他 electron、nodejs API**,否则可能会导致数据同步时分块丢失,造成云端数据损坏。 - -相关 API 见 `/api/file/*`(例如 `/api/file/getFile` 等)。 - -### 2. Daily Note 属性规范 - -思源在创建日记的时候会自动为文档添加 custom-dailynote-yyyymmdd 属性,以方便将日记文档同普通文档区分。 - -> 详情请见 [Github Issue #9807](https://github.com/siyuan-note/siyuan/issues/9807)。 - -开发者在开发手动创建 Daily Note 的功能时请注意: - -* 如果调用了 `/api/filetree/createDailyNote` 创建日记,那么文档会自动添加这个属性,无需开发者特别处理 -* 如果是开发者代码手动创建文档(例如使用 `createDocWithMd` API 创建日记),请手动为文档添加该属性 diff --git a/icon.png b/icon.png index 47d51af..89bfeea 100644 Binary files a/icon.png and b/icon.png differ diff --git a/package.json b/package.json index bf6332d..96dcabe 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { - "name": "plugin-sample-vite-svelte", - "version": "0.4.1", + "name": "syspell", + "version": "0.5.0", "type": "module", - "description": "This is a sample plugin based on vite and svelte for Siyuan (https://b3log.org/siyuan)", - "repository": "", - "homepage": "", - "author": "frostime", + "description": "Include a whiteboard for freehand drawing anywhere in your documents.", + "repository": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin", + "homepage": "https://git.massive.box/massivebox/siyuan-jsdraw-plugin", + "author": "massivebox", "license": "MIT", "scripts": { "dev": "cross-env NODE_ENV=development VITE_SOURCEMAP=inline vite build --watch", @@ -34,4 +34,4 @@ "vite-plugin-static-copy": "^1.0.2", "vite-plugin-zip-pack": "^1.0.5" } -} \ No newline at end of file +} diff --git a/plugin.json b/plugin.json index f804990..9eb5345 100644 --- a/plugin.json +++ b/plugin.json @@ -1,10 +1,9 @@ { - "name": "plugin-sample-vite-svelte", - "author": "frostime", - "url": "https://github.com/siyuan-note/plugin-sample-vite-svelte", - "version": "0.4.1", - "minAppVersion": "3.2.1", - "disabledInPublish": true, + "name": "syspell", + "author": "massivebox", + "url": "https://git.massive.box/massivebox/syspell", + "version": "0.1.0", + "minAppVersion": "3.0.12", "backends": [ "windows", "linux", @@ -22,25 +21,25 @@ "desktop-window" ], "displayName": { - "en_US": "Plugin sample with vite and svelte", - "zh_CN": "插件样例 vite + svelte 版" + "en_US": "SySpell - Grammar and Spell Checker" }, "description": { - "en_US": "SiYuan plugin sample with vite and svelte", - "zh_CN": "使用 vite 和 svelte 开发的思源插件样例" + "en_US": "A fully featured grammar checker for SiYuan, like Grammarly or LanguageTool. Supports multiple languages and custom dictionaries. Completely free and open source." }, "readme": { - "en_US": "README.md", - "zh_CN": "README_zh_CN.md" + "en_US": "README.md" }, "funding": { "custom": [ - "" + "https://s.massive.box/siyuan-plugin-donate" ] }, "keywords": [ "plugin", - "sample", - "插件样例" + "spelling", + "grammar", + "languagetool", + "grammarly", + "correction" ] } diff --git a/preview.png b/preview.png index 95b93c7..9739e10 100644 Binary files a/preview.png and b/preview.png differ diff --git a/public/i18n/en_US.json b/public/i18n/en_US.json index 7a11256..06e47ad 100644 --- a/public/i18n/en_US.json +++ b/public/i18n/en_US.json @@ -1,20 +1,76 @@ { - "addTopBarIcon": "Add a top bar icon by plugin", - "cancel": "Cancel", - "save": "Save", - "byeMenu": "Bye, Menu!", - "helloPlugin": "Hello, Plugin!", - "byePlugin": "Bye, Plugin!", - "showDialog": "Show dialog", - "removedData": "Data deleted", - "confirmRemove": "Confirm to delete the data in ${name}?", - "insertEmoji": "Insert Emoji", - "removeSpace": "Remove Space", - "getTab": "Print out all opened custom tabs in the debugger", - "name": "SiYuan", - "hello": { - "makesure": "Before using this template, please read the offical sample, make sure that you've known about the pipeline for plugin developing." + "settings":{ + "info": { + "title": "Information", + "description": "The default grammar checking server is provided for free by the developer, MassiveBox. It does not save, sell or share the content of your text.
Running a LanguageTool server is resource intensive, so if you find this plugin useful, please consider donating to help me keep it alive!" }, - "hintTitle":"About", - "hintDesc":"plugin-sample-vite-svelte
@frostime
@88250
@zxkmm" + "server": { + "title": "LanguageTool server", + "description": "The URL of the LanguageTool server, with the trailing slash. Some suggestions:\n
  • Free server provided by the plugin developer, slower but without limits: https://lt.massive.box/
  • \n
  • Free server provided by LanguageTool, very strict rate limits: https://languagetool.org/api/
  • \n
  • If you have LanguageTool Premium: https://api.languagetoolplus.com/
  • \n
  • If you're running the self-hosted embedded server: http://localhost:8081/
  • " + }, + "username": { + "title": "LanguageTool Account username [Optional]", + "description": "If you have a LanguageTool account with premium features, enter the username here" + }, + "apiKey": { + "title": "LanguageTool API Key [Optional]", + "description": "If you have a LanguageTool account with premium features, enter the API key here. Your API key" + }, + "picky": { + "title": "Picky mode", + "description": "Check for additional rules that you might only find useful in the context of formal text" + }, + "motherTongue": { + "title": "Mother tongue", + "description": "Check and report false friends for some language pairs" + }, + "preferredVariants": { + "title": "Preferred variants", + "description": "A comma-separated list of preferred variants for languages that have them. For example: en-US,de-DE or en-GB,de-DE" + }, + "enabledByDefault": { + "title": "Enable grammar checking by default", + "description": "If turned on, grammar checking is enabled for all documents, and must be manually disabled for each document where it's not needed.
    \nIf turned off, grammar checking is disabled for all documents, and must be manually enabled for each document where it's needed." + }, + "defaultLanguage": { + "title": "Default language for documents", + "description": "When a language is not manually set for a document, this one will be used for grammar suggestions.
    If you select auto-detection, some blocks may be recognized incorrectly." + }, + "analytics": { + "title": "Analytics", + "description": "Enable to send anonymous usage data to the developer. Privacy Policy" + }, + "customDictionary": { + "title": "Custom dictionary", + "description": "A comma-separated list of words that will not trigger the grammar checker. For example: hello,SiYuan
    After editing and saving, reload SiYuan to apply." + }, + "experimentalCorrect": { + "title": "[Feature preview] Apply corrections when selected", + "description": "[Feature preview] This feature will modify the content of your documents when used, and can alter them significantly without the ability to roll the changes back. This feature is not recommended for production workspaces.

    When a correction is chosen, apply it to the document instead of just having copied to your clipboard." + } + }, + "errors": { + "builtInEnabled": "The built-in spell checker is enabled, and may conflict with the SySpell Plugin and cause confusion. Disable it in Settings > Editor.", + "cantRenderLabel": "Can't provide grammar suggestions for this block", + "cantRender": "This block contains elements, such as images or tables, that don't work well with the SySpell system.", + "waitingForSuggestions": "Grammar suggestions for this block aren't ready yet, please close the menu and open it again after a few seconds.", + "correctionNotEnabled": "The correction has been copied to your clipboard. Suggestions can be auto-applied when selected. Visit the plugin's settings to enable.", + "checkServer": "Failed to contact grammar checking server, make sure it's correctly set in the plugin settings.", + "fatal": "The grammar checking plugin will quit now. Please restart SiYuan.", + "notImplementedNotebookSettings": "Notebook-wide grammar checking settings aren't implemented yet, they will be added in a future version. Thanks for your patience!" + }, + "docMenu": { + "documentStatus": "Document status", + "language": "Language", + "status": "Status", + "enabled": "Enabled", + "disabled": "Disabled", + "toggleSpellCheck": "Toggle grammar checking", + "setDocumentLanguage": "Set document language", + "autodetectLanguage": "Autodetect [block by block]" + }, + "textMenu": { + "addToDictionary": "Add to dictionary", + "addedToDictionary": "Added to dictionary: " + } } \ No newline at end of file diff --git a/public/i18n/zh_CN.json b/public/i18n/zh_CN.json deleted file mode 100644 index 6600f6a..0000000 --- a/public/i18n/zh_CN.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "addTopBarIcon": "使用插件添加一个顶栏按钮", - "cancel": "取消", - "save": "保存", - "byeMenu": "再见,菜单!", - "helloPlugin": "你好,插件!", - "byePlugin": "再见,插件!", - "showDialog": "弹出一个对话框", - "removedData": "数据已删除", - "confirmRemove": "确认删除 ${name} 中的数据?", - "insertEmoji": "插入表情", - "removeSpace": "移除空格", - "getTab": "在日志中打印出已打开的所有自定义页签", - "name": "思源", - "hello": { - "makesure": "使用这个模板之前,请阅读官方教程, 确保自己已经理解了插件的基本开发流程。" - }, - "hintTitle": "关于", - "hintDesc": "🔗 plugin-sample-vite-svelte
    💻 @frostime
    💻 @88250
    💻 @zxkmm" -} \ No newline at end of file diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 0000000..8f35e06 --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,47 @@ +import {getBackend, getFrontend} from "siyuan"; +import packageJson from '../package.json' assert { type: 'json' }; + +export class Analytics { + + private readonly enabled: boolean; + + private static readonly ENDPOINT = 'https://stats.massive.box/api/send_noua'; + private static readonly WEBSITE_ID = '6963975c-c7e7-495f-a4f0-fa1a0d3e64ac'; + + constructor(enabled: boolean) { + this.enabled = enabled; + } + + async sendEvent(name: string, addData?: Record) { + + if(!this.enabled) return; + + const sendData: Record = (name == 'load' || name == 'install') ? + { + 'appVersion': window.navigator.userAgent.split(' ')[0], + 'pluginVersion': packageJson.version, + 'frontend': getFrontend(), + 'backend': getBackend(), + 'language': navigator.language, + 'appLanguage': window.siyuan.config.lang, + ...addData + } : { ...addData }; + + await fetch(Analytics.ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type: 'event', + payload: { + website: Analytics.WEBSITE_ID, + name: name, + data: sendData, + }, + }) + }) + + } + +} \ No newline at end of file diff --git a/src/hello.svelte b/src/hello.svelte deleted file mode 100644 index 23353c7..0000000 --- a/src/hello.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - - -
    -
    appId:
    -
    -
    ${app?.appId}
    -
    -
    -
    API demo:
    -
    -
    - System current time: {time} -
    -
    -
    -
    Protyle demo: id = {blockID}
    -
    -
    -
    - diff --git a/src/icons.ts b/src/icons.ts new file mode 100644 index 0000000..2189f92 --- /dev/null +++ b/src/icons.ts @@ -0,0 +1,43 @@ +import {Plugin} from 'siyuan'; + +export class Icons { + + constructor(p: Plugin) { + this.icons.forEach(icon => + p.addIcons(icon) + ) + + } + + private icons = [ + // info - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:info + ` + + `, + // spell check - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:spellcheck + ` + + `, + // language - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:language_chinese_quick + ` + + `, + // toggle - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:toggle_on + ` + + `, + // autodetect - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:network_intelligence + ` + + `, + // error - https://fonts.google.com/icons?selected=Material+Symbols+Outlined + ` + + `, + // add - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:add + ` + + ` + ] + +} \ No newline at end of file diff --git a/src/index.scss b/src/index.scss deleted file mode 100644 index e69de29..0000000 diff --git a/src/index.ts b/src/index.ts index 614e0a4..7227a9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,1011 +1,122 @@ -import { - Plugin, - showMessage, - confirm, - Dialog, - Menu, - openTab, - adaptHotkey, - getFrontend, - getBackend, - // Setting, - // fetchPost, - Protyle, - openWindow, - IOperation, - Constants, - openMobileFileById, - lockScreen, - ICard, - ICardData, - Custom, - exitSiYuan, - getModelByDockType, - getAllEditor, - Files, - platformUtils, - openSetting, - openAttributePanel, - saveLayout -} from "siyuan"; -import "./index.scss"; -import { IMenuItem } from "siyuan/types"; - -import HelloExample from "@/hello.svelte"; -import SettingExample from "@/setting-example.svelte"; - -import { SettingUtils } from "./libs/setting-utils"; -import { svelteDialog } from "./libs/dialog"; - -const STORAGE_NAME = "menu-config"; -const TAB_TYPE = "custom_tab"; -const DOCK_TYPE = "dock_tab"; - -export default class PluginSample extends Plugin { - - private custom: () => Custom; - private isMobile: boolean; - private blockIconEventBindThis = this.blockIconEvent.bind(this); - private settingUtils: SettingUtils; +import {Plugin, showMessage} from 'siyuan'; +import {ProtyleHelpers} from "@/protyleHelpers"; +import {Icons} from "@/icons"; +import {Settings} from "@/settings"; +import {SettingUtils} from "@/libs/setting-utils"; +import {Analytics} from "@/analytics"; +import {SuggestionEngine} from "@/suggestions"; +import {Menus} from "@/menus"; - updateProtyleToolbar(toolbar: Array) { - toolbar.push("|"); - toolbar.push({ - name: "insert-smail-emoji", - icon: "iconEmoji", - hotkey: "⇧⌘I", - tipPosition: "n", - tip: this.i18n.insertEmoji, - click(protyle: Protyle) { - protyle.insert("😊"); - } - }); - return toolbar; - } +export default class SpellCheckPlugin extends Plugin { + + private menus: Menus + + public settingsUtil: SettingUtils; + public suggestions: SuggestionEngine + public analytics: Analytics + public i18nx: any; // This object is just a copy of i18n, but with type "any" to not trigger type errors + + public static ENABLED_ATTR = 'custom-spellcheck-enable' + public static LANGUAGE_ATTR = 'custom-spellcheck-language' async onload() { - this.data[STORAGE_NAME] = { readonlyText: "Readonly" }; - console.log("loading plugin-sample", this.i18n); + this.i18nx = this.i18n + new Icons(this); + this.settingsUtil = await Settings.init(this) + this.analytics = new Analytics(this.settingsUtil.get('analytics')); + this.suggestions = new SuggestionEngine(this) + this.menus = new Menus(this) - const frontEnd = getFrontend(); - this.isMobile = frontEnd === "mobile" || frontEnd === "browser-mobile"; - // 图标的制作参见帮助文档 - this.addIcons(` - - - - -`); + void this.analytics.sendEvent('load') - let tabDiv = document.createElement("div"); - let app = null; - this.custom = this.addTab({ - type: TAB_TYPE, - init() { - app = new HelloExample({ - target: tabDiv, - props: { - app: this.app, - blockID: this.data.blockID - } - }); - this.element.appendChild(tabDiv); - console.log(this.element); - }, - beforeDestroy() { - console.log("before destroy tab:", TAB_TYPE); - }, - destroy() { - app?.$destroy(); - console.log("destroy tab:", TAB_TYPE); - } - }); + const style = document.createElement('style'); + style.innerHTML = ` + .underline-overlay { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + z-index: 2; + } + .error-underline { + position: absolute; + height: 2px; + background-image: + radial-gradient(circle at 2px 1px, #ff4444 1px, transparent 1px), + radial-gradient(circle at 6px 1px, #ff4444 1px, transparent 1px); + background-size: 8px 2px; + background-repeat: repeat-x; + background-position: 0 bottom; + }` + window.document.head.appendChild(style); - this.addCommand({ - langKey: "showDialog", - hotkey: "⇧⌘O", - callback: () => { - this.showDialog(); - }, - }); - - this.addCommand({ - langKey: "getTab", - hotkey: "⇧⌘M", - globalCallback: () => { - console.log(this.getOpenedTab()); - }, - }); - - this.addDock({ - config: { - position: "LeftBottom", - size: { width: 200, height: 0 }, - icon: "iconSaving", - title: "Custom Dock", - hotkey: "⌥⌘W", - }, - data: { - text: "This is my custom dock" - }, - type: DOCK_TYPE, - resize() { - console.log(DOCK_TYPE + " resize"); - }, - update() { - console.log(DOCK_TYPE + " update"); - }, - init: (dock) => { - if (this.isMobile) { - dock.element.innerHTML = `
    - -
    Custom Dock
    -
    -
    - ${dock.data.text} -
    -
    `; - } else { - dock.element.innerHTML = `
    -
    - - - -
    -
    - ${dock.data.text} -
    -
    `; - } - }, - destroy() { - console.log("destroy dock:", DOCK_TYPE); - } - }); - - this.settingUtils = new SettingUtils({ - plugin: this, name: STORAGE_NAME - }); - this.settingUtils.addItem({ - key: "Input", - value: "", - type: "textinput", - title: "Readonly text", - description: "Input description", - action: { - // Called when focus is lost and content changes - callback: () => { - // Return data and save it in real time - let value = this.settingUtils.takeAndSave("Input"); - console.log(value); - } - } - }); - this.settingUtils.addItem({ - key: "InputArea", - value: "", - type: "textarea", - title: "Readonly text", - description: "Input description", - // Called when focus is lost and content changes - action: { - callback: () => { - // Read data in real time - let value = this.settingUtils.take("InputArea"); - console.log(value); - } - } - }); - this.settingUtils.addItem({ - key: "Check", - value: true, - type: "checkbox", - title: "Checkbox text", - description: "Check description", - action: { - callback: () => { - // Return data and save it in real time - let value = !this.settingUtils.get("Check"); - this.settingUtils.set("Check", value); - console.log(value); - } - } - }); - this.settingUtils.addItem({ - key: "Select", - value: 1, - type: "select", - title: "Select", - description: "Select description", - options: { - 1: "Option 1", - 2: "Option 2" - }, - action: { - callback: () => { - // Read data in real time - let value = this.settingUtils.take("Select"); - console.log(value); - } - } - }); - this.settingUtils.addItem({ - key: "Slider", - value: 50, - type: "slider", - title: "Slider text", - description: "Slider description", - direction: "column", - slider: { - min: 0, - max: 100, - step: 1, - }, - action: { - callback: () => { - // Read data in real time - let value = this.settingUtils.take("Slider"); - console.log(value); - } - } - }); - this.settingUtils.addItem({ - key: "Btn", - value: "", - type: "button", - title: "Button", - description: "Button description", - button: { - label: "Button", - callback: () => { - showMessage("Button clicked"); - } - } - }); - this.settingUtils.addItem({ - key: "Custom Element", - value: "", - type: "custom", - direction: "row", - title: "Custom Element", - description: "Custom Element description", - //Any custom element must offer the following methods - createElement: (currentVal: any) => { - let div = document.createElement('div'); - div.style.border = "1px solid var(--b3-theme-primary)"; - div.contentEditable = "true"; - div.textContent = currentVal; - return div; - }, - getEleVal: (ele: HTMLElement) => { - return ele.textContent; - }, - setEleVal: (ele: HTMLElement, val: any) => { - ele.textContent = val; - } - }); - this.settingUtils.addItem({ - key: "Hint", - value: "", - type: "hint", - title: this.i18n.hintTitle, - description: this.i18n.hintDesc, - }); - - try { - this.settingUtils.load(); - } catch (error) { - console.error("Error loading settings storage, probably empty config json:", error); + if(window.siyuan.config.editor.spellcheck) { + showMessage(this.i18nx.errors.builtInEnabled, -1, 'error') } + this.eventBus.on('ws-main', async (event) => { - this.protyleSlash = [{ - filter: ["insert emoji 😊", "插入表情 😊", "crbqwx"], - html: `
    ${this.i18n.insertEmoji}😊
    `, - id: "insertEmoji", - callback(protyle: Protyle) { - protyle.insert("😊"); - } - }]; + if (event.detail.cmd != 'transactions') { return } - this.protyleOptions = { - toolbar: ["block-ref", - "a", - "|", - "text", - "strong", - "em", - "u", - "s", - "mark", - "sup", - "sub", - "clear", - "|", - "code", - "kbd", - "tag", - "inline-math", - "inline-memo", - ], - }; + const operation = event.detail.data[0].doOperations[0] + const action = operation.action + const blockID = operation.id + + if(action != 'update') { return } + + await this.suggestions.suggestAndRender(blockID) + void this.suggestions.forAllBlocksSuggest(this.suggestions.documentID, false, true, false) + + }) + + this.eventBus.on('open-menu-content', async (event) => { + + if(!this.suggestions.documentEnabled) { return } + void this.analytics.sendEvent('menu-open-any'); + const blockID = ProtyleHelpers.getNodeId(event.detail.range.startContainer.parentElement) + + const suggNo = this.suggestions.getSuggestionNumber(blockID, event.detail.range) + this.menus.addCorrectionsToParagraphMenu(blockID, suggNo, event.detail.menu) + + }) + + this.eventBus.on('open-menu-doctree', async (event) => { + console.log(event) + const docID = ProtyleHelpers.getNodeId(event.detail.elements[0]) // @TODO this is ugly, why does the event not carry the docID? + void this.menus.addSettingsToDocMenu(docID, event.detail.menu) + }) + + this.eventBus.on('switch-protyle', async (event) => { + + const docID = event.detail.protyle.block.id + const settings = await ProtyleHelpers.getDocumentSettings(docID, this.settingsUtil.get('enabledByDefault'), this.settingsUtil.get('defaultLanguage')) + + this.suggestions.documentID = docID + this.suggestions.documentEnabled = settings.enabled + this.suggestions.documentLanguage = settings.language + + this.suggestions.clearStorage() + void this.suggestions.forAllBlocksSuggest(docID, true, true, false) + + const activeEditor = document.querySelector('.fn__flex-1.protyle:not([class*="fn__none"])') + new ResizeObserver(this.reRenderSuggestions.bind(this)).observe(activeEditor) + + }) - console.log(this.i18n.helloPlugin); } - onLayoutReady() { - const topBarElement = this.addTopBar({ - icon: "iconFace", - title: this.i18n.addTopBarIcon, - position: "right", - callback: () => { - if (this.isMobile) { - this.addMenu(); - } else { - let rect = topBarElement.getBoundingClientRect(); - // 如果被隐藏,则使用更多按钮 - if (rect.width === 0) { - rect = document.querySelector("#barMore").getBoundingClientRect(); - } - if (rect.width === 0) { - rect = document.querySelector("#barPlugins").getBoundingClientRect(); - } - this.addMenu(rect); - } - } - }); - - const statusIconTemp = document.createElement("template"); - statusIconTemp.innerHTML = `
    - - - -
    `; - statusIconTemp.content.firstElementChild.addEventListener("click", () => { - confirm("⚠️", this.i18n.confirmRemove.replace("${name}", this.name), () => { - this.removeData(STORAGE_NAME).then(() => { - this.data[STORAGE_NAME] = { readonlyText: "Readonly" }; - showMessage(`[${this.name}]: ${this.i18n.removedData}`); - }); - }); - }); - this.addStatusBar({ - element: statusIconTemp.content.firstElementChild as HTMLElement, - }); - // this.loadData(STORAGE_NAME); - this.settingUtils.load(); - console.log(`frontend: ${getFrontend()}; backend: ${getBackend()}`); - - console.log( - "Official settings value calling example:\n" + - this.settingUtils.get("InputArea") + "\n" + - this.settingUtils.get("Slider") + "\n" + - this.settingUtils.get("Select") + "\n" - ); + private reRenderSuggestions() { + void this.suggestions.forAllBlocksSuggest(this.suggestions.documentID, false, true, false) } - async onunload() { - console.log(this.i18n.byePlugin); - showMessage("Goodbye SiYuan Plugin"); - console.log("onunload"); + onunload() { + void this.analytics.sendEvent('unload'); } uninstall() { - console.log("uninstall"); + void this.analytics.sendEvent('uninstall'); } - async updateCards(options: ICardData) { - options.cards.sort((a: ICard, b: ICard) => { - if (a.blockID < b.blockID) { - return -1; - } - if (a.blockID > b.blockID) { - return 1; - } - return 0; - }); - return options; - } - /** - * A custom setting pannel provided by svelte - */ - openSetting(): void { - let dialog = new Dialog({ - title: "SettingPannel", - content: `
    `, - width: "800px", - destroyCallback: (options) => { - console.log("destroyCallback", options); - //You'd better destroy the component when the dialog is closed - pannel.$destroy(); - } - }); - let pannel = new SettingExample({ - target: dialog.element.querySelector("#SettingPanel"), - }); - } - - private eventBusPaste(event: any) { - // 如果需异步处理请调用 preventDefault, 否则会进行默认处理 - event.preventDefault(); - // 如果使用了 preventDefault,必须调用 resolve,否则程序会卡死 - event.detail.resolve({ - textPlain: event.detail.textPlain.trim(), - }); - } - - private eventBusLog({ detail }: any) { - console.log(detail); - } - - private blockIconEvent({ detail }: any) { - detail.menu.addItem({ - id: "pluginSample_removeSpace", - iconHTML: "", - label: this.i18n.removeSpace, - click: () => { - const doOperations: IOperation[] = []; - detail.blockElements.forEach((item: HTMLElement) => { - const editElement = item.querySelector('[contenteditable="true"]'); - if (editElement) { - editElement.textContent = editElement.textContent.replace(/ /g, ""); - doOperations.push({ - id: item.dataset.nodeId, - data: item.outerHTML, - action: "update" - }); - } - }); - detail.protyle.getInstance().transaction(doOperations); - } - }); - } - - private showDialog() { - const docId = this.getEditor().protyle.block.rootID; - svelteDialog({ - title: `SiYuan ${Constants.SIYUAN_VERSION}`, - width: this.isMobile ? "92vw" : "720px", - constructor: (container: HTMLElement) => { - return new HelloExample({ - target: container, - props: { - app: this.app, - blockID: docId - } - }); - } - }); - } - - private addMenu(rect?: DOMRect) { - const menu = new Menu("topBarSample", () => { - console.log(this.i18n.byeMenu); - }); - menu.addItem({ - icon: "iconSettings", - label: "Open SiYuan Setting", - click: () => { - openSetting(this.app); - } - }); - menu.addItem({ - icon: "iconSettings", - label: "Open Plugin Setting", - click: () => { - this.openSetting(); - } - }); - menu.addSeparator(); - menu.addItem({ - icon: "iconDrag", - label: "Open Attribute Panel", - click: () => { - openAttributePanel({ - nodeElement: this.getEditor().protyle.wysiwyg.element.firstElementChild as HTMLElement, - protyle: this.getEditor().protyle, - focusName: "custom", - }); - } - }); - menu.addItem({ - icon: "iconInfo", - label: "Dialog(open doc first)", - accelerator: this.commands[0].customHotkey, - click: () => { - this.showDialog(); - } - }); - menu.addItem({ - icon: "iconFocus", - label: "Select Opened Doc(open doc first)", - click: () => { - (getModelByDockType("file") as Files).selectItem(this.getEditor().protyle.notebookId, this.getEditor().protyle.path); - } - }); - if (!this.isMobile) { - menu.addItem({ - icon: "iconFace", - label: "Open Custom Tab(open doc first)", - click: () => { - const tab = openTab({ - app: this.app, - custom: { - icon: "iconFace", - title: "Custom Tab", - data: { - // text: platformUtils.isHuawei() ? "Hello, Huawei!" : "This is my custom tab", - blockID: this.getEditor().protyle.block.rootID, - }, - id: this.name + TAB_TYPE - }, - }); - console.log(tab); - } - }); - menu.addItem({ - icon: "iconImage", - label: "Open Asset Tab(First open the Chinese help document)", - click: () => { - const tab = openTab({ - app: this.app, - asset: { - path: "assets/paragraph-20210512165953-ag1nib4.svg" - } - }); - console.log(tab); - } - }); - menu.addItem({ - icon: "iconFile", - label: "Open Doc Tab(open doc first)", - click: async () => { - const tab = await openTab({ - app: this.app, - doc: { - id: this.getEditor().protyle.block.rootID, - } - }); - console.log(tab); - } - }); - menu.addItem({ - icon: "iconSearch", - label: "Open Search Tab", - click: () => { - const tab = openTab({ - app: this.app, - search: { - k: "SiYuan" - } - }); - console.log(tab); - } - }); - menu.addItem({ - icon: "iconRiffCard", - label: "Open Card Tab", - click: () => { - const tab = openTab({ - app: this.app, - card: { - type: "all" - } - }); - console.log(tab); - } - }); - menu.addItem({ - icon: "iconLayout", - label: "Open Float Layer(open doc first)", - click: () => { - this.addFloatLayer({ - refDefs: [{ refID: this.getEditor().protyle.block.rootID }], - x: window.innerWidth - 768 - 120, - y: 32, - isBacklink: false - }); - } - }); - menu.addItem({ - icon: "iconOpenWindow", - label: "Open Doc Window(open doc first)", - click: () => { - openWindow({ - doc: { id: this.getEditor().protyle.block.rootID } - }); - } - }); - } else { - menu.addItem({ - icon: "iconFile", - label: "Open Doc(open doc first)", - click: () => { - openMobileFileById(this.app, this.getEditor().protyle.block.rootID); - } - }); - } - menu.addItem({ - icon: "iconLock", - label: "Lockscreen", - click: () => { - lockScreen(this.app); - } - }); - menu.addItem({ - icon: "iconQuit", - label: "Exit Application", - click: () => { - exitSiYuan(); - } - }); - menu.addItem({ - icon: "iconDownload", - label: "Save Layout", - click: () => { - saveLayout(() => { - showMessage("Layout saved"); - }); - } - }); - menu.addItem({ - icon: "iconScrollHoriz", - label: "Event Bus", - type: "submenu", - submenu: [{ - icon: "iconSelect", - label: "On ws-main", - click: () => { - this.eventBus.on("ws-main", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off ws-main", - click: () => { - this.eventBus.off("ws-main", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On click-blockicon", - click: () => { - this.eventBus.on("click-blockicon", this.blockIconEventBindThis); - } - }, { - icon: "iconClose", - label: "Off click-blockicon", - click: () => { - this.eventBus.off("click-blockicon", this.blockIconEventBindThis); - } - }, { - icon: "iconSelect", - label: "On click-pdf", - click: () => { - this.eventBus.on("click-pdf", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off click-pdf", - click: () => { - this.eventBus.off("click-pdf", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On click-editorcontent", - click: () => { - this.eventBus.on("click-editorcontent", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off click-editorcontent", - click: () => { - this.eventBus.off("click-editorcontent", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On click-editortitleicon", - click: () => { - this.eventBus.on("click-editortitleicon", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off click-editortitleicon", - click: () => { - this.eventBus.off("click-editortitleicon", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On click-flashcard-action", - click: () => { - this.eventBus.on("click-flashcard-action", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off click-flashcard-action", - click: () => { - this.eventBus.off("click-flashcard-action", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On open-noneditableblock", - click: () => { - this.eventBus.on("open-noneditableblock", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off open-noneditableblock", - click: () => { - this.eventBus.off("open-noneditableblock", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On loaded-protyle-static", - click: () => { - this.eventBus.on("loaded-protyle-static", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off loaded-protyle-static", - click: () => { - this.eventBus.off("loaded-protyle-static", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On loaded-protyle-dynamic", - click: () => { - this.eventBus.on("loaded-protyle-dynamic", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off loaded-protyle-dynamic", - click: () => { - this.eventBus.off("loaded-protyle-dynamic", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On switch-protyle", - click: () => { - this.eventBus.on("switch-protyle", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off switch-protyle", - click: () => { - this.eventBus.off("switch-protyle", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On destroy-protyle", - click: () => { - this.eventBus.on("destroy-protyle", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off destroy-protyle", - click: () => { - this.eventBus.off("destroy-protyle", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On open-menu-doctree", - click: () => { - this.eventBus.on("open-menu-doctree", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off open-menu-doctree", - click: () => { - this.eventBus.off("open-menu-doctree", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On open-menu-blockref", - click: () => { - this.eventBus.on("open-menu-blockref", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off open-menu-blockref", - click: () => { - this.eventBus.off("open-menu-blockref", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On open-menu-fileannotationref", - click: () => { - this.eventBus.on("open-menu-fileannotationref", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off open-menu-fileannotationref", - click: () => { - this.eventBus.off("open-menu-fileannotationref", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On open-menu-tag", - click: () => { - this.eventBus.on("open-menu-tag", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off open-menu-tag", - click: () => { - this.eventBus.off("open-menu-tag", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On open-menu-link", - click: () => { - this.eventBus.on("open-menu-link", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off open-menu-link", - click: () => { - this.eventBus.off("open-menu-link", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On open-menu-image", - click: () => { - this.eventBus.on("open-menu-image", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off open-menu-image", - click: () => { - this.eventBus.off("open-menu-image", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On open-menu-av", - click: () => { - this.eventBus.on("open-menu-av", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off open-menu-av", - click: () => { - this.eventBus.off("open-menu-av", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On open-menu-content", - click: () => { - this.eventBus.on("open-menu-content", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off open-menu-content", - click: () => { - this.eventBus.off("open-menu-content", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On open-menu-breadcrumbmore", - click: () => { - this.eventBus.on("open-menu-breadcrumbmore", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off open-menu-breadcrumbmore", - click: () => { - this.eventBus.off("open-menu-breadcrumbmore", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On open-menu-inbox", - click: () => { - this.eventBus.on("open-menu-inbox", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off open-menu-inbox", - click: () => { - this.eventBus.off("open-menu-inbox", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On input-search", - click: () => { - this.eventBus.on("input-search", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off input-search", - click: () => { - this.eventBus.off("input-search", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On paste", - click: () => { - this.eventBus.on("paste", this.eventBusPaste); - } - }, { - icon: "iconClose", - label: "Off paste", - click: () => { - this.eventBus.off("paste", this.eventBusPaste); - } - }, { - icon: "iconSelect", - label: "On open-siyuan-url-plugin", - click: () => { - this.eventBus.on("open-siyuan-url-plugin", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off open-siyuan-url-plugin", - click: () => { - this.eventBus.off("open-siyuan-url-plugin", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On open-siyuan-url-block", - click: () => { - this.eventBus.on("open-siyuan-url-block", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off open-siyuan-url-block", - click: () => { - this.eventBus.off("open-siyuan-url-block", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On opened-notebook", - click: () => { - this.eventBus.on("opened-notebook", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off opened-notebook", - click: () => { - this.eventBus.off("opened-notebook", this.eventBusLog); - } - }, { - icon: "iconSelect", - label: "On closed-notebook", - click: () => { - this.eventBus.on("closed-notebook", this.eventBusLog); - } - }, { - icon: "iconClose", - label: "Off closed-notebook", - click: () => { - this.eventBus.off("closed-notebook", this.eventBusLog); - } - }] - }); - menu.addSeparator(); - menu.addItem({ - icon: "iconSparkles", - label: this.data[STORAGE_NAME].readonlyText || "Readonly", - type: "readonly", - }); - if (this.isMobile) { - menu.fullscreen(); - } else { - menu.open({ - x: rect.right, - y: rect.bottom, - isLeft: true, - }); - } - } - - private getEditor() { - const editors = getAllEditor(); - if (editors.length === 0) { - showMessage("please open doc first"); - return; - } - return editors[0]; - } -} +} \ No newline at end of file diff --git a/src/languagetool.ts b/src/languagetool.ts new file mode 100644 index 0000000..bc4ee97 --- /dev/null +++ b/src/languagetool.ts @@ -0,0 +1,80 @@ +import {PluginSettings} from "@/settings"; + +export type Suggestion = { + message: string + shortMessage: string + replacements: Array<{ + value: string + type: string + }> + offset: number + length: number + context: { + text: string + offset: number + length: number + } + sentence: string + type: { + typeName: string + } + rule: { + id: string + description: string + issueType: string + category: { + id: string + name: string + } + isPremium: boolean + confidence: number + } + ignoreForIncompleteSentence: boolean + contextForSureMatch: number +} + +export type Language = { name: string; code: string; longCode: string; } +interface HTTPError extends Error { + status?: number; +} + +export class LanguageTool { + + public static async check(text: string, language: string, settings: PluginSettings): Promise { + + const body = new URLSearchParams({ + text: text, + language: language, + level: settings.picky ? 'picky' : 'default', + motherTongue: settings.motherTongue == '' ? window.navigator.language : settings.motherTongue, + }); + + if(settings.username != '') { + body.append('username', settings.username); + } + if(settings.apiKey) { + body.append('apiKey', settings.apiKey); + } + if(language == 'auto') { + body.append('preferredVariants', settings.preferredVariants) + } + + const res = await fetch(settings.server + 'v2/check', {method: 'POST', body}); + if(res.status != 200) { + const err = new Error('Network error') as HTTPError + err.status = res.status; + throw err + } + + const json = await res.json(); + return json.matches; + + } + + public static async getLanguages(settings: PluginSettings): Promise { + const res = await fetch(settings.server + 'v2/languages', {method: 'GET'}); + return await res.json(); + } + + +} \ No newline at end of file diff --git a/src/menus.ts b/src/menus.ts new file mode 100644 index 0000000..b0d5fcd --- /dev/null +++ b/src/menus.ts @@ -0,0 +1,151 @@ +import {Menu, showMessage, subMenu} from 'siyuan'; +import SpellCheckPlugin from "@/index"; +import {getBlockAttrs, setBlockAttrs} from "@/api"; +import {LanguageTool} from "@/languagetool"; +import {PluginSettings, Settings} from "@/settings"; +import {ProtyleHelpers} from "@/protyleHelpers"; +import {SuggestionEngine} from "@/suggestions"; + +export class Menus { + + private plugin: SpellCheckPlugin + public constructor(plugin: SpellCheckPlugin) { + this.plugin = plugin + } + + public addCorrectionsToParagraphMenu(blockID: string, suggestionNumber: number, menu: subMenu) { + + const storedBlock = this.plugin.suggestions.getStorage()[blockID] + if (suggestionNumber == -1) { + return + } + void this.plugin.analytics.sendEvent('menu-open-wrong'); + + let suggestion = storedBlock.suggestions[suggestionNumber] + menu.addItem({ + icon: 'info', + label: suggestion.shortMessage == '' ? suggestion.message : suggestion.shortMessage, + click: async () => { + showMessage(suggestion.message, 5000, 'info') + void this.plugin.analytics.sendEvent('menu-click-info'); + } + }) + + if(suggestion.type.typeName == 'UnknownWord') { + // add to dictionary + menu.addItem({ + icon: 'add', + label: this.plugin.i18nx.textMenu.addToDictionary, + click: async () => { + void this.plugin.analytics.sendEvent('menu-click-add-to-dictionary'); + const word = SuggestionEngine.suggestionToWrongText(suggestion) + await Settings.addToDictionary(word, this.plugin.settingsUtil) + showMessage(this.plugin.i18nx.textMenu.addedToDictionary + word, 5000, 'info') + await this.plugin.suggestions.renderSuggestions(blockID) + } + }) + } + + // corrections + suggestion.replacements.forEach((replacement, correctionNumber) => { + menu.addItem({ + icon: 'spellcheck', + label: replacement.value, + click: async () => { + void this.plugin.analytics.sendEvent('menu-click-correct', { + 'type': suggestion.rule.category.id + }); + if(this.plugin.settingsUtil.get('experimentalCorrect')) { + void this.plugin.suggestions.correctSuggestion(blockID, suggestionNumber, correctionNumber) + }else{ + void navigator.clipboard.writeText(replacement.value) + showMessage(this.plugin.i18nx.errors.correctionNotEnabled, 5000, 'info') + } + } + }) + }) + + } + + public async addSettingsToDocMenu(docID: string, menu: subMenu) { + + menu.addItem({ + icon: 'info', + label: this.plugin.i18nx.docMenu.documentStatus, + click: async () => { + const settings = await ProtyleHelpers.getDocumentSettings(docID, this.plugin.settingsUtil.get('enabledByDefault'), this.plugin.settingsUtil.get('defaultLanguage')) + if(settings == null) { + void this.plugin.analytics.sendEvent('docmenu-click-info-notebook'); + showMessage(this.plugin.i18nx.errors.notImplementedNotebookSettings, 5000, 'info') + return + } + showMessage(` + ${this.plugin.i18nx.docMenu.documentStatus}
    + ${this.plugin.i18nx.docMenu.status}: ${settings.enabled ? this.plugin.i18nx.docMenu.enabled : this.plugin.i18nx.docMenu.disabled}
    + ${this.plugin.i18nx.docMenu.language}: ${settings.language} + `, 5000, 'info') + void this.plugin.analytics.sendEvent('docmenu-click-info'); + } + }) + + menu.addItem({ + icon: 'toggle', + label: this.plugin.i18nx.docMenu.toggleSpellCheck, + click: async () => { + void this.plugin.analytics.sendEvent('docmenu-click-toggle'); + const attrs = await getBlockAttrs(docID) + const settings = await ProtyleHelpers.getDocumentSettings(docID, this.plugin.settingsUtil.get('enabledByDefault'), this.plugin.settingsUtil.get('defaultLanguage')) + if(settings == null) { + void this.plugin.analytics.sendEvent('docmenu-click-info-notebook'); + showMessage(this.plugin.i18nx.errors.notImplementedNotebookSettings, 5000, 'info') + return + } + attrs[SpellCheckPlugin.ENABLED_ATTR] = settings.enabled ? 'false' : 'true' + await setBlockAttrs(docID, attrs) + location.reload() + } + }) + + menu.addItem({ + icon: 'language', + label: this.plugin.i18nx.docMenu.setDocumentLanguage, + click: async (_, ev: MouseEvent) => { + void this.plugin.analytics.sendEvent('docmenu-click-setlang-1'); + const languages = await LanguageTool.getLanguages(this.plugin.settingsUtil.dump()) + const langMenu = new Menu('spellCheckLangMenu'); + langMenu.addItem({ + icon: 'autodetect', + label: this.plugin.i18nx.docMenu.autodetectLanguage, + click: async () => { + const attrs = await getBlockAttrs(docID) + attrs[SpellCheckPlugin.LANGUAGE_ATTR] = 'auto' + await setBlockAttrs(docID, attrs) + void this.plugin.analytics.sendEvent('docmenu-click-setlang-2', { + 'language': 'auto' + }); + location.reload() + } + }); + languages.forEach(language => { + langMenu.addItem({ + icon: 'language', + label: language.name + ' [' + language.longCode + ']', + click: async () => { + const attrs = await getBlockAttrs(docID) + attrs[SpellCheckPlugin.LANGUAGE_ATTR] = language.longCode + await setBlockAttrs(docID, attrs) + void this.plugin.analytics.sendEvent('docmenu-click-setlang-2', { + 'language': language.longCode + }); + location.reload() + } + }); + }); + langMenu.open({ x: ev.clientX, y: ev.clientY }); + + } + }) + + } + +} \ No newline at end of file diff --git a/src/protyleHelpers.ts b/src/protyleHelpers.ts new file mode 100644 index 0000000..de88a46 --- /dev/null +++ b/src/protyleHelpers.ts @@ -0,0 +1,65 @@ +import {getBlockAttrs} from "@/api"; +import SpellCheckPlugin from "@/index"; + +export class ProtyleHelpers { + + // We shouldn't use JavaScript elements to get and set data in blocks, but the kernel API is noticeably too slow for this. + // We must try to keep the dependency to the HTML to a minimum. + + // doesn't use kernel API, so it's faster + public static fastGetBlockElement(blockID: string): Element { + const wrapper = Array.from( + document.querySelectorAll(`div[data-node-id="${blockID}"]`) + ).find(el => + !el.closest('.protyle-wysiwyg__embed') // true = not inside that class + ); + + return wrapper?.querySelector(':scope > [contenteditable="true"]') ?? null; + } + + public static fastGetBlockHTML(blockID: string): string { + return this.fastGetBlockElement(blockID).innerHTML + } + + public static fastGetBlockText(blockID: string): string { + return this.fastGetBlockElement(blockID)?.textContent + } + + public static fastGetTitleElement(docID: string) { + const container = document.querySelector(`div.protyle-title.protyle-wysiwyg--attr[data-node-id="${docID}"]`); + if (!container) return null; + return container.querySelector('div.protyle-title__input[contenteditable="true"]'); + } + + public static fastGetOverlayElement(blockID: string): Element { + return document.querySelector(`div.underline-overlay[for-block-id="${blockID}"]`) + } + + // given an element such as a span inside a block, return its blockID + public static getNodeId(el: Element) { + let i = 0; + while (el && i < 50) { + if (el.hasAttribute('data-node-id')) { + return el.getAttribute('data-node-id'); + } + el = el.parentElement; + i++; + } + return null; + } + + public static async getDocumentSettings(docID: string, enabledByDefault: boolean, defaultLanguage: string): Promise<{enabled: boolean, language: string} | null> { + const attrs = await getBlockAttrs(docID) + if(attrs == null) { return null } + return { + enabled: (SpellCheckPlugin.ENABLED_ATTR in attrs) ? attrs[SpellCheckPlugin.ENABLED_ATTR] == 'true' : enabledByDefault, + language: (SpellCheckPlugin.LANGUAGE_ATTR in attrs) ? attrs[SpellCheckPlugin.LANGUAGE_ATTR] : defaultLanguage + } + } + + public static isProtyleReady(docID: string): boolean { + const protyleTitleContainer = document.querySelector(`div[class="protyle-title protyle-wysiwyg--attr"]`) + return protyleTitleContainer.getAttribute('data-node-id') == docID + } + +} \ No newline at end of file diff --git a/src/setting-example.svelte b/src/setting-example.svelte deleted file mode 100644 index 2a2c809..0000000 --- a/src/setting-example.svelte +++ /dev/null @@ -1,139 +0,0 @@ - - -
    -
      - {#each groups as group} - -
    • { - focusGroup = group; - }} - on:keydown={() => {}} - > - {group} -
    • - {/each} -
    -
    - { console.debug("Click:", detail.key); }} - > -
    - 💡 This is our default settings. -
    -
    - { console.debug("Click:", detail.key); }} - > - -
    -
    - - - diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..39d80fc --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,159 @@ +import {SettingUtils} from "@/libs/setting-utils"; +import {showMessage} from 'siyuan'; +import {LanguageTool} from "@/languagetool"; +import SpellCheckPlugin from "@/index"; + +export type PluginSettings = { + server: string + username: string + apiKey: string + picky: boolean + motherTongue: string + preferredVariants: string + enabledByDefault: boolean + defaultLanguage: string + preferredLanguages: string + analytics: boolean +} + + +export class Settings { + + static async init(plugin: SpellCheckPlugin): Promise { + + const to = plugin.i18nx.settings + const su = new SettingUtils({ + plugin: plugin, name: plugin.name + }); + + su.addItem({ + type: 'hint', + key: 'info', + title: to.info.title, + description: to.info.description, + value: '' + }) + + su.addItem({ + type: 'checkbox', + key: 'experimentalCorrect', + title: to.experimentalCorrect.title, + description: to.experimentalCorrect.description, + value: false + }) + + su.addItem({ + type: 'textarea', + key: 'customDictionary', + title: to.customDictionary.title, + description: to.customDictionary.description, + value: 'SySpell,SiYuan' + }) + + su.addItem({ + type: 'textinput', + key: 'server', + title: to.server.title, + description: to.server.description, + value: 'https://lt.massive.box/' + }) + + await su.load() // needed to fetch languages from server + let languagesKV = {} + try { + let languages = await LanguageTool.getLanguages(su.dump()) + languages.forEach(language => { + languagesKV[language.longCode] = language.name + ' [' + language.longCode + ']' + }) + } catch { + showMessage(plugin.i18nx.errors.checkServer, -1, 'error') + showMessage(plugin.i18nx.errors.fatal, -1, 'error') + } + + su.addItem({ + type: 'textinput', + key: 'username', + title: to.username.title, + description: to.username.description, + value: '' + }) + + su.addItem({ + type: 'textinput', + key: 'apiKey', + title: to.apiKey.title, + description: to.apiKey.description, + value: '' + }) + + su.addItem({ + type: 'checkbox', + key: 'picky', + title: to.picky.title, + description: to.picky.description, + value: false + }) + + su.addItem({ + type: 'select', + key: 'motherTongue', + title: to.motherTongue.title, + description: to.motherTongue.description, + value: (window.navigator.language in languagesKV) ? window.navigator.language : 'en-US', + options: languagesKV + }) + + su.addItem({ + type: 'textinput', + key: 'preferredVariants', + title: to.preferredVariants.title, + description: to.preferredVariants.description, + value: 'en-US,de-DE' + }) + + su.addItem({ + type: 'checkbox', + key: 'enabledByDefault', + title: to.enabledByDefault.title, + description: to.enabledByDefault.description, + value: true + }) + + languagesKV['auto'] = plugin.i18nx.docMenu.autodetectLanguage + su.addItem({ + type: 'select', + key: 'defaultLanguage', + title: to.defaultLanguage.title, + description: to.defaultLanguage.description, + options: languagesKV, + value: 'auto' + }) + + su.addItem({ + type: 'checkbox', + key: 'analytics', + title: to.analytics.title, + description: to.analytics.description, + value: true + }) + + await su.load() + return su + + } + + // dictionary is a string of words separated by commas + static isInCustomDictionary(word: string, settings: SettingUtils) { + const dictionary = settings.get('customDictionary').split(',') + return dictionary.includes(word) + } + + static addToDictionary(word: string, settings: SettingUtils) { + const dictionary = settings.get('customDictionary').split(',') + if (!dictionary.includes(word)) { + dictionary.push(word) + return settings.setAndSave('customDictionary', dictionary.join(',')) + } + } + +} \ No newline at end of file diff --git a/src/spellchecker.ts b/src/spellchecker.ts new file mode 100644 index 0000000..3054e8b --- /dev/null +++ b/src/spellchecker.ts @@ -0,0 +1,151 @@ +import {ProtyleHelpers} from "@/protyleHelpers"; + +export class SpellChecker { + + private readonly blockID: string; + private readonly docID: string; + private block: HTMLElement; + private overlay: HTMLElement; + + constructor(blockID: string, docID: string) { + this.blockID = blockID; + this.docID = docID; + this.setBlock() + } + + private setBlock() { + + this.block = ProtyleHelpers.fastGetBlockElement(this.blockID) + let overlay = ProtyleHelpers.fastGetOverlayElement(this.blockID) + + if(overlay == null) { + this.overlay = document.createElement('div') + this.overlay.className = 'underline-overlay'; + this.overlay.setAttribute('for-block-id', this.blockID) + const protyleTitle = ProtyleHelpers.fastGetTitleElement(this.docID) + protyleTitle?.append(this.overlay) + }else{ + if(this.overlay == null) { + this.overlay = overlay + } + } + + } + + public highlightCharacterRange(startIndex: number, endIndex: number) { + + this.setBlock() + + // Get all text content + const textContent = this.block?.innerText || ''; + if (startIndex >= textContent.length || endIndex > textContent.length || startIndex >= endIndex) { + console.log('Invalid range'); + return; + } + + // Find the text nodes and character positions + const range = this.createRangeFromCharacterIndices(startIndex, endIndex); + if (range) { + this.createUnderlineFromRange(range, endIndex - startIndex); + } + } + + private createRangeFromCharacterIndices(startIndex: number, endIndex: number) { + // Get the innerHTML and create a temporary container to parse it + const tempContainer = document.createElement('div'); + tempContainer.innerHTML = this.block.innerHTML; + + // Walk through all nodes (including text and elements) to build character map + const walker = document.createTreeWalker( + this.block, + NodeFilter.SHOW_TEXT, + null + ); + + let currentIndex = 0; + let startNode = null, startOffset = 0; + let endNode = null, endOffset = 0; + let textNode; + + // Build a map of character positions to actual DOM text nodes + while (textNode = walker.nextNode()) { + const nodeLength = textNode.length; + const nodeEndIndex = currentIndex + nodeLength; + + // Find start position + if (startNode === null && startIndex >= currentIndex && startIndex < nodeEndIndex) { + startNode = textNode; + startOffset = startIndex - currentIndex; + } + + // Find end position + if (endIndex > currentIndex && endIndex <= nodeEndIndex) { + endNode = textNode; + endOffset = endIndex - currentIndex; + break; + } + + currentIndex = nodeEndIndex; + } + + if (startNode && endNode) { + const range = document.createRange(); + range.setStart(startNode, startOffset); + range.setEnd(endNode, endOffset); + return range; + } + + return null; + } + + private createUnderlineFromRange(range: Range, charsCount: number) { + const rects = range.getClientRects(); + const editorRect = this.block.getBoundingClientRect(); + + for (let i = 0; i < rects.length; i++) { + const rect = rects[i]; + const underline = document.createElement('div'); + underline.className = 'error-underline'; + + const left = rect.left - editorRect.left + this.block.scrollLeft; + const top = rect.bottom - editorRect.top - 2 + this.block.scrollTop; + const width = rect.width; + + const offset = SpellChecker.distance(this.overlay, this.block) + + underline.style.left = (left + offset.h) + 'px'; + underline.style.top = (top + 2 + offset.v) + 'px'; + underline.style.width = width + 'px'; + + if(!SpellChecker.checkDontUnderline(width, charsCount)) { + this.overlay.appendChild(underline); + } + } + } + + // if the underline is too wide for the number of characters that are underlined, we don't render it + // this is a consequence of using .innerText: things like tags are only a character + private static checkDontUnderline(width: number, charsCount: number) { + const maxWidthPerChar = 16; + return width > maxWidthPerChar * charsCount + } + + private static distance(elA: HTMLElement, elB: HTMLElement): {h: number, v: number} { + const rectA = elA.getBoundingClientRect(); + const rectB = elB.getBoundingClientRect(); + return { + h: Math.abs(rectA.left - rectB.left), + v: Math.abs(rectA.top - rectB.top) + } + } + + public clearUnderlines() { + this.overlay.innerHTML = ''; + } + + public destroy() { + let overlay = ProtyleHelpers.fastGetOverlayElement(this.blockID) + overlay?.remove(); + } + +} \ No newline at end of file diff --git a/src/suggestions.ts b/src/suggestions.ts new file mode 100644 index 0000000..5548da5 --- /dev/null +++ b/src/suggestions.ts @@ -0,0 +1,191 @@ +import {ProtyleHelpers} from "@/protyleHelpers"; +import {LanguageTool, Suggestion} from "@/languagetool"; +import {PluginSettings, Settings} from "@/settings"; +import {getChildBlocks, updateBlock} from "@/api"; +import {SpellChecker} from "@/spellchecker"; +import {showMessage} from "siyuan"; +import SpellCheckPlugin from "@/index"; + +interface StoredBlock { + spellChecker: SpellChecker; + suggestions: Suggestion[]; +} + +type BlockStorage = Record; + +export class SuggestionEngine { + + private blockStorage: BlockStorage = {}; + private plugin: SpellCheckPlugin; + + public documentID: string; + public documentEnabled: boolean = false; + public documentLanguage: string = 'auto'; + + constructor(plugin: SpellCheckPlugin) { + this.plugin = plugin + } + + public getStorage(): BlockStorage { + return this.blockStorage + } + public clearStorage() { + for(let blockID in this.blockStorage) { + this.blockStorage[blockID].spellChecker.destroy() + delete this.blockStorage[blockID] + } + } + + private async discoverBlocks(blockID: string) { + const children = await getChildBlocks(blockID) + if(children.length == 0) { + if(!(blockID in this.blockStorage)) { + const spellChecker = new SpellChecker(blockID, this.documentID) + this.blockStorage[blockID] = { + spellChecker: spellChecker, + suggestions: [] + } + } + }else{ + for (const child of children) { + await this.discoverBlocks(child.id) + } + } + } + + public async forAllBlocksSuggest(docID: string, suggest: boolean, render: boolean, remove: boolean) { + if(!this.documentEnabled) { return } + if(suggest) { + await this.discoverBlocks(docID) // updates this.blockStorage + } + const blockPromises = Object.keys(this.blockStorage).map(async (blockID) => { + if(suggest) { + await this.suggestForBlock(blockID) + } + if(render) { + await this.renderSuggestions(blockID) + } + if(remove) { + await this.removeSuggestionsAndRender(blockID) + } + }); + await Promise.all(blockPromises); + } + + public async suggestAndRender(blockID: string) { + if(!this.documentEnabled) { return } + await this.suggestForBlock(blockID) + await this.renderSuggestions(blockID) + } + + public async suggestForBlock(blockID: string) { + + let suggestions: Suggestion[] + const text = ProtyleHelpers.fastGetBlockText(blockID) + if(text == null || !this.documentEnabled) { + return + } + if(!(blockID in this.blockStorage)) { + await this.discoverBlocks(blockID) + return this.suggestForBlock(blockID) + } + + try { + suggestions = await LanguageTool.check(text, this.documentLanguage, this.plugin.settingsUtil.dump()) + }catch (_) { + showMessage(this.plugin.i18nx.errors.checkServer, 5000, 'error') + } + this.blockStorage[blockID].suggestions = suggestions + + } + + public async removeSuggestionsAndRender(blockID: string) { + this.blockStorage[blockID].spellChecker.clearUnderlines() + } + + public async renderSuggestions(blockID: string) { + if(!(blockID in this.blockStorage) || !this.documentEnabled) { + return + } + this.blockStorage[blockID].spellChecker.clearUnderlines() + this.blockStorage[blockID].suggestions.forEach(suggestion => { + if(!Settings.isInCustomDictionary(SuggestionEngine.suggestionToWrongText(suggestion), this.plugin.settingsUtil)) { + this.blockStorage[blockID].spellChecker.highlightCharacterRange(suggestion.offset, suggestion.offset + suggestion.length) + } + }) + } + + static suggestionToWrongText(suggestion: Suggestion): string { + return suggestion.context.text.slice(suggestion.context.offset, suggestion.context.offset + suggestion.context.length) + } + + private getAbsoluteOffsetInBlock(range: Range, blockID: string): number { + + const block = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE + ? (range.commonAncestorContainer as Element).closest(`[data-node-id="${blockID}"]`) + : (range.commonAncestorContainer as Text).parentElement!.closest(`[data-node-id="${blockID}"]`); + if (!block) return -1; + + const measureToRange = range.cloneRange(); + measureToRange.setStart(block, 0); + measureToRange.setEnd(range.startContainer, range.startOffset); + + return measureToRange.toString().length; + + } + + // given the content of a "wrong" span, get the suggestion number + public getSuggestionNumber(blockID: string, range: Range): number { + + const offset = this.getAbsoluteOffsetInBlock(range, blockID) + let suggNo = -1 + + this.blockStorage[blockID].suggestions.forEach((suggestion, i) => { + if(offset >= suggestion.offset && offset <= suggestion.offset + suggestion.length) { + suggNo = i + } + }) + + return suggNo + + } + + // correct the error in the block + public async correctSuggestion(blockID: string, suggestionNumber: number, correctionNumber: number) { + + if (suggestionNumber == -1) { + return + } + + console.log("dbg " + blockID + ' ' + suggestionNumber + ' ' + correctionNumber) + console.log(this.blockStorage) + const suggestion = this.blockStorage[blockID].suggestions[suggestionNumber] + const rich = ProtyleHelpers.fastGetBlockHTML(blockID) + const fixedOffset = this.adjustIndexForTags(rich, suggestion.offset) + const newStr = rich.slice(0, fixedOffset) + suggestion.replacements[correctionNumber].value + rich.slice(fixedOffset + suggestion.length) + + console.log("new str " + newStr); + await updateBlock('markdown', window.Lute.New().BlockDOM2Md(newStr), blockID) + void this.suggestAndRender(blockID) + + } + + private adjustIndexForTags(html: string, plainIdx: number): number { + let plain = 0; // characters consumed in s1 + let rich = 0; // characters consumed in s2 + + while (rich < html.length && plain < plainIdx) { + if (html[rich] === '<') { + // skip entire tag in s2 + while (rich < html.length && html[rich] !== '>') rich++; + rich++; // include the '>' + } else { + // normal character: advance both counters + rich++; + plain++; + } + } + return rich; // index inside s2 + } + +} \ No newline at end of file