commit 04f54e248a039fd1e6befb46f6bab176cfea4f96 Author: MassiveBox Date: Fri Sep 19 19:16:32 2025 +0000 Initial commit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..49834e5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Create Release on Tag Push + +on: + push: + tags: + - "v*" + +jobs: + build: + runs-on: ubuntu-latest + steps: + # Checkout + - name: Checkout + uses: actions/checkout@v3 + + # Install Node.js + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + registry-url: "https://registry.npmjs.org" + + # Install pnpm + - name: Install pnpm + uses: pnpm/action-setup@v4 + id: pnpm-install + with: + version: 8 + run_install: false + + # Get pnpm store directory + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + # Setup pnpm cache + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + # Install dependencies + - name: Install dependencies + run: pnpm install + + # Build for production, 这一步会生成一个 package.zip + - name: Build for production + run: pnpm build + + - name: Release + uses: ncipollo/release-action@v1 + with: + allowUpdates: true + artifactErrorsFailBuild: true + artifacts: "package.zip" + token: ${{ secrets.GITHUB_TOKEN }} + prerelease: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2a2064 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.idea +.vscode +.DS_Store +pnpm-lock.yaml +package-lock.json +package.zip +node_modules +dev +dist +build +tmp diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..af04807 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,112 @@ +# Changelog + +## v0.3.5 2024-04-30 + +* [Add `direction` to plugin method `Setting.addItem`](https://github.com/siyuan-note/siyuan/issues/11183) + + +## 0.3.4 2024-02-20 + +* [Add plugin event bus `click-flashcard-action`](https://github.com/siyuan-note/siyuan/issues/10318) + +## 0.3.3 2024-01-24 + +* Update dock icon class + +## 0.3.2 2024-01-09 + +* [Add plugin `protyleOptions`](https://github.com/siyuan-note/siyuan/issues/10090) +* [Add plugin api `uninstall`](https://github.com/siyuan-note/siyuan/issues/10063) +* [Add plugin method `updateCards`](https://github.com/siyuan-note/siyuan/issues/10065) +* [Add plugin function `lockScreen`](https://github.com/siyuan-note/siyuan/issues/10063) +* [Add plugin event bus `lock-screen`](https://github.com/siyuan-note/siyuan/pull/9967) +* [Add plugin event bus `open-menu-inbox`](https://github.com/siyuan-note/siyuan/pull/9967) + + +## 0.3.1 2023-12-06 + +* [Support `Dock Plugin` and `Command Palette` on mobile](https://github.com/siyuan-note/siyuan/issues/9926) + +## 0.3.0 2023-12-05 + +* Upgrade Siyuan to 0.9.0 +* Support more platforms + +## 0.2.9 2023-11-28 + +* [Add plugin method `openMobileFileById`](https://github.com/siyuan-note/siyuan/issues/9738) + + +## 0.2.8 2023-11-15 + +* [`resize` cannot be triggered after dragging to unpin the dock](https://github.com/siyuan-note/siyuan/issues/9640) + +## 0.2.7 2023-10-31 + +* [Export `Constants` to plugin](https://github.com/siyuan-note/siyuan/issues/9555) +* [Add plugin `app.appId`](https://github.com/siyuan-note/siyuan/issues/9538) +* [Add plugin event bus `switch-protyle`](https://github.com/siyuan-note/siyuan/issues/9454) + +## 0.2.6 2023-10-24 + +* [Deprecated `loaded-protyle` use `loaded-protyle-static` instead](https://github.com/siyuan-note/siyuan/issues/9468) + +## 0.2.5 2023-10-10 + +* [Add plugin event bus `open-menu-doctree`](https://github.com/siyuan-note/siyuan/issues/9351) + +## 0.2.4 2023-09-19 + +* Supports use in windows +* [Add plugin function `transaction`](https://github.com/siyuan-note/siyuan/issues/9172) + +## 0.2.3 2023-09-05 + +* [Add plugin function `transaction`](https://github.com/siyuan-note/siyuan/issues/9172) +* [Plugin API add openWindow and command.globalCallback](https://github.com/siyuan-note/siyuan/issues/9032) + +## 0.2.2 2023-08-29 + +* [Add plugin event bus `destroy-protyle`](https://github.com/siyuan-note/siyuan/issues/9033) +* [Add plugin event bus `loaded-protyle-dynamic`](https://github.com/siyuan-note/siyuan/issues/9021) + +## 0.2.1 2023-08-21 + +* [Plugin API add getOpenedTab method](https://github.com/siyuan-note/siyuan/issues/9002) +* [Plugin API custom.fn => custom.id in openTab](https://github.com/siyuan-note/siyuan/issues/8944) + +## 0.2.0 2023-08-15 + +* [Add plugin event bus `open-siyuan-url-plugin` and `open-siyuan-url-block`](https://github.com/siyuan-note/siyuan/pull/8927) + + +## 0.1.12 2023-08-01 + +* Upgrade siyuan to 0.7.9 + +## 0.1.11 + +* [Add `input-search` event bus to plugins](https://github.com/siyuan-note/siyuan/issues/8725) + + +## 0.1.10 + +* [Add `bind this` example for eventBus in plugins](https://github.com/siyuan-note/siyuan/issues/8668) +* [Add `open-menu-breadcrumbmore` event bus to plugins](https://github.com/siyuan-note/siyuan/issues/8666) + +## 0.1.9 + +* [Add `open-menu-xxx` event bus for plugins ](https://github.com/siyuan-note/siyuan/issues/8617) + +## 0.1.8 + +* [Add protyleSlash to the plugin](https://github.com/siyuan-note/siyuan/issues/8599) +* [Add plugin API protyle](https://github.com/siyuan-note/siyuan/issues/8445) + +## 0.1.7 + +* [Support build js and json](https://github.com/siyuan-note/plugin-sample/pull/8) + +## 0.1.6 + +* add `fetchPost` example diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3cf562d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 SiYuan 思源笔记 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2cc772c --- /dev/null +++ b/README.md @@ -0,0 +1,282 @@ + +# SiYuan plugin sample with vite and svelte + +[中文版](./README_zh_CN.md) + +> 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) + + + +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) + +4. Provides a github action template to automatically generate package.zip and upload to new release + + +> [!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. + +## 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 + diff --git a/README_zh_CN.md b/README_zh_CN.md new file mode 100644 index 0000000..f5815fd --- /dev/null +++ b/README_zh_CN.md @@ -0,0 +1,276 @@ + +# 使用 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/asset/action.png b/asset/action.png new file mode 100644 index 0000000..a884045 Binary files /dev/null and b/asset/action.png differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..47d51af Binary files /dev/null and b/icon.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..bf6332d --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "plugin-sample-vite-svelte", + "version": "0.4.1", + "type": "module", + "description": "This is a sample plugin based on vite and svelte for Siyuan (https://b3log.org/siyuan)", + "repository": "", + "homepage": "", + "author": "frostime", + "license": "MIT", + "scripts": { + "dev": "cross-env NODE_ENV=development VITE_SOURCEMAP=inline vite build --watch", + "build": "cross-env NODE_ENV=production vite build", + "make-link": "node --no-warnings ./scripts/make_dev_link.js", + "make-link-win": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File ./scripts/elevate.ps1 -scriptPath ./scripts/make_dev_link.js", + "update-version": "node --no-warnings ./scripts/update_version.js", + "make-install": "vite build && node --no-warnings ./scripts/make_install.js" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.1.0", + "@tsconfig/svelte": "^4.0.1", + "@types/node": "^20.3.0", + "cross-env": "^7.0.3", + "fast-glob": "^3.2.12", + "glob": "^10.0.0", + "js-yaml": "^4.1.0", + "minimist": "^1.2.8", + "rollup-plugin-livereload": "^2.0.5", + "sass": "^1.63.3", + "siyuan": "1.1.2", + "svelte": "^4.2.20", + "ts-node": "^10.9.1", + "typescript": "^5.1.3", + "vite": "^5.2.9", + "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 new file mode 100644 index 0000000..f804990 --- /dev/null +++ b/plugin.json @@ -0,0 +1,46 @@ +{ + "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, + "backends": [ + "windows", + "linux", + "darwin", + "ios", + "android", + "harmony", + "docker" + ], + "frontends": [ + "desktop", + "mobile", + "browser-desktop", + "browser-mobile", + "desktop-window" + ], + "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.md", + "zh_CN": "README_zh_CN.md" + }, + "funding": { + "custom": [ + "" + ] + }, + "keywords": [ + "plugin", + "sample", + "插件样例" + ] +} diff --git a/preview.png b/preview.png new file mode 100644 index 0000000..95b93c7 Binary files /dev/null and b/preview.png differ diff --git a/public/i18n/README.md b/public/i18n/README.md new file mode 100644 index 0000000..af8de98 --- /dev/null +++ b/public/i18n/README.md @@ -0,0 +1,12 @@ +思源支持的 i18n 文件范围,可以在控制台 `siyuan.config.langs` 中查看。以下是目前(2024-10-24)支持的语言方案: + +The range of i18n files supported by SiYuan can be viewed in the console under `siyuan.config.langs`. Below are the language schemes currently supported as of now (October 24, 2024) : + +```js +>>> siyuan.config.langs.map( lang => lang.name) +['de_DE', 'en_US', 'es_ES', 'fr_FR', 'he_IL', 'it_IT', 'ja_JP', 'pl_PL', 'ru_RU', 'zh_CHT', 'zh_CN'] +``` + +在插件开发中,默认使用 JSON 格式作为国际化(i18n)的载体文件。如果您更喜欢使用 YAML 语法,可以将 JSON 文件替换为 YAML 文件(例如 `en_US.yaml`),并在其中编写 i18n 文本。本模板提供了相关的 Vite 插件,可以在编译时自动将 YAML 文件转换为 JSON 文件(请参见 `/yaml-plugin.js`)。本 MD 文件 和 YAML 文件会在 `npm run build` 时自动从 `dist` 目录下删除,仅保留必要的 JSON 文件共插件系统使用。 + +In plugin development, JSON format is used by default as the carrier file for internationalization (i18n). If you prefer to use YAML syntax, you can replace the JSON file with a YAML file (e.g., `en_US.yaml`) and write the i18n text within it. This template provides a related Vite plugin that can automatically convert YAML files to JSON files during the compilation process (see `/yaml-plugin.js`). This markdown file and YAML files will be automatically removed from the `dist` directory during `npm run build`, leaving only the necessary JSON files for plugin system to use. diff --git a/public/i18n/en_US.json b/public/i18n/en_US.json new file mode 100644 index 0000000..7a11256 --- /dev/null +++ b/public/i18n/en_US.json @@ -0,0 +1,20 @@ +{ + "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." + }, + "hintTitle":"About", + "hintDesc":"plugin-sample-vite-svelte
@frostime
@88250
@zxkmm" +} \ No newline at end of file diff --git a/public/i18n/zh_CN.json b/public/i18n/zh_CN.json new file mode 100644 index 0000000..6600f6a --- /dev/null +++ b/public/i18n/zh_CN.json @@ -0,0 +1,20 @@ +{ + "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/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 0000000..82fa0dc --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1,5 @@ +.venv +build +dist +*.exe +*.spec diff --git a/scripts/elevate.ps1 b/scripts/elevate.ps1 new file mode 100644 index 0000000..151b8ba --- /dev/null +++ b/scripts/elevate.ps1 @@ -0,0 +1,24 @@ +# Copyright (c) 2024 by frostime. All Rights Reserved. +# @Author : frostime +# @Date : 2024-09-06 19:15:53 +# @FilePath : /scripts/elevate.ps1 +# @LastEditTime : 2024-09-06 19:39:13 +# @Description : Force to elevate the script to admin privilege. + +param ( + [string]$scriptPath +) + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$projectDir = Split-Path -Parent $scriptDir + +if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) { + $args = "-NoProfile -ExecutionPolicy Bypass -File `"" + $MyInvocation.MyCommand.Path + "`" -scriptPath `"" + $scriptPath + "`"" + Start-Process powershell.exe -Verb RunAs -ArgumentList $args -WorkingDirectory $projectDir + exit +} + +Set-Location -Path $projectDir +& node $scriptPath + +pause diff --git a/scripts/make_dev_link.js b/scripts/make_dev_link.js new file mode 100644 index 0000000..2be3d2b --- /dev/null +++ b/scripts/make_dev_link.js @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2024 by frostime. All Rights Reserved. + * @Author : frostime + * @Date : 2023-07-15 15:31:31 + * @FilePath : /scripts/make_dev_link.js + * @LastEditTime : 2024-09-06 18:13:53 + * @Description : + */ +// make_dev_link.js +import fs from 'fs'; +import { log, error, getSiYuanDir, chooseTarget, getThisPluginName, makeSymbolicLink } from './utils.js'; + +let targetDir = ''; + +/** + * 1. Get the parent directory to install the plugin + */ +log('>>> Try to visit constant "targetDir" in make_dev_link.js...'); +if (targetDir === '') { + log('>>> Constant "targetDir" is empty, try to get SiYuan directory automatically....'); + let res = await getSiYuanDir(); + + if (!res || res.length === 0) { + log('>>> Can not get SiYuan directory automatically, try to visit environment variable "SIYUAN_PLUGIN_DIR"....'); + let env = process.env?.SIYUAN_PLUGIN_DIR; + if (env) { + targetDir = env; + log(`\tGot target directory from environment variable "SIYUAN_PLUGIN_DIR": ${targetDir}`); + } else { + error('\tCan not get SiYuan directory from environment variable "SIYUAN_PLUGIN_DIR", failed!'); + process.exit(1); + } + } else { + targetDir = await chooseTarget(res); + } + + log(`>>> Successfully got target directory: ${targetDir}`); +} +if (!fs.existsSync(targetDir)) { + error(`Failed! Plugin directory not exists: "${targetDir}"`); + error('Please set the plugin directory in scripts/make_dev_link.js'); + process.exit(1); +} + +/** + * 2. The dev directory, which contains the compiled plugin code + */ +const devDir = `${process.cwd()}/dev`; +if (!fs.existsSync(devDir)) { + fs.mkdirSync(devDir); +} + + +/** + * 3. The target directory to make symbolic link to dev directory + */ +const name = getThisPluginName(); +if (name === null) { + process.exit(1); +} +const targetPath = `${targetDir}/${name}`; + +/** + * 4. Make symbolic link + */ +makeSymbolicLink(devDir, targetPath); diff --git a/scripts/make_install.js b/scripts/make_install.js new file mode 100644 index 0000000..cb2a4ac --- /dev/null +++ b/scripts/make_install.js @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2024 by frostime. All Rights Reserved. + * @Author : frostime + * @Date : 2024-03-28 20:03:59 + * @FilePath : /scripts/make_install.js + * @LastEditTime : 2024-09-06 18:08:19 + * @Description : + */ +// make_install.js +import fs from 'fs'; +import { log, error, getSiYuanDir, chooseTarget, copyDirectory, getThisPluginName } from './utils.js'; + +let targetDir = ''; + +/** + * 1. Get the parent directory to install the plugin + */ +log('>>> Try to visit constant "targetDir" in make_install.js...'); +if (targetDir === '') { + log('>>> Constant "targetDir" is empty, try to get SiYuan directory automatically....'); + let res = await getSiYuanDir(); + + if (res === null || res === undefined || res.length === 0) { + error('>>> Can not get SiYuan directory automatically'); + process.exit(1); + } else { + targetDir = await chooseTarget(res); + } + log(`>>> Successfully got target directory: ${targetDir}`); +} +if (!fs.existsSync(targetDir)) { + error(`Failed! Plugin directory not exists: "${targetDir}"`); + error('Please set the plugin directory in scripts/make_install.js'); + process.exit(1); +} + +/** + * 2. The dist directory, which contains the compiled plugin code + */ +const distDir = `${process.cwd()}/dist`; +if (!fs.existsSync(distDir)) { + fs.mkdirSync(distDir); +} + +/** + * 3. The target directory to install the plugin + */ +const name = getThisPluginName(); +if (name === null) { + process.exit(1); +} +const targetPath = `${targetDir}/${name}`; + +/** + * 4. Copy the compiled plugin code to the target directory + */ +copyDirectory(distDir, targetPath); diff --git a/scripts/update_version.js b/scripts/update_version.js new file mode 100644 index 0000000..775c98a --- /dev/null +++ b/scripts/update_version.js @@ -0,0 +1,141 @@ +// const fs = require('fs'); +// const path = require('path'); +// const readline = require('readline'); +import fs from 'node:fs'; +import path from 'node:path'; +import readline from 'node:readline'; + +// Utility to read JSON file +function readJsonFile(filePath) { + return new Promise((resolve, reject) => { + fs.readFile(filePath, 'utf8', (err, data) => { + if (err) return reject(err); + try { + const jsonData = JSON.parse(data); + resolve(jsonData); + } catch (e) { + reject(e); + } + }); + }); +} + +// Utility to write JSON file +function writeJsonFile(filePath, jsonData) { + return new Promise((resolve, reject) => { + fs.writeFile(filePath, JSON.stringify(jsonData, null, 2), 'utf8', (err) => { + if (err) return reject(err); + resolve(); + }); + }); +} + +// Utility to prompt the user for input +function promptUser(query) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + return new Promise((resolve) => rl.question(query, (answer) => { + rl.close(); + resolve(answer); + })); +} + +// Function to parse the version string +function parseVersion(version) { + const [major, minor, patch] = version.split('.').map(Number); + return { major, minor, patch }; +} + +// Function to auto-increment version parts +function incrementVersion(version, type) { + let { major, minor, patch } = parseVersion(version); + + switch (type) { + case 'major': + major++; + minor = 0; + patch = 0; + break; + case 'minor': + minor++; + patch = 0; + break; + case 'patch': + patch++; + break; + default: + break; + } + + return `${major}.${minor}.${patch}`; +} + +// Main script +(async function () { + try { + const pluginJsonPath = path.join(process.cwd(), 'plugin.json'); + const packageJsonPath = path.join(process.cwd(), 'package.json'); + + // Read both JSON files + const pluginData = await readJsonFile(pluginJsonPath); + const packageData = await readJsonFile(packageJsonPath); + + // Get the current version from both files (assuming both have the same version) + const currentVersion = pluginData.version || packageData.version; + console.log(`\n🌟 Current version: \x1b[36m${currentVersion}\x1b[0m\n`); + + // Calculate potential new versions for auto-update + const newPatchVersion = incrementVersion(currentVersion, 'patch'); + const newMinorVersion = incrementVersion(currentVersion, 'minor'); + const newMajorVersion = incrementVersion(currentVersion, 'major'); + + // Prompt the user with formatted options + console.log('🔄 How would you like to update the version?\n'); + console.log(` 1️⃣ Auto update \x1b[33mpatch\x1b[0m version (new version: \x1b[32m${newPatchVersion}\x1b[0m)`); + console.log(` 2️⃣ Auto update \x1b[33mminor\x1b[0m version (new version: \x1b[32m${newMinorVersion}\x1b[0m)`); + console.log(` 3️⃣ Auto update \x1b[33mmajor\x1b[0m version (new version: \x1b[32m${newMajorVersion}\x1b[0m)`); + console.log(` 4️⃣ Input version \x1b[33mmanually\x1b[0m`); + // Press 0 to skip version update + console.log(' 0️⃣ Quit without updating\n'); + + const updateChoice = await promptUser('👉 Please choose (1/2/3/4): '); + + let newVersion; + + switch (updateChoice.trim()) { + case '1': + newVersion = newPatchVersion; + break; + case '2': + newVersion = newMinorVersion; + break; + case '3': + newVersion = newMajorVersion; + break; + case '4': + newVersion = await promptUser('✍️ Please enter the new version (in a.b.c format): '); + break; + case '0': + console.log('\n🛑 Skipping version update.'); + return; + default: + console.log('\n❌ Invalid option, no version update.'); + return; + } + + // Update the version in both plugin.json and package.json + pluginData.version = newVersion; + packageData.version = newVersion; + + // Write the updated JSON back to files + await writeJsonFile(pluginJsonPath, pluginData); + await writeJsonFile(packageJsonPath, packageData); + + console.log(`\n✅ Version successfully updated to: \x1b[32m${newVersion}\x1b[0m\n`); + + } catch (error) { + console.error('❌ Error:', error); + } +})(); diff --git a/scripts/utils.js b/scripts/utils.js new file mode 100644 index 0000000..210b6b1 --- /dev/null +++ b/scripts/utils.js @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2024 by frostime. All Rights Reserved. + * @Author : frostime + * @Date : 2024-09-06 17:42:57 + * @FilePath : /scripts/utils.js + * @LastEditTime : 2024-09-06 19:23:12 + * @Description : + */ +// common.js +import fs from 'fs'; +import path from 'node:path'; +import http from 'node:http'; +import readline from 'node:readline'; + +// Logging functions +export const log = (info) => console.log(`\x1B[36m%s\x1B[0m`, info); +export const error = (info) => console.log(`\x1B[31m%s\x1B[0m`, info); + +// HTTP POST headers +export const POST_HEADER = { + "Content-Type": "application/json", +}; + +// Fetch function compatible with older Node.js versions +export async function myfetch(url, options) { + return new Promise((resolve, reject) => { + let req = http.request(url, options, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + resolve({ + ok: true, + status: res.statusCode, + json: () => JSON.parse(data) + }); + }); + }); + req.on('error', (e) => { + reject(e); + }); + req.end(); + }); +} + +/** + * Fetch SiYuan workspaces from port 6806 + * @returns {Promise} + */ +export async function getSiYuanDir() { + let url = 'http://127.0.0.1:6806/api/system/getWorkspaces'; + let conf = {}; + try { + let response = await myfetch(url, { + method: 'POST', + headers: POST_HEADER + }); + if (response.ok) { + conf = await response.json(); + } else { + error(`\tHTTP-Error: ${response.status}`); + return null; + } + } catch (e) { + error(`\tError: ${e}`); + error("\tPlease make sure SiYuan is running!!!"); + return null; + } + return conf?.data; // 保持原始返回值 +} + +/** + * Choose target workspace + * @param {{path: string}[]} workspaces + * @returns {string} The path of the selected workspace + */ +export async function chooseTarget(workspaces) { + let count = workspaces.length; + log(`>>> Got ${count} SiYuan ${count > 1 ? 'workspaces' : 'workspace'}`); + workspaces.forEach((workspace, i) => { + log(`\t[${i}] ${workspace.path}`); + }); + + if (count === 1) { + return `${workspaces[0].path}/data/plugins`; + } else { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + let index = await new Promise((resolve) => { + rl.question(`\tPlease select a workspace[0-${count - 1}]: `, (answer) => { + resolve(answer); + }); + }); + rl.close(); + return `${workspaces[index].path}/data/plugins`; + } +} + +/** + * Check if two paths are the same + * @param {string} path1 + * @param {string} path2 + * @returns {boolean} + */ +export function cmpPath(path1, path2) { + path1 = path1.replace(/\\/g, '/'); + path2 = path2.replace(/\\/g, '/'); + if (path1[path1.length - 1] !== '/') { + path1 += '/'; + } + if (path2[path2.length - 1] !== '/') { + path2 += '/'; + } + return path1 === path2; +} + +export function getThisPluginName() { + if (!fs.existsSync('./plugin.json')) { + process.chdir('../'); + if (!fs.existsSync('./plugin.json')) { + error('Failed! plugin.json not found'); + return null; + } + } + + const plugin = JSON.parse(fs.readFileSync('./plugin.json', 'utf8')); + const name = plugin?.name; + if (!name) { + error('Failed! Please set plugin name in plugin.json'); + return null; + } + + return name; +} + +export function copyDirectory(srcDir, dstDir) { + if (!fs.existsSync(dstDir)) { + fs.mkdirSync(dstDir); + log(`Created directory ${dstDir}`); + } + + fs.readdirSync(srcDir, { withFileTypes: true }).forEach((file) => { + const src = path.join(srcDir, file.name); + const dst = path.join(dstDir, file.name); + + if (file.isDirectory()) { + copyDirectory(src, dst); + } else { + fs.copyFileSync(src, dst); + log(`Copied file: ${src} --> ${dst}`); + } + }); + log(`All files copied!`); +} + + +export function makeSymbolicLink(srcPath, targetPath) { + if (!fs.existsSync(targetPath)) { + // fs.symlinkSync(srcPath, targetPath, 'junction'); + //Go 1.23 no longer supports junctions as symlinks + //Please refer to https://github.com/siyuan-note/siyuan/issues/12399 + fs.symlinkSync(srcPath, targetPath, 'dir'); + log(`Done! Created symlink ${targetPath}`); + return; + } + + //Check the existed target path + let isSymbol = fs.lstatSync(targetPath).isSymbolicLink(); + if (!isSymbol) { + error(`Failed! ${targetPath} already exists and is not a symbolic link`); + return; + } + let existedPath = fs.readlinkSync(targetPath); + if (cmpPath(existedPath, srcPath)) { + log(`Good! ${targetPath} is already linked to ${srcPath}`); + } else { + error(`Error! Already exists symbolic link ${targetPath}\nBut it links to ${existedPath}`); + } +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..0595371 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,478 @@ +/** + * Copyright (c) 2023 frostime. All rights reserved. + * https://github.com/frostime/sy-plugin-template-vite + * + * See API Document in [API.md](https://github.com/siyuan-note/siyuan/blob/master/API.md) + * API 文档见 [API_zh_CN.md](https://github.com/siyuan-note/siyuan/blob/master/API_zh_CN.md) + */ + +import { fetchPost, fetchSyncPost, IWebSocketData } from "siyuan"; + + +export async function request(url: string, data: any) { + let response: IWebSocketData = await fetchSyncPost(url, data); + let res = response.code === 0 ? response.data : null; + return res; +} + + +// **************************************** Noteboook **************************************** + + +export async function lsNotebooks(): Promise { + let url = '/api/notebook/lsNotebooks'; + return request(url, ''); +} + + +export async function openNotebook(notebook: NotebookId) { + let url = '/api/notebook/openNotebook'; + return request(url, { notebook: notebook }); +} + + +export async function closeNotebook(notebook: NotebookId) { + let url = '/api/notebook/closeNotebook'; + return request(url, { notebook: notebook }); +} + + +export async function renameNotebook(notebook: NotebookId, name: string) { + let url = '/api/notebook/renameNotebook'; + return request(url, { notebook: notebook, name: name }); +} + + +export async function createNotebook(name: string): Promise { + let url = '/api/notebook/createNotebook'; + return request(url, { name: name }); +} + + +export async function removeNotebook(notebook: NotebookId) { + let url = '/api/notebook/removeNotebook'; + return request(url, { notebook: notebook }); +} + + +export async function getNotebookConf(notebook: NotebookId): Promise { + let data = { notebook: notebook }; + let url = '/api/notebook/getNotebookConf'; + return request(url, data); +} + + +export async function setNotebookConf(notebook: NotebookId, conf: NotebookConf): Promise { + let data = { notebook: notebook, conf: conf }; + let url = '/api/notebook/setNotebookConf'; + return request(url, data); +} + + +// **************************************** File Tree **************************************** +export async function createDocWithMd(notebook: NotebookId, path: string, markdown: string): Promise { + let data = { + notebook: notebook, + path: path, + markdown: markdown, + }; + let url = '/api/filetree/createDocWithMd'; + return request(url, data); +} + + +export async function renameDoc(notebook: NotebookId, path: string, title: string): Promise { + let data = { + doc: notebook, + path: path, + title: title + }; + let url = '/api/filetree/renameDoc'; + return request(url, data); +} + + +export async function removeDoc(notebook: NotebookId, path: string) { + let data = { + notebook: notebook, + path: path, + }; + let url = '/api/filetree/removeDoc'; + return request(url, data); +} + + +export async function moveDocs(fromPaths: string[], toNotebook: NotebookId, toPath: string) { + let data = { + fromPaths: fromPaths, + toNotebook: toNotebook, + toPath: toPath + }; + let url = '/api/filetree/moveDocs'; + return request(url, data); +} + + +export async function getHPathByPath(notebook: NotebookId, path: string): Promise { + let data = { + notebook: notebook, + path: path + }; + let url = '/api/filetree/getHPathByPath'; + return request(url, data); +} + + +export async function getHPathByID(id: BlockId): Promise { + let data = { + id: id + }; + let url = '/api/filetree/getHPathByID'; + return request(url, data); +} + + +export async function getIDsByHPath(notebook: NotebookId, path: string): Promise { + let data = { + notebook: notebook, + path: path + }; + let url = '/api/filetree/getIDsByHPath'; + return request(url, data); +} + +// **************************************** Asset Files **************************************** + +export async function upload(assetsDirPath: string, files: any[]): Promise { + let form = new FormData(); + form.append('assetsDirPath', assetsDirPath); + for (let file of files) { + form.append('file[]', file); + } + let url = '/api/asset/upload'; + return request(url, form); +} + +// **************************************** Block **************************************** +type DataType = "markdown" | "dom"; +export async function insertBlock( + dataType: DataType, data: string, + nextID?: BlockId, previousID?: BlockId, parentID?: BlockId +): Promise { + let payload = { + dataType: dataType, + data: data, + nextID: nextID, + previousID: previousID, + parentID: parentID + } + let url = '/api/block/insertBlock'; + return request(url, payload); +} + + +export async function prependBlock(dataType: DataType, data: string, parentID: BlockId | DocumentId): Promise { + let payload = { + dataType: dataType, + data: data, + parentID: parentID + } + let url = '/api/block/prependBlock'; + return request(url, payload); +} + + +export async function appendBlock(dataType: DataType, data: string, parentID: BlockId | DocumentId): Promise { + let payload = { + dataType: dataType, + data: data, + parentID: parentID + } + let url = '/api/block/appendBlock'; + return request(url, payload); +} + + +export async function updateBlock(dataType: DataType, data: string, id: BlockId): Promise { + let payload = { + dataType: dataType, + data: data, + id: id + } + let url = '/api/block/updateBlock'; + return request(url, payload); +} + + +export async function deleteBlock(id: BlockId): Promise { + let data = { + id: id + } + let url = '/api/block/deleteBlock'; + return request(url, data); +} + + +export async function moveBlock(id: BlockId, previousID?: PreviousID, parentID?: ParentID): Promise { + let data = { + id: id, + previousID: previousID, + parentID: parentID + } + let url = '/api/block/moveBlock'; + return request(url, data); +} + + +export async function foldBlock(id: BlockId) { + let data = { + id: id + } + let url = '/api/block/foldBlock'; + return request(url, data); +} + + +export async function unfoldBlock(id: BlockId) { + let data = { + id: id + } + let url = '/api/block/unfoldBlock'; + return request(url, data); +} + + +export async function getBlockKramdown(id: BlockId): Promise { + let data = { + id: id + } + let url = '/api/block/getBlockKramdown'; + return request(url, data); +} + + +export async function getChildBlocks(id: BlockId): Promise { + let data = { + id: id + } + let url = '/api/block/getChildBlocks'; + return request(url, data); +} + +export async function transferBlockRef(fromID: BlockId, toID: BlockId, refIDs: BlockId[]) { + let data = { + fromID: fromID, + toID: toID, + refIDs: refIDs + } + let url = '/api/block/transferBlockRef'; + return request(url, data); +} + +// **************************************** Attributes **************************************** +export async function setBlockAttrs(id: BlockId, attrs: { [key: string]: string }) { + let data = { + id: id, + attrs: attrs + } + let url = '/api/attr/setBlockAttrs'; + return request(url, data); +} + + +export async function getBlockAttrs(id: BlockId): Promise<{ [key: string]: string }> { + let data = { + id: id + } + let url = '/api/attr/getBlockAttrs'; + return request(url, data); +} + +// **************************************** SQL **************************************** + +export async function sql(sql: string): Promise { + let sqldata = { + stmt: sql, + }; + let url = '/api/query/sql'; + return request(url, sqldata); +} + +export async function getBlockByID(blockId: string): Promise { + let sqlScript = `select * from blocks where id ='${blockId}'`; + let data = await sql(sqlScript); + return data[0]; +} + +// **************************************** Template **************************************** + +export async function render(id: DocumentId, path: string): Promise { + let data = { + id: id, + path: path + } + let url = '/api/template/render'; + return request(url, data); +} + + +export async function renderSprig(template: string): Promise { + let url = '/api/template/renderSprig'; + return request(url, { template: template }); +} + +// **************************************** File **************************************** + +export async function getFile(path: string): Promise { + let data = { + path: path + } + let url = '/api/file/getFile'; + return new Promise((resolve, _) => { + fetchPost(url, data, (content: any) => { + resolve(content) + }); + }); +} + + +/** + * fetchPost will secretly convert data into json, this func merely return Blob + * @param endpoint + * @returns + */ +export const getFileBlob = async (path: string): Promise => { + const endpoint = '/api/file/getFile' + let response = await fetch(endpoint, { + method: 'POST', + body: JSON.stringify({ + path: path + }) + }); + if (!response.ok) { + return null; + } + let data = await response.blob(); + return data; +} + + +export async function putFile(path: string, isDir: boolean, file: any) { + let form = new FormData(); + form.append('path', path); + form.append('isDir', isDir.toString()); + // Copyright (c) 2023, terwer. + // https://github.com/terwer/siyuan-plugin-importer/blob/v1.4.1/src/api/kernel-api.ts + form.append('modTime', Math.floor(Date.now() / 1000).toString()); + form.append('file', file); + let url = '/api/file/putFile'; + return request(url, form); +} + +export async function removeFile(path: string) { + let data = { + path: path + } + let url = '/api/file/removeFile'; + return request(url, data); +} + + + +export async function readDir(path: string): Promise { + let data = { + path: path + } + let url = '/api/file/readDir'; + return request(url, data); +} + + +// **************************************** Export **************************************** + +export async function exportMdContent(id: DocumentId): Promise { + let data = { + id: id + } + let url = '/api/export/exportMdContent'; + return request(url, data); +} + +export async function exportResources(paths: string[], name: string): Promise { + let data = { + paths: paths, + name: name + } + let url = '/api/export/exportResources'; + return request(url, data); +} + +// **************************************** Convert **************************************** + +export type PandocArgs = string; +export async function pandoc(args: PandocArgs[]) { + let data = { + args: args + } + let url = '/api/convert/pandoc'; + return request(url, data); +} + +// **************************************** Notification **************************************** + +// /api/notification/pushMsg +// { +// "msg": "test", +// "timeout": 7000 +// } +export async function pushMsg(msg: string, timeout: number = 7000) { + let payload = { + msg: msg, + timeout: timeout + }; + let url = "/api/notification/pushMsg"; + return request(url, payload); +} + +export async function pushErrMsg(msg: string, timeout: number = 7000) { + let payload = { + msg: msg, + timeout: timeout + }; + let url = "/api/notification/pushErrMsg"; + return request(url, payload); +} + +// **************************************** Network **************************************** +export async function forwardProxy( + url: string, method: string = 'GET', payload: any = {}, + headers: any[] = [], timeout: number = 7000, contentType: string = "text/html" +): Promise { + let data = { + url: url, + method: method, + timeout: timeout, + contentType: contentType, + headers: headers, + payload: payload + } + let url1 = '/api/network/forwardProxy'; + return request(url1, data); +} + + +// **************************************** System **************************************** + +export async function bootProgress(): Promise { + return request('/api/system/bootProgress', {}); +} + + +export async function version(): Promise { + return request('/api/system/version', {}); +} + + +export async function currentTime(): Promise { + return request('/api/system/currentTime', {}); +} diff --git a/src/hello.svelte b/src/hello.svelte new file mode 100644 index 0000000..23353c7 --- /dev/null +++ b/src/hello.svelte @@ -0,0 +1,63 @@ + + + +
+
appId:
+
+
${app?.appId}
+
+
+
API demo:
+
+
+ System current time: {time} +
+
+
+
Protyle demo: id = {blockID}
+
+
+
+ diff --git a/src/index.scss b/src/index.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..614e0a4 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,1011 @@ +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; + + + 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; + } + + async onload() { + this.data[STORAGE_NAME] = { readonlyText: "Readonly" }; + + console.log("loading plugin-sample", this.i18n); + + const frontEnd = getFrontend(); + this.isMobile = frontEnd === "mobile" || frontEnd === "browser-mobile"; + // 图标的制作参见帮助文档 + this.addIcons(` + + + + +`); + + 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); + } + }); + + 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); + } + + + this.protyleSlash = [{ + filter: ["insert emoji 😊", "插入表情 😊", "crbqwx"], + html: `
${this.i18n.insertEmoji}😊
`, + id: "insertEmoji", + callback(protyle: Protyle) { + protyle.insert("😊"); + } + }]; + + this.protyleOptions = { + toolbar: ["block-ref", + "a", + "|", + "text", + "strong", + "em", + "u", + "s", + "mark", + "sup", + "sub", + "clear", + "|", + "code", + "kbd", + "tag", + "inline-math", + "inline-memo", + ], + }; + + 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" + ); + } + + async onunload() { + console.log(this.i18n.byePlugin); + showMessage("Goodbye SiYuan Plugin"); + console.log("onunload"); + } + + uninstall() { + console.log("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]; + } +} diff --git a/src/libs/components/Form/form-input.svelte b/src/libs/components/Form/form-input.svelte new file mode 100644 index 0000000..cbf5a7e --- /dev/null +++ b/src/libs/components/Form/form-input.svelte @@ -0,0 +1,118 @@ + + +{#if type === "checkbox"} + + +{:else if type === "textinput"} + + +{:else if type === "textarea"} +
+
+
+
+ +
`, + width: args.width ?? "520px", + height: args.height + }); + const target: HTMLTextAreaElement = dialog.element.querySelector(".b3-dialog__content>div.ft__breakword>textarea"); + const btnsElement = dialog.element.querySelectorAll(".b3-button"); + btnsElement[0].addEventListener("click", () => { + if (args?.cancel) { + args.cancel(); + } + dialog.destroy(); + }); + btnsElement[1].addEventListener("click", () => { + if (args?.confirm) { + args.confirm(target.value); + } + dialog.destroy(); + }); +}; + +export const inputDialogSync = async (args: { + title: string, placeholder?: string, defaultText?: string, + width?: string, height?: string +}) => { + return new Promise((resolve) => { + let newargs = { + ...args, confirm: (text) => { + resolve(text); + }, cancel: () => { + resolve(null); + } + }; + inputDialog(newargs); + }); +} + + +interface IConfirmDialogArgs { + title: string; + content: string | HTMLElement; + confirm?: (ele?: HTMLElement) => void; + cancel?: (ele?: HTMLElement) => void; + width?: string; + height?: string; +} + +export const confirmDialog = (args: IConfirmDialogArgs) => { + const { title, content, confirm, cancel, width, height } = args; + + const dialog = new Dialog({ + title, + content: `
+
+
+
+
+
+ +
`, + width: width, + height: height + }); + + const target: HTMLElement = dialog.element.querySelector(".b3-dialog__content>div.ft__breakword"); + if (typeof content === "string") { + target.innerHTML = content; + } else { + target.appendChild(content); + } + + const btnsElement = dialog.element.querySelectorAll(".b3-button"); + btnsElement[0].addEventListener("click", () => { + if (cancel) { + cancel(target); + } + dialog.destroy(); + }); + btnsElement[1].addEventListener("click", () => { + if (confirm) { + confirm(target); + } + dialog.destroy(); + }); +}; + + +export const confirmDialogSync = async (args: IConfirmDialogArgs) => { + return new Promise((resolve) => { + let newargs = { + ...args, confirm: (ele: HTMLElement) => { + resolve(ele); + }, cancel: (ele: HTMLElement) => { + resolve(ele); + } + }; + confirmDialog(newargs); + }); +}; + + +export const simpleDialog = (args: { + title: string, ele: HTMLElement | DocumentFragment, + width?: string, height?: string, + callback?: () => void; +}) => { + const dialog = new Dialog({ + title: args.title, + content: `
`, + width: args.width, + height: args.height, + destroyCallback: args.callback + }); + dialog.element.querySelector(".dialog-content").appendChild(args.ele); + return { + dialog, + close: dialog.destroy.bind(dialog) + }; +} + + +export const svelteDialog = (args: { + title: string, constructor: (container: HTMLElement) => SvelteComponent, + width?: string, height?: string, + callback?: () => void; +}) => { + let container = document.createElement('div') + container.style.display = 'contents'; + let component = args.constructor(container); + const { dialog, close } = simpleDialog({ + ...args, ele: container, callback: () => { + component.$destroy(); + if (args.callback) args.callback(); + } + }); + return { + component, + dialog, + close + } +} diff --git a/src/libs/index.d.ts b/src/libs/index.d.ts new file mode 100644 index 0000000..27a27ed --- /dev/null +++ b/src/libs/index.d.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 by frostime. All Rights Reserved. + * @Author : frostime + * @Date : 2024-04-19 18:30:12 + * @FilePath : /src/libs/index.d.ts + * @LastEditTime : 2024-04-30 16:39:54 + * @Description : + */ +type TSettingItemType = "checkbox" | "select" | "textinput" | "textarea" | "number" | "slider" | "button" | "hint" | "custom"; + +interface ISettingItemCore { + type: TSettingItemType; + key: string; + value: any; + placeholder?: string; + slider?: { + min: number; + max: number; + step: number; + }; + options?: { [key: string | number]: string }; + button?: { + label: string; + callback: () => void; + } +} + +interface ISettingItem extends ISettingItemCore { + title: string; + description: string; + direction?: "row" | "column"; +} + + +//Interface for setting-utils +interface ISettingUtilsItem extends ISettingItem { + action?: { + callback: () => void; + } + createElement?: (currentVal: any) => HTMLElement; + getEleVal?: (ele: HTMLElement) => any; + setEleVal?: (ele: HTMLElement, val: any) => void; +} diff --git a/src/libs/promise-pool.ts b/src/libs/promise-pool.ts new file mode 100644 index 0000000..e20b0b8 --- /dev/null +++ b/src/libs/promise-pool.ts @@ -0,0 +1,48 @@ +export default class PromiseLimitPool { + private maxConcurrent: number; + private currentRunning = 0; + private queue: (() => void)[] = []; + private promises: Promise[] = []; + + constructor(maxConcurrent: number) { + this.maxConcurrent = maxConcurrent; + } + + add(fn: () => Promise): void { + const promise = new Promise((resolve, reject) => { + const run = async () => { + try { + this.currentRunning++; + const result = await fn(); + resolve(result); + } catch (error) { + reject(error); + } finally { + this.currentRunning--; + this.next(); + } + }; + + if (this.currentRunning < this.maxConcurrent) { + run(); + } else { + this.queue.push(run); + } + }); + this.promises.push(promise); + } + + async awaitAll(): Promise { + return Promise.all(this.promises); + } + + /** + * Handles the next task in the queue. + */ + private next(): void { + if (this.queue.length > 0 && this.currentRunning < this.maxConcurrent) { + const nextRun = this.queue.shift()!; + nextRun(); + } + } +} diff --git a/src/libs/setting-utils.ts b/src/libs/setting-utils.ts new file mode 100644 index 0000000..ae316e2 --- /dev/null +++ b/src/libs/setting-utils.ts @@ -0,0 +1,397 @@ +/* + * Copyright (c) 2023 by frostime. All Rights Reserved. + * @Author : frostime + * @Date : 2023-12-17 18:28:19 + * @FilePath : /src/libs/setting-utils.ts + * @LastEditTime : 2024-05-01 17:44:16 + * @Description : + */ + +import { Plugin, Setting } from 'siyuan'; + + +/** + * The default function to get the value of the element + * @param type + * @returns + */ +const createDefaultGetter = (type: TSettingItemType) => { + let getter: (ele: HTMLElement) => any; + switch (type) { + case 'checkbox': + getter = (ele: HTMLInputElement) => { + return ele.checked; + }; + break; + case 'select': + case 'slider': + case 'textinput': + case 'textarea': + getter = (ele: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) => { + return ele.value; + }; + break; + case 'number': + getter = (ele: HTMLInputElement) => { + return parseInt(ele.value); + } + break; + default: + getter = () => null; + break; + } + return getter; +} + + +/** + * The default function to set the value of the element + * @param type + * @returns + */ +const createDefaultSetter = (type: TSettingItemType) => { + let setter: (ele: HTMLElement, value: any) => void; + switch (type) { + case 'checkbox': + setter = (ele: HTMLInputElement, value: any) => { + ele.checked = value; + }; + break; + case 'select': + case 'slider': + case 'textinput': + case 'textarea': + case 'number': + setter = (ele: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, value: any) => { + ele.value = value; + }; + break; + default: + setter = () => {}; + break; + } + return setter; + +} + + +export class SettingUtils { + plugin: Plugin; + name: string; + file: string; + + settings: Map = new Map(); + elements: Map = new Map(); + + constructor(args: { + plugin: Plugin, + name?: string, + callback?: (data: any) => void, + width?: string, + height?: string + }) { + this.name = args.name ?? 'settings'; + this.plugin = args.plugin; + this.file = this.name.endsWith('.json') ? this.name : `${this.name}.json`; + this.plugin.setting = new Setting({ + width: args.width, + height: args.height, + confirmCallback: () => { + for (let key of this.settings.keys()) { + this.updateValueFromElement(key); + } + let data = this.dump(); + if (args.callback !== undefined) { + args.callback(data); + } + this.plugin.data[this.name] = data; + this.save(data); + }, + destroyCallback: () => { + //Restore the original value + for (let key of this.settings.keys()) { + this.updateElementFromValue(key); + } + } + }); + } + + async load() { + let data = await this.plugin.loadData(this.file); + console.debug('Load config:', data); + if (data) { + for (let [key, item] of this.settings) { + item.value = data?.[key] ?? item.value; + } + } + this.plugin.data[this.name] = this.dump(); + return data; + } + + async save(data?: any) { + data = data ?? this.dump(); + await this.plugin.saveData(this.file, this.dump()); + console.debug('Save config:', data); + return data; + } + + /** + * read the data after saving + * @param key key name + * @returns setting item value + */ + get(key: string) { + return this.settings.get(key)?.value; + } + + /** + * Set data to this.settings, + * but do not save it to the configuration file + * @param key key name + * @param value value + */ + set(key: string, value: any) { + let item = this.settings.get(key); + if (item) { + item.value = value; + this.updateElementFromValue(key); + } + } + + /** + * Set and save setting item value + * If you want to set and save immediately you can use this method + * @param key key name + * @param value value + */ + async setAndSave(key: string, value: any) { + let item = this.settings.get(key); + if (item) { + item.value = value; + this.updateElementFromValue(key); + await this.save(); + } + } + + /** + * Read in the value of element instead of setting obj in real time + * @param key key name + * @param apply whether to apply the value to the setting object + * if true, the value will be applied to the setting object + * @returns value in html + */ + take(key: string, apply: boolean = false) { + let item = this.settings.get(key); + let element = this.elements.get(key) as any; + if (!element) { + return + } + if (apply) { + this.updateValueFromElement(key); + } + return item.getEleVal(element); + } + + /** + * Read data from html and save it + * @param key key name + * @param value value + * @return value in html + */ + async takeAndSave(key: string) { + let value = this.take(key, true); + await this.save(); + return value; + } + + /** + * Disable setting item + * @param key key name + */ + disable(key: string) { + let element = this.elements.get(key) as any; + if (element) { + element.disabled = true; + } + } + + /** + * Enable setting item + * @param key key name + */ + enable(key: string) { + let element = this.elements.get(key) as any; + if (element) { + element.disabled = false; + } + } + + /** + * 将设置项目导出为 JSON 对象 + * @returns object + */ + dump(): Object { + let data: any = {}; + for (let [key, item] of this.settings) { + if (item.type === 'button') continue; + data[key] = item.value; + } + return data; + } + + addItem(item: ISettingUtilsItem) { + this.settings.set(item.key, item); + const IsCustom = item.type === 'custom'; + let error = IsCustom && (item.createElement === undefined || item.getEleVal === undefined || item.setEleVal === undefined); + if (error) { + console.error('The custom setting item must have createElement, getEleVal and setEleVal methods'); + return; + } + + if (item.getEleVal === undefined) { + item.getEleVal = createDefaultGetter(item.type); + } + if (item.setEleVal === undefined) { + item.setEleVal = createDefaultSetter(item.type); + } + + if (item.createElement === undefined) { + let itemElement = this.createDefaultElement(item); + this.elements.set(item.key, itemElement); + this.plugin.setting.addItem({ + title: item.title, + description: item?.description, + direction: item?.direction, + createActionElement: () => { + this.updateElementFromValue(item.key); + let element = this.getElement(item.key); + return element; + } + }); + } else { + this.plugin.setting.addItem({ + title: item.title, + description: item?.description, + direction: item?.direction, + createActionElement: () => { + let val = this.get(item.key); + let element = item.createElement(val); + this.elements.set(item.key, element); + return element; + } + }); + } + } + + createDefaultElement(item: ISettingUtilsItem) { + let itemElement: HTMLElement; + //阻止思源内置的回车键确认 + const preventEnterConfirm = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopImmediatePropagation(); + } + } + switch (item.type) { + case 'checkbox': + let element: HTMLInputElement = document.createElement('input'); + element.type = 'checkbox'; + element.checked = item.value; + element.className = "b3-switch fn__flex-center"; + itemElement = element; + element.onchange = item.action?.callback ?? (() => { }); + break; + case 'select': + let selectElement: HTMLSelectElement = document.createElement('select'); + selectElement.className = "b3-select fn__flex-center fn__size200"; + let options = item?.options ?? {}; + for (let val in options) { + let optionElement = document.createElement('option'); + let text = options[val]; + optionElement.value = val; + optionElement.text = text; + selectElement.appendChild(optionElement); + } + selectElement.value = item.value; + selectElement.onchange = item.action?.callback ?? (() => { }); + itemElement = selectElement; + break; + case 'slider': + let sliderElement: HTMLInputElement = document.createElement('input'); + sliderElement.type = 'range'; + sliderElement.className = 'b3-slider fn__size200 b3-tooltips b3-tooltips__n'; + sliderElement.ariaLabel = item.value; + sliderElement.min = item.slider?.min.toString() ?? '0'; + sliderElement.max = item.slider?.max.toString() ?? '100'; + sliderElement.step = item.slider?.step.toString() ?? '1'; + sliderElement.value = item.value; + sliderElement.onchange = () => { + sliderElement.ariaLabel = sliderElement.value; + item.action?.callback(); + } + itemElement = sliderElement; + break; + case 'textinput': + let textInputElement: HTMLInputElement = document.createElement('input'); + textInputElement.className = 'b3-text-field fn__flex-center fn__size200'; + textInputElement.value = item.value; + textInputElement.onchange = item.action?.callback ?? (() => { }); + itemElement = textInputElement; + textInputElement.addEventListener('keydown', preventEnterConfirm); + break; + case 'textarea': + let textareaElement: HTMLTextAreaElement = document.createElement('textarea'); + textareaElement.className = "b3-text-field fn__block"; + textareaElement.value = item.value; + textareaElement.onchange = item.action?.callback ?? (() => { }); + itemElement = textareaElement; + break; + case 'number': + let numberElement: HTMLInputElement = document.createElement('input'); + numberElement.type = 'number'; + numberElement.className = 'b3-text-field fn__flex-center fn__size200'; + numberElement.value = item.value; + itemElement = numberElement; + numberElement.addEventListener('keydown', preventEnterConfirm); + break; + case 'button': + let buttonElement: HTMLButtonElement = document.createElement('button'); + buttonElement.className = "b3-button b3-button--outline fn__flex-center fn__size200"; + buttonElement.innerText = item.button?.label ?? 'Button'; + buttonElement.onclick = item.button?.callback ?? (() => { }); + itemElement = buttonElement; + break; + case 'hint': + let hintElement: HTMLElement = document.createElement('div'); + hintElement.className = 'b3-label fn__flex-center'; + itemElement = hintElement; + break; + } + return itemElement; + } + + /** + * return the setting element + * @param key key name + * @returns element + */ + getElement(key: string) { + // let item = this.settings.get(key); + let element = this.elements.get(key) as any; + return element; + } + + private updateValueFromElement(key: string) { + let item = this.settings.get(key); + if (item.type === 'button') return; + let element = this.elements.get(key) as any; + item.value = item.getEleVal(element); + } + + private updateElementFromValue(key: string) { + let item = this.settings.get(key); + if (item.type === 'button') return; + let element = this.elements.get(key) as any; + item.setEleVal(element, item.value); + } +} \ No newline at end of file diff --git a/src/setting-example.svelte b/src/setting-example.svelte new file mode 100644 index 0000000..2a2c809 --- /dev/null +++ b/src/setting-example.svelte @@ -0,0 +1,139 @@ + + +
+
    + {#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/types/api.d.ts b/src/types/api.d.ts new file mode 100644 index 0000000..3c08859 --- /dev/null +++ b/src/types/api.d.ts @@ -0,0 +1,65 @@ +interface IResGetNotebookConf { + box: string; + conf: NotebookConf; + name: string; +} + +interface IReslsNotebooks { + notebooks: Notebook[]; +} + +interface IResUpload { + errFiles: string[]; + succMap: { [key: string]: string }; +} + +interface IResdoOperations { + doOperations: doOperation[]; + undoOperations: doOperation[] | null; +} + +interface IResGetBlockKramdown { + id: BlockId; + kramdown: string; +} + +interface IResGetChildBlock { + id: BlockId; + type: BlockType; + subtype?: BlockSubType; +} + +interface IResGetTemplates { + content: string; + path: string; +} + +interface IResReadDir { + isDir: boolean; + isSymlink: boolean; + name: string; +} + +interface IResExportMdContent { + hPath: string; + content: string; +} + +interface IResBootProgress { + progress: number; + details: string; +} + +interface IResForwardProxy { + body: string; + contentType: string; + elapsed: number; + headers: { [key: string]: string }; + status: number; + url: string; +} + +interface IResExportResources { + path: string; +} + diff --git a/src/types/index.d.ts b/src/types/index.d.ts new file mode 100644 index 0000000..f224b3e --- /dev/null +++ b/src/types/index.d.ts @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2024 by frostime. All Rights Reserved. + * @Author : frostime + * @Date : 2023-08-15 10:28:10 + * @FilePath : /src/types/index.d.ts + * @LastEditTime : 2024-06-08 20:50:53 + * @Description : Frequently used data structures in SiYuan + */ + + +type DocumentId = string; +type BlockId = string; +type NotebookId = string; +type PreviousID = BlockId; +type ParentID = BlockId | DocumentId; + +type Notebook = { + id: NotebookId; + name: string; + icon: string; + sort: number; + closed: boolean; +} + +type NotebookConf = { + name: string; + closed: boolean; + refCreateSavePath: string; + createDocNameTemplate: string; + dailyNoteSavePath: string; + dailyNoteTemplatePath: string; +} + +type BlockType = + | 'd' + | 'p' + | 'query_embed' + | 'l' + | 'i' + | 'h' + | 'iframe' + | 'tb' + | 'b' + | 's' + | 'c' + | 'widget' + | 't' + | 'html' + | 'm' + | 'av' + | 'audio'; + + +type BlockSubType = "d1" | "d2" | "s1" | "s2" | "s3" | "t1" | "t2" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "table" | "task" | "toggle" | "latex" | "quote" | "html" | "code" | "footnote" | "cite" | "collection" | "bookmark" | "attachment" | "comment" | "mindmap" | "spreadsheet" | "calendar" | "image" | "audio" | "video" | "other"; + +type Block = { + id: BlockId; + parent_id?: BlockId; + root_id: DocumentId; + hash: string; + box: string; + path: string; + hpath: string; + name: string; + alias: string; + memo: string; + tag: string; + content: string; + fcontent?: string; + markdown: string; + length: number; + type: BlockType; + subtype: BlockSubType; + /** string of { [key: string]: string } + * For instance: "{: custom-type=\"query-code\" id=\"20230613234017-zkw3pr0\" updated=\"20230613234509\"}" + */ + ial?: string; + sort: number; + created: string; + updated: string; +} + +type doOperation = { + action: string; + data: string; + id: BlockId; + parentID: BlockId | DocumentId; + previousID: BlockId; + retData: null; +} + +interface Window { + siyuan: { + config: any; + notebooks: any; + menus: any; + dialogs: any; + blockPanels: any; + storage: any; + user: any; + ws: any; + languages: any; + emojis: any; + }; + Lute: any; +} diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..d62a343 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2024 by frostime. All Rights Reserved. + * @Author : frostime + * @Date : 2023-05-19 19:49:13 + * @FilePath : /svelte.config.js + * @LastEditTime : 2024-04-19 19:01:55 + * @Description : + */ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte" + +const NoWarns = new Set([ + "a11y-click-events-have-key-events", + "a11y-no-static-element-interactions", + "a11y-no-noninteractive-element-interactions" +]); + +export default { + // Consult https://svelte.dev/docs#compile-time-svelte-preprocess + // for more information about preprocessors + preprocess: vitePreprocess(), + onwarn: (warning, handler) => { + // suppress warnings on `vite dev` and `vite build`; but even without this, things still work + if (NoWarns.has(warning.code)) return; + handler(warning); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0fcc1ad --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,59 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + /* Bundler mode */ + "moduleResolution": "Node", + // "allowImportingTsExtensions": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + /* Linting */ + "strict": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + /* Svelte */ + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable checkJs if you'd like to use dynamic types in JS. + * Note that setting allowJs false does not prevent the use + * of JS in `.svelte` files. + */ + "allowJs": true, + "checkJs": true, + "types": [ + "node", + "vite/client", + "svelte" + ], + // "baseUrl": "./src", + "paths": { + "@/*": ["./src/*"], + "@/libs/*": ["./src/libs/*"], + } + }, + "include": [ + "tools/**/*.ts", + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.tsx", + "src/**/*.vue", + "src/**/*.svelte" + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ], + "root": "." +} \ No newline at end of file diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..1951553 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": [ + "vite.config.ts" + ] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..cb7511b --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,163 @@ +import { resolve } from "path" +import { defineConfig, loadEnv } from "vite" +import { viteStaticCopy } from "vite-plugin-static-copy" +import livereload from "rollup-plugin-livereload" +import { svelte } from "@sveltejs/vite-plugin-svelte" +import zipPack from "vite-plugin-zip-pack"; +import fg from 'fast-glob'; + +import vitePluginYamlI18n from './yaml-plugin'; + +const env = process.env; +const isSrcmap = env.VITE_SOURCEMAP === 'inline'; +const isDev = env.NODE_ENV === 'development'; + +const outputDir = isDev ? "dev" : "dist"; + +console.log("isDev=>", isDev); +console.log("isSrcmap=>", isSrcmap); +console.log("outputDir=>", outputDir); + +export default defineConfig({ + resolve: { + alias: { + "@": resolve(__dirname, "src"), + } + }, + + plugins: [ + svelte(), + + vitePluginYamlI18n({ + inDir: 'public/i18n', + outDir: `${outputDir}/i18n` + }), + + viteStaticCopy({ + targets: [ + { src: "./README*.md", dest: "./" }, + { src: "./plugin.json", dest: "./" }, + { src: "./preview.png", dest: "./" }, + { src: "./icon.png", dest: "./" } + ], + }), + + ], + + define: { + "process.env.DEV_MODE": JSON.stringify(isDev), + "process.env.NODE_ENV": JSON.stringify(env.NODE_ENV) + }, + + build: { + outDir: outputDir, + emptyOutDir: false, + minify: true, + sourcemap: isSrcmap ? 'inline' : false, + + lib: { + entry: resolve(__dirname, "src/index.ts"), + fileName: "index", + formats: ["cjs"], + }, + rollupOptions: { + plugins: [ + ...(isDev ? [ + livereload(outputDir), + { + name: 'watch-external', + async buildStart() { + const files = await fg([ + 'public/i18n/**', + './README*.md', + './plugin.json' + ]); + for (let file of files) { + this.addWatchFile(file); + } + } + } + ] : [ + // Clean up unnecessary files under dist dir + cleanupDistFiles({ + patterns: ['i18n/*.yaml', 'i18n/*.md'], + distDir: outputDir + }), + zipPack({ + inDir: './dist', + outDir: './', + outFileName: 'package.zip' + }) + ]) + ], + + external: ["siyuan", "process"], + + output: { + entryFileNames: "[name].js", + assetFileNames: (assetInfo) => { + if (assetInfo.name === "style.css") { + return "index.css" + } + return assetInfo.name + }, + }, + }, + } +}); + + +/** + * Clean up some dist files after compiled + * @author frostime + * @param options: + * @returns + */ +function cleanupDistFiles(options: { patterns: string[], distDir: string }) { + const { + patterns, + distDir + } = options; + + return { + name: 'rollup-plugin-cleanup', + enforce: 'post', + writeBundle: { + sequential: true, + order: 'post' as 'post', + async handler() { + const fg = await import('fast-glob'); + const fs = await import('fs'); + // const path = await import('path'); + + // 使用 glob 语法,确保能匹配到文件 + const distPatterns = patterns.map(pat => `${distDir}/${pat}`); + console.debug('Cleanup searching patterns:', distPatterns); + + const files = await fg.default(distPatterns, { + dot: true, + absolute: true, + onlyFiles: false + }); + + // console.info('Files to be cleaned up:', files); + + for (const file of files) { + try { + if (fs.default.existsSync(file)) { + const stat = fs.default.statSync(file); + if (stat.isDirectory()) { + fs.default.rmSync(file, { recursive: true }); + } else { + fs.default.unlinkSync(file); + } + console.log(`Cleaned up: ${file}`); + } + } catch (error) { + console.error(`Failed to clean up ${file}:`, error); + } + } + } + } + }; +} diff --git a/yaml-plugin.js b/yaml-plugin.js new file mode 100644 index 0000000..01c85e2 --- /dev/null +++ b/yaml-plugin.js @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2024 by frostime. All Rights Reserved. + * @Author : frostime + * @Date : 2024-04-05 21:27:55 + * @FilePath : /yaml-plugin.js + * @LastEditTime : 2024-04-05 22:53:34 + * @Description : 去妮玛的 json 格式,我就是要用 yaml 写 i18n + */ +// plugins/vite-plugin-parse-yaml.js +import fs from 'fs'; +import yaml from 'js-yaml'; +import { resolve } from 'path'; + +export default function vitePluginYamlI18n(options = {}) { + // Default options with a fallback + const DefaultOptions = { + inDir: 'src/i18n', + outDir: 'dist/i18n', + }; + + const finalOptions = { ...DefaultOptions, ...options }; + + return { + name: 'vite-plugin-yaml-i18n', + buildStart() { + console.log('🌈 Parse I18n: YAML to JSON..'); + const inDir = finalOptions.inDir; + const outDir = finalOptions.outDir + + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + + //Parse yaml file, output to json + const files = fs.readdirSync(inDir); + for (const file of files) { + if (file.endsWith('.yaml') || file.endsWith('.yml')) { + console.log(`-- Parsing ${file}`) + //检查是否有同名的json文件 + const jsonFile = file.replace(/\.(yaml|yml)$/, '.json'); + if (files.includes(jsonFile)) { + console.log(`---- File ${jsonFile} already exists, skipping...`); + continue; + } + try { + const filePath = resolve(inDir, file); + const fileContents = fs.readFileSync(filePath, 'utf8'); + const parsed = yaml.load(fileContents); + const jsonContent = JSON.stringify(parsed, null, 2); + const outputFilePath = resolve(outDir, file.replace(/\.(yaml|yml)$/, '.json')); + console.log(`---- Writing to ${outputFilePath}`); + fs.writeFileSync(outputFilePath, jsonContent); + } catch (error) { + this.error(`---- Error parsing YAML file ${file}: ${error.message}`); + } + } + } + }, + }; +}