generated from mirrors/plugin-sample-vite-svelte
First commit
Some checks failed
Build on Push and create Release on Tag / build (push) Failing after 28s
Some checks failed
Build on Push and create Release on Tag / build (push) Failing after 28s
This commit is contained in:
parent
04f54e248a
commit
c69eaca7e9
21 changed files with 1147 additions and 1805 deletions
|
@ -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 }}
|
308
README.md
308
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
|
||||
<details>
|
||||
<summary>Why does this matter?</summary>
|
||||
|
||||
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
|
||||
</details>
|
||||
|
||||
> **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 <kbd>Use this template</kbd> 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 `<siyuan workspace>/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:
|
||||
|
||||

|
||||
|
||||
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.
|
276
README_zh_CN.md
276
README_zh_CN.md
|
@ -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. 通过 <kbd>Use this template</kbd> 按钮将该库文件复制到你自己的库中,请注意库名和插件名称一致,默认分支必须为 `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` 为思源的插件目录 `<siyuan workspace>/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**,打开配置
|
||||
|
||||

|
||||
|
||||
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 创建日记),请手动为文档添加该属性
|
BIN
icon.png
BIN
icon.png
Binary file not shown.
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3.6 KiB |
12
package.json
12
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",
|
||||
|
|
29
plugin.json
29
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"
|
||||
]
|
||||
}
|
||||
|
|
BIN
preview.png
BIN
preview.png
Binary file not shown.
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 58 KiB |
|
@ -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 <a href=\"https://github.com/siyuan-note/plugin-sample\">offical sample</a>, 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, <a href='https://massive.box'>MassiveBox</a>. It does not save, sell or share the content of your text.<br>Running a LanguageTool server is resource intensive, so if you find this plugin useful, please consider <a href=\"https://s.massive.box/siyuan-plugin-donate\">donating</a> to help me keep it alive!"
|
||||
},
|
||||
"hintTitle":"About",
|
||||
"hintDesc":"<a href='https://github.com/siyuan-note/plugin-sample-vite-svelte'>plugin-sample-vite-svelte</a><br>@frostime<br>@88250<br>@zxkmm"
|
||||
"server": {
|
||||
"title": "LanguageTool server",
|
||||
"description": "The URL of the LanguageTool server, with the trailing slash. Some suggestions:\n<li>Free server provided by the plugin developer, slower but without limits: <code>https://lt.massive.box/</code></li>\n<li>Free server provided by LanguageTool, very strict rate limits: <code>https://languagetool.org/api/</code></li>\n<li>If you have <a href=\"https://languagetool.org/premium\">LanguageTool Premium</a>: <code>https://api.languagetoolplus.com/</code></li>\n<li>If you're running the self-hosted <a href=\"https://dev.languagetool.org/http-server\">embedded server</a>: <code>http://localhost:8081/</code></li>"
|
||||
},
|
||||
"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. <a href=\"https://languagetool.org/editor/settings/access-tokens\">Your API key</a>"
|
||||
},
|
||||
"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: <code>en-US,de-DE</code> or <code>en-GB,de-DE</code>"
|
||||
},
|
||||
"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.<br />\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.<br>If you select auto-detection, some blocks may be recognized incorrectly."
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"description": "Enable to send anonymous usage data to the developer. <a href='https://s.massive.box/jsdraw-plugin-privacy'>Privacy Policy</a>"
|
||||
},
|
||||
"customDictionary": {
|
||||
"title": "Custom dictionary",
|
||||
"description": "A comma-separated list of words that will not trigger the grammar checker. For example: <code>hello,SiYuan</code><br>After editing and saving, reload SiYuan to apply."
|
||||
},
|
||||
"experimentalCorrect": {
|
||||
"title": "<b>[Feature preview]</b> Apply corrections when selected",
|
||||
"description": "<b>[Feature preview]</b> 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.<br><br>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. <small>Suggestions can be auto-applied when selected. Visit the plugin's settings to enable.</small>",
|
||||
"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: "
|
||||
}
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"addTopBarIcon": "使用插件添加一个顶栏按钮",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"byeMenu": "再见,菜单!",
|
||||
"helloPlugin": "你好,插件!",
|
||||
"byePlugin": "再见,插件!",
|
||||
"showDialog": "弹出一个对话框",
|
||||
"removedData": "数据已删除",
|
||||
"confirmRemove": "确认删除 ${name} 中的数据?",
|
||||
"insertEmoji": "插入表情",
|
||||
"removeSpace": "移除空格",
|
||||
"getTab": "在日志中打印出已打开的所有自定义页签",
|
||||
"name": "思源",
|
||||
"hello": {
|
||||
"makesure": "使用这个模板之前,请阅读<a href=\"https://github.com/siyuan-note/plugin-sample\">官方教程</a>, 确保自己已经理解了插件的基本开发流程。"
|
||||
},
|
||||
"hintTitle": "关于",
|
||||
"hintDesc": "<a href='https://github.com/siyuan-note/plugin-sample-vite-svelte'>🔗 plugin-sample-vite-svelte</a><br>💻 @frostime<br>💻 @88250<br>💻 @zxkmm"
|
||||
}
|
47
src/analytics.ts
Normal file
47
src/analytics.ts
Normal file
|
@ -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<string, string>) {
|
||||
|
||||
if(!this.enabled) return;
|
||||
|
||||
const sendData: Record<string, string> = (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,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
<!--
|
||||
Copyright (c) 2024 by frostime. All Rights Reserved.
|
||||
Author : frostime
|
||||
Date : 2023-11-19 12:30:45
|
||||
FilePath : /src/hello.svelte
|
||||
LastEditTime : 2024-10-16 14:37:50
|
||||
Description :
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
// import { version } from "@/api";
|
||||
import { showMessage, fetchPost, Protyle } from "siyuan";
|
||||
|
||||
export let app;
|
||||
export let blockID: string;
|
||||
|
||||
let time: string = "";
|
||||
|
||||
let divProtyle: HTMLDivElement;
|
||||
let protyle: any;
|
||||
|
||||
onMount(async () => {
|
||||
// ver = await version();
|
||||
fetchPost("/api/system/currentTime", {}, (response) => {
|
||||
time = new Date(response.data).toString();
|
||||
});
|
||||
if (blockID) {
|
||||
protyle = await initProtyle();
|
||||
} else {
|
||||
divProtyle.innerHTML = "Please open a document first";
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
showMessage("Hello panel closed");
|
||||
protyle?.destroy();
|
||||
});
|
||||
|
||||
async function initProtyle() {
|
||||
return new Protyle(app, divProtyle, {
|
||||
blockId: blockID
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="b3-dialog__content">
|
||||
<div>appId:</div>
|
||||
<div class="fn__hr"></div>
|
||||
<div class="plugin-sample__time">${app?.appId}</div>
|
||||
<div class="fn__hr"></div>
|
||||
<div class="fn__hr"></div>
|
||||
<div>API demo:</div>
|
||||
<div class="fn__hr" />
|
||||
<div class="plugin-sample__time">
|
||||
System current time: <span id="time">{time}</span>
|
||||
</div>
|
||||
<div class="fn__hr" />
|
||||
<div class="fn__hr" />
|
||||
<div>Protyle demo: id = {blockID}</div>
|
||||
<div class="fn__hr" />
|
||||
<div id="protyle" style="height: 360px;" bind:this={divProtyle}/>
|
||||
</div>
|
||||
|
43
src/icons.ts
Normal file
43
src/icons.ts
Normal file
|
@ -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
|
||||
`<symbol id="info" viewBox="0 -960 960 960">
|
||||
<path d="M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/>
|
||||
</symbol>`,
|
||||
// spell check - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:spellcheck
|
||||
`<symbol id="spellcheck" viewBox="0 -960 960 960">
|
||||
<path d="M564-80 394-250l56-56 114 114 226-226 56 56L564-80ZM120-320l194-520h94l194 520h-92l-46-132H254l-46 132h-88Zm162-208h156l-76-216h-4l-76 216Z"/>
|
||||
</symbol>`,
|
||||
// language - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:language_chinese_quick
|
||||
`<symbol id="language" viewBox="0 -960 960 960">
|
||||
<path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-155.5t86-127Q252-817 325-848.5T480-880q83 0 155.5 31.5t127 86q54.5 54.5 86 127T880-480q0 82-31.5 155t-86 127.5q-54.5 54.5-127 86T480-80Zm0-82q26-36 45-75t31-83H404q12 44 31 83t45 75Zm-104-16q-18-33-31.5-68.5T322-320H204q29 50 72.5 87t99.5 55Zm208 0q56-18 99.5-55t72.5-87H638q-9 38-22.5 73.5T584-178ZM170-400h136q-3-20-4.5-39.5T300-480q0-21 1.5-40.5T306-560H170q-5 20-7.5 39.5T160-480q0 21 2.5 40.5T170-400Zm216 0h188q3-20 4.5-39.5T580-480q0-21-1.5-40.5T574-560H386q-3 20-4.5 39.5T380-480q0 21 1.5 40.5T386-400Zm268 0h136q5-20 7.5-39.5T800-480q0-21-2.5-40.5T790-560H654q3 20 4.5 39.5T660-480q0 21-1.5 40.5T654-400Zm-16-240h118q-29-50-72.5-87T584-782q18 33 31.5 68.5T638-640Zm-234 0h152q-12-44-31-83t-45-75q-26 36-45 75t-31 83Zm-200 0h118q9-38 22.5-73.5T376-782q-56 18-99.5 55T204-640Z"/>
|
||||
</symbol>`,
|
||||
// toggle - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:toggle_on
|
||||
`<symbol id="toggle" viewBox="0 -960 960 960">
|
||||
<path d="M280-240q-100 0-170-70T40-480q0-100 70-170t170-70h400q100 0 170 70t70 170q0 100-70 170t-170 70H280Zm0-80h400q66 0 113-47t47-113q0-66-47-113t-113-47H280q-66 0-113 47t-47 113q0 66 47 113t113 47Zm400-40q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35ZM480-480Z"/>
|
||||
</symbol>`,
|
||||
// autodetect - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:network_intelligence
|
||||
`<symbol id="autodetect" viewBox="0 -960 960 960">
|
||||
<path d="M346-212q-8 0-15-3.5T320-227l-68-123h48l36 68h77v-28h-60l-36-68h-81l-49-87q-2-4-3-7.5t-1-7.5q0-2 4-15l49-87h81l36-68h60v-28h-77l-36 68h-48l68-123q4-8 11-11.5t15-3.5h90q13 0 21.5 8.5T466-718v140h-61l-28 28h89v106h-76l-34-67h-69l-28 28h80l34 67h93v174q0 13-8.5 21.5T436-212h-90Zm178 0q-13 0-21.5-8.5T494-242v-174h93l34-67h80l-28-28h-69l-35 67h-75v-106h89l-28-28h-61v-140q0-13 8.5-21.5T524-748h90q8 0 15 3.5t11 11.5l68 123h-48l-36-68h-77v28h60l35 68h82l49 87q2 4 3 7.5t1 7.5q0 2-4 15l-49 87h-82l-35 68h-60v28h77l36-68h48l-68 123q-4 8-11 11.5t-15 3.5h-90Z"/>
|
||||
</symbol>`,
|
||||
// error - https://fonts.google.com/icons?selected=Material+Symbols+Outlined
|
||||
`<symbol id="error" viewBox="0 -960 960 960">
|
||||
<path d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/>
|
||||
</symbol>`,
|
||||
// add - https://fonts.google.com/icons?selected=Material+Symbols+Outlined:add
|
||||
`<symbol id="add" viewBox="0 -960 960 960">
|
||||
<path d="M440-440H200v-80h240v-240h80v240h240v80H520v240h-80v-240Z"/>
|
||||
</symbol>`
|
||||
]
|
||||
|
||||
}
|
1087
src/index.ts
1087
src/index.ts
File diff suppressed because it is too large
Load diff
80
src/languagetool.ts
Normal file
80
src/languagetool.ts
Normal file
|
@ -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<Suggestion[]> {
|
||||
|
||||
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<Language[]> {
|
||||
const res = await fetch(settings.server + 'v2/languages', {method: 'GET'});
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
|
||||
}
|
151
src/menus.ts
Normal file
151
src/menus.ts
Normal file
|
@ -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(`
|
||||
<b>${this.plugin.i18nx.docMenu.documentStatus}</b><br />
|
||||
${this.plugin.i18nx.docMenu.status}: ${settings.enabled ? this.plugin.i18nx.docMenu.enabled : this.plugin.i18nx.docMenu.disabled}<br />
|
||||
${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(<PluginSettings>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 });
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
65
src/protyleHelpers.ts
Normal file
65
src/protyleHelpers.ts
Normal file
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -1,139 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { showMessage } from "siyuan";
|
||||
import SettingPanel from "./libs/components/setting-panel.svelte";
|
||||
|
||||
let groups: string[] = ["🌈 Group 1", "✨ Group 2"];
|
||||
let focusGroup = groups[0];
|
||||
|
||||
const group1Items: ISettingItem[] = [
|
||||
{
|
||||
type: 'checkbox',
|
||||
title: 'checkbox',
|
||||
description: 'checkbox',
|
||||
key: 'a',
|
||||
value: true
|
||||
},
|
||||
{
|
||||
type: 'textinput',
|
||||
title: 'text',
|
||||
description: 'This is a text',
|
||||
key: 'b',
|
||||
value: 'This is a text',
|
||||
placeholder: 'placeholder'
|
||||
},
|
||||
{
|
||||
type: 'textarea',
|
||||
title: 'textarea',
|
||||
description: 'This is a textarea',
|
||||
key: 'b2',
|
||||
value: 'This is a textarea',
|
||||
placeholder: 'placeholder',
|
||||
direction: 'row'
|
||||
},
|
||||
{
|
||||
type: 'select',
|
||||
title: 'select',
|
||||
description: 'select',
|
||||
key: 'c',
|
||||
value: 'x',
|
||||
options: {
|
||||
x: 'x',
|
||||
y: 'y',
|
||||
z: 'z'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const group2Items: ISettingItem[] = [
|
||||
{
|
||||
type: 'button',
|
||||
title: 'button',
|
||||
description: 'This is a button',
|
||||
key: 'e',
|
||||
value: 'Click Button',
|
||||
button: {
|
||||
label: 'Click Me',
|
||||
callback: () => {
|
||||
showMessage('Hello, world!');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'slider',
|
||||
title: 'slider',
|
||||
description: 'slider',
|
||||
key: 'd',
|
||||
value: 50,
|
||||
slider: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/********** Events **********/
|
||||
interface ChangeEvent {
|
||||
group: string;
|
||||
key: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
const onChanged = ({ detail }: CustomEvent<ChangeEvent>) => {
|
||||
if (detail.group === groups[0]) {
|
||||
// setting.set(detail.key, detail.value);
|
||||
//Please add your code here
|
||||
//Udpate the plugins setting data, don't forget to call plugin.save() for data persistence
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="fn__flex-1 fn__flex config__panel">
|
||||
<ul class="b3-tab-bar b3-list b3-list--background">
|
||||
{#each groups as group}
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<li
|
||||
data-name="editor"
|
||||
class:b3-list-item--focus={group === focusGroup}
|
||||
class="b3-list-item"
|
||||
on:click={() => {
|
||||
focusGroup = group;
|
||||
}}
|
||||
on:keydown={() => {}}
|
||||
>
|
||||
<span class="b3-list-item__text">{group}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="config__tab-wrap">
|
||||
<SettingPanel
|
||||
group={groups[0]}
|
||||
settingItems={group1Items}
|
||||
display={focusGroup === groups[0]}
|
||||
on:changed={onChanged}
|
||||
on:click={({ detail }) => { console.debug("Click:", detail.key); }}
|
||||
>
|
||||
<div class="fn__flex b3-label">
|
||||
💡 This is our default settings.
|
||||
</div>
|
||||
</SettingPanel>
|
||||
<SettingPanel
|
||||
group={groups[1]}
|
||||
settingItems={group2Items}
|
||||
display={focusGroup === groups[1]}
|
||||
on:changed={onChanged}
|
||||
on:click={({ detail }) => { console.debug("Click:", detail.key); }}
|
||||
>
|
||||
</SettingPanel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.config__panel {
|
||||
height: 100%;
|
||||
}
|
||||
.config__panel > ul > li {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
159
src/settings.ts
Normal file
159
src/settings.ts
Normal file
|
@ -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<SettingUtils> {
|
||||
|
||||
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(<PluginSettings>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(','))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
151
src/spellchecker.ts
Normal file
151
src/spellchecker.ts
Normal file
|
@ -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 = <HTMLElement>ProtyleHelpers.fastGetBlockElement(this.blockID)
|
||||
let overlay = <HTMLElement>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 <img> 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 = <HTMLElement>ProtyleHelpers.fastGetOverlayElement(this.blockID)
|
||||
overlay?.remove();
|
||||
}
|
||||
|
||||
}
|
191
src/suggestions.ts
Normal file
191
src/suggestions.ts
Normal file
|
@ -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<string, StoredBlock>;
|
||||
|
||||
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, <PluginSettings>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
|
||||
}
|
||||
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue