diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..36af5e0 --- /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: true 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..fe214da --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +## 二级文档列表插件 + + +### 功能介绍 +1. 点击文档树中的的笔记本或文档会切换(刷新)文档列表。 +2. 双击文档列表的文档,会在文档树中定位该文档。 + +#### 何时会切换路径 +1. 点击笔记本名称或文档名称。 +2. 点击的文档存在子文档。 + +注意:文档前面的 折叠按钮、Emoji,后面的 更多按钮、添加子文档按钮 不会触发切换路径; + + + + +### 注意事项 + +该插件使用两种搜索方式,一种是“数据库查询”,另一种是“根据路径获取文档接口”; + +因为数据库不存在文档的子文档数量、文档大小、文档顺序 信息,所以在使用数据库查询时,使用了前面提到的排序方式,会重置为设置中的:“数据库默认查询方式”。 + + +#### 何时使用数据库查询? +1. 显示全部文档时。 +2. 开启显示子文档的子文档。 +3. 开启全文搜索 同时 使用了关键字搜索。 + diff --git a/README_zh_CN.md b/README_zh_CN.md new file mode 100644 index 0000000..fe214da --- /dev/null +++ b/README_zh_CN.md @@ -0,0 +1,28 @@ +## 二级文档列表插件 + + +### 功能介绍 +1. 点击文档树中的的笔记本或文档会切换(刷新)文档列表。 +2. 双击文档列表的文档,会在文档树中定位该文档。 + +#### 何时会切换路径 +1. 点击笔记本名称或文档名称。 +2. 点击的文档存在子文档。 + +注意:文档前面的 折叠按钮、Emoji,后面的 更多按钮、添加子文档按钮 不会触发切换路径; + + + + +### 注意事项 + +该插件使用两种搜索方式,一种是“数据库查询”,另一种是“根据路径获取文档接口”; + +因为数据库不存在文档的子文档数量、文档大小、文档顺序 信息,所以在使用数据库查询时,使用了前面提到的排序方式,会重置为设置中的:“数据库默认查询方式”。 + + +#### 何时使用数据库查询? +1. 显示全部文档时。 +2. 开启显示子文档的子文档。 +3. 开启全文搜索 同时 使用了关键字搜索。 + 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..4b6609f Binary files /dev/null and b/icon.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..03a2a88 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "plugin-sample-vite-svelte", + "version": "0.3.5", + "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": { + "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", + "dev": "vite build --watch", + "update-version": "node --no-warnings ./scripts/update_version.js", + "build": "vite build", + "make-install": "vite build && node --no-warnings ./scripts/make_install.js" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@tsconfig/svelte": "^4.0.1", + "@types/node": "^20.3.0", + "fast-glob": "^3.2.12", + "glob": "^7.2.3", + "js-yaml": "^4.1.0", + "minimist": "^1.2.8", + "rollup-plugin-livereload": "^2.0.5", + "sass": "^1.63.3", + "siyuan": "0.9.9", + "svelte": "^4.2.0", + "ts-node": "^10.9.1", + "typescript": "^5.1.3", + "vite": "^5.0.0", + "vite-plugin-static-copy": "^1.0.2", + "vite-plugin-zip-pack": "^1.0.5" + } +} diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..a234cd9 --- /dev/null +++ b/plugin.json @@ -0,0 +1,38 @@ +{ + "name": "syplugin-dual-doc-list", + "author": "Misuzu2027", + "url": "https://github.com/Misuzu2027/syplugin-dual-doc-list", + "version": "0.0.1", + "minAppVersion": "3.0.12", + "backends": [ + "all" + ], + "frontends": [ + "desktop", + "browser-desktop", + "desktop-window" + ], + "displayName": { + "en_US": "Dual-Level Document List", + "zh_CN": "二级文档列表" + }, + "description": { + "en_US": "Dual-Level Document List", + "zh_CN": "二级文档列表" + }, + "readme": { + "en_US": "README.md", + "zh_CN": "README_zh_CN.md" + }, + "funding": { + "custom": [ + "" + ] + }, + "keywords": [ + "document", + "tree", + "文档", + "搜索" + ] +} \ No newline at end of file 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/en_US.json b/public/i18n/en_US.json new file mode 100644 index 0000000..42ab238 --- /dev/null +++ b/public/i18n/en_US.json @@ -0,0 +1,83 @@ +{ + "openDocumentSearchTab": "Open Document-based Search Tab", + "table": "Table", + "mathBlock": "Formula block", + "quoteBlock": "Blockquote", + "superBlock": "Super block", + "paragraph": "Paragraph", + "doc": "Doc", + "headings": "Headings", + "list": "List", + "listItem": "List item", + "codeBlock": "Code block", + "htmlBlock": "HTML block", + "embedBlock": "Embed block", + "database": "Database", + "video": "Video", + "audio": "Audio", + "IFrame": "IFrame", + "widget": "Widget", + "name": "Name", + "alias": "Alias", + "memo": "Memo", + "allAttrs": "All attribute names and attribute values", + "sortByRankASC": "Relevance ASC", + "sortByRankDESC": "Relevance DESC", + "modifiedASC": "Modified Time ASC", + "modifiedDESC": "Modified Time DESC", + "createdASC": "Created Time ASC", + "createdDESC": "Created Time DESC", + "type": "Type", + "sortByContent": "Original content order", + "sortByTypeAndContent": "Type And Original content order", + "show": "Show", + "hide": "Hide", + "refCountASC": "Ref Count ASC", + "refCountDESC": "Ref Count DESC", + "fileNameASC": "Name Alphabet ASC", + "fileNameDESC": "Name Alphabet DESC", + "fileNameNatASC": "Name Natural ASC", + "fileNameNatDESC": "Name Natural DESC", + "notebook": "Notebook", + "path": "Path", + "sort": "Sort", + "clear": "Clear", + "refresh": "Refresh", + "reference": "Ref", + "documentBasedSearch": "Document-based Search", + "flatDocumentTree": "Flat Document Tree", + "documentBasedSearchDock": "Document-based Search Dock", + "flatDocumentTreeDock": "Flat Document Tree Dock", + "previousLabel": "Previous", + "nextLabel": "Next", + "findInDoc": "Found ${x} documents", + "notebookFilter": "Notebook Filter", + "attr": "Attribute", + "other": "Other", + "expand": "Expand", + "collapse": "Collapse", + "noContentBelow": "No content matching the criteria exists below", + "dockModifyTips": "Note: Modifying Dock will refresh the interface.", + "settingDock": "🌈 Dock Settings", + "settingNotebookFilter": "🌈 Notebook Filter", + "settingType": "🌈 Type", + "settingAttr": "🌈 Attribute", + "settingOther": "🌈 Other", + "docSortMethod": "Document Sorting Method", + "contentBlockSortMethod": "Content Block Sorting Method", + "documentsPerPage": "Documents Per Page", + "blockCountBehaviorTips": "If the number of blocks in the query result is less than the current value, all documents will be expanded by default; otherwise, all documents will be collapsed by default.", + "defaultExpansionCount": "Default Expansion Count", + "alwaysExpandSingleDoc": "Always Expand Single Document", + "displayDocBlock": "Display Document Blocks", + "doubleClickTimeThreshold": "Double Click Time Threshold (Milliseconds)", + "previewRefreshHighlightDelayTips": "For code blocks, databases, and other blocks that require time to render, too short a delay may fail. Set to 0 if not needed.", + "previewRefreshHighlightDelay": "Preview Refresh Highlight Delay (Milliseconds)", + "settingHub": "Settings Hub", + "switchCurrentDocumentSearchFailureMessage": "Document Search Plugin: Failed to retrieve the opened document. You can switch tabs or reopen the document.", + "swapDocumentItemClickLogic": "Swap Document Item Click Logic", + "swapDocumentItemClickLogicTips": "When disabled, click to expand search results and double-click to open the document; when enabled, click to open the document and double-click to expand search results.", + "allNotebooks": "All Notebooks", + "specifyNotebook": "Specify Notebook", + "searchInTheCurrentDocument": "Search in the current document" +} \ 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..4816714 --- /dev/null +++ b/public/i18n/zh_CN.json @@ -0,0 +1,84 @@ +{ + "openDocumentSearchTab": "打开搜索页签", + "table": "表格", + "mathBlock": "公式块", + "quoteBlock": "引述块", + "superBlock": "超级块", + "paragraph": "段落", + "doc": "文档", + "headings": "标题", + "list": "列表", + "listItem": "列表项", + "codeBlock": "代码块", + "htmlBlock": "HTML 块", + "embedBlock": "嵌入块", + "database": "数据库", + "video": "视频", + "audio": "音频", + "IFrame": "IFrame", + "widget": "挂件", + "name": "name", + "alias": "别名", + "memo": "备注", + "allAttrs": "所有属性名和属性值", + "sortByRankASC": "按相关度升序", + "sortByRankDESC": "按相关度降序", + "modifiedASC": "修改时间升序", + "modifiedDESC": "修改时间降序", + "createdASC": "创建时间升序", + "createdDESC": "创建时间降序", + "type": "类型", + "sortByContent": "按原文内容顺序", + "sortByTypeAndContent": "类型和原文内容顺序", + "show": "显示", + "hide": "隐藏", + "refCountASC": "引用数升序", + "refCountDESC": "引用数降序", + "fileNameASC": "名称字母升序", + "fileNameDESC": "名称字母降序", + "fileNameNatASC": "名称自然升序", + "fileNameNatDESC": "名称自然降序", + "notebook": "笔记本", + "path": "路径", + "sort": "排序", + "clear": "清空", + "refresh": "刷新", + "reference": "引用", + "documentBasedSearch": "基于文档搜索", + "flatDocumentTree": "扁平化文档树", + "documentBasedSearchDock": "基于文档搜索 Dock", + "flatDocumentTreeDock": "扁平化文档树 Dock", + "previousLabel": "上一页", + "nextLabel": "下一页", + "findInDoc": "匹配到 ${x} 个文档", + "notebookFilter": "笔记本过滤", + "attr": "属性", + "other": "其他", + "expand": "展开", + "collapse": "折叠", + "noContentBelow": "下不存在符合条件的内容", + "dockModifyTips": "注:修改 Dock 会刷新界面。", + "settingDock": "🌈 Dock 设置", + "settingNotebookFilter": "🌈 笔记本过滤", + "settingType": "🌈 类型", + "settingAttr": "🌈 属性", + "settingOther": "🌈 其他", + "docSortMethod": "文档排序方式", + "contentBlockSortMethod": "内容块排序方式", + "documentsPerPage": "每页文档数量", + "blockCountBehaviorTips": "如果查询结果的块数量小于当前值,默认展开全部文档;反之会默认折叠全部文档。", + "defaultExpansionCount": "默认展开数", + "alwaysExpandSingleDoc": "单篇文档始终展开", + "displayDocBlock": "显示文档块", + "doubleClickTimeThreshold": "双击时间阈值(毫秒)", + "previewRefreshHighlightDelayTips": "用于代码块、数据库这种需要时间渲染的块高亮,太短可能会失败,不需要可以设置为0", + "previewRefreshHighlightDelay": "刷新预览区高亮延迟(毫秒)", + "settingHub": "设置中心", + "switchCurrentDocumentSearchFailureMessage": "文档搜索插件: 没有获取到打开的文档,可切换页签或重新打开文档。", + "swapDocumentItemClickLogic": "交换文档项点击逻辑", + "swapDocumentItemClickLogicTips": "禁用时,单击展开搜索结果,双击打开文档;启用时,单击打开文档,双击展开搜索结果。", + "allNotebooks": "所有笔记本", + "specifyNotebook": "指定笔记本", + "searchInTheCurrentDocument": "在当前文档查询" + +} \ 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/components/doc-list/DocListManager.ts b/src/components/doc-list/DocListManager.ts new file mode 100644 index 0000000..7a16055 --- /dev/null +++ b/src/components/doc-list/DocListManager.ts @@ -0,0 +1,209 @@ +import DocList from "@/components/doc-list/doc-list.svelte"; +import { SettingService } from "@/service/setting/SettingService"; +import { findParentElementWithAttribute, getAttributeRecursively } from "@/utils/html-util"; +import Instance from "@/utils/Instance"; + +const EmbedDualDocListElementAttrName = "data-misuzu2027-embed-dualDocList"; +export class DocListManager { + + + public static get ins(): DocListManager { + return Instance.get(DocListManager); + } + + + private checkEmbedIntervalId; + + private embedDocListSvelte: DocList; + + init() { + this.initInterval(); + this.initElementEventListener(); + } + + + destroy() { + this.destroyInterval(); + this.destroyElementEventListener(); + this.destroyEmbedDualDocList(); + } + + initElementEventListener() { + document.addEventListener('click', this.documentGlobeClickEvent, true); + } + + destroyElementEventListener() { + document.removeEventListener('click', this.documentGlobeClickEvent, true); + } + + initInterval() { + this.destroyInterval(); + this.checkEmbedIntervalId = setInterval(() => this.intervalCheckEmbedDualDocTree(), 800) + } + + destroyInterval() { + if (this.checkEmbedIntervalId) { + clearInterval(this.checkEmbedIntervalId); + this.checkEmbedIntervalId = null; + } + } + + intervalCheckEmbedDualDocTree() { + let showEmbedDualDocList = SettingService.ins.SettingConfig.showEmbedDualDocList; + if (showEmbedDualDocList) { + this.createEmbedDualDocList(); + } else { + this.destroyEmbedDualDocList(); + } + } + + createEmbedDualDocList() { + let fileTreeDocElement = document.querySelector("#layouts div.layout-tab-container div.file-tree.sy__file"); + if (!fileTreeDocElement) { + return; + } + let docTreeId = fileTreeDocElement.getAttribute("data-id"); + let dualDocListElement: HTMLElement = null; + let oldDocListElementArray = document.querySelectorAll(`div.layout-tab-container div[${EmbedDualDocListElementAttrName}]`); + + if (oldDocListElementArray) { + for (const element of oldDocListElementArray) { + if (element.getAttribute("data-id") == docTreeId) { + dualDocListElement = element as HTMLElement; + } else { + element.remove(); + } + } + } + + let settingConfig = SettingService.ins.SettingConfig; + let listViewFlex = "1"; + if (!isNaN(settingConfig.embedDocListViewFlex)) { + listViewFlex = settingConfig.embedDocListViewFlex.toString(); + } + + if (dualDocListElement) { + if (dualDocListElement.style.flex != listViewFlex) { + dualDocListElement.style.flex = listViewFlex; + } + return; + } + + if (this.embedDocListSvelte) { + this.embedDocListSvelte.$destroy(); + this.embedDocListSvelte = null; + } + let docListElement = document.createElement("div"); + docListElement.setAttribute("data-id", docTreeId); + docListElement.setAttribute(EmbedDualDocListElementAttrName, "1"); + docListElement.classList.add("fn__flex-1"); + + docListElement.style.flex = listViewFlex; + + docListElement.addEventListener("click", (event) => { + event.stopPropagation(); + }) + + if (document.querySelector("div.layout__dockl").contains(fileTreeDocElement)) { + fileTreeDocElement.after(docListElement); + } else { + fileTreeDocElement.before(docListElement); + } + + + console.log(fileTreeDocElement); + + + this.embedDocListSvelte = new DocList({ + target: docListElement, + props: { + } + }); + } + + destroyEmbedDualDocList() { + if (this.embedDocListSvelte) { + this.embedDocListSvelte.$destroy(); + this.embedDocListSvelte = null; + } + let docListPageElementArray = document.querySelectorAll(`div.layout-tab-container div[data-id][${EmbedDualDocListElementAttrName}]`); + if (docListPageElementArray) { + for (const pageElement of docListPageElementArray) { + pageElement.remove(); + } + } + } + + clickCount: number = 0; + + documentGlobeClickEvent = (event: MouseEvent) => { + if (event.button != 0 || event.ctrlKey) { + return; + } + + let fileTreeDocElement = document.querySelector("#layouts div.layout-tab-container div.file-tree.sy__file"); + let target = event.target as HTMLElement; + + if (!fileTreeDocElement || !fileTreeDocElement.contains(target)) { + return; + } + const targetLiElement = findParentElementWithAttribute(target, ["navigation-file", "navigation-root"], 4); + if (!targetLiElement || !target.classList.contains("b3-list-item__text")) return; + + let targetLiElementType = targetLiElement.getAttribute("data-type"); + if (targetLiElementType != "navigation-file" && targetLiElementType != "navigation-root") { + return + } + + // 如果是文档,但是不存在子文档。 + if (targetLiElementType == "navigation-file" + && targetLiElement.querySelector("span.b3-list-item__toggle").classList.contains("fn__hidden") + ) { + return; + } + // 如果是笔记本,判断一下是否启用双击切换文档折叠。 + if (targetLiElementType == "navigation-root") { + this.handleNotebookDoubleClick(event); + } + + this.handleSelectDoc(targetLiElement) + } + + private handleNotebookDoubleClick(event: MouseEvent): void { + let settingConfig = SettingService.ins.SettingConfig; + if (!settingConfig || !settingConfig.doubleClickToggleNotebook) { + return; + } + this.clickCount++; + let doubleClickTimeout = settingConfig.doubleClickTimeout; + if (this.clickCount < 2) { + event.stopPropagation(); + event.preventDefault(); + setTimeout(() => { + this.clickCount = 0; + }, doubleClickTimeout); + } + } + + handleSelectDoc(targetLiElement: HTMLElement) { + if (!targetLiElement) { + return; + } + let notebookId: string; + let docId: string; + let docPath: string; + // let type = targetLiElement.getAttribute("data-type"); + notebookId = getAttributeRecursively(targetLiElement, "data-url"); + docId = targetLiElement.getAttribute("data-node-id"); + docPath = targetLiElement.getAttribute("data-path"); + + if ((!docId && !notebookId) || !this.embedDocListSvelte) { + return; + } + + this.embedDocListSvelte.switchPath(notebookId, docId, docPath); + } + + + +} diff --git a/src/components/doc-list/doc-list.svelte b/src/components/doc-list/doc-list.svelte new file mode 100644 index 0000000..6970830 --- /dev/null +++ b/src/components/doc-list/doc-list.svelte @@ -0,0 +1,981 @@ + + + + +
+
+
+
+ {documentItems.length} +
+ + + + + + + + +
+ +
+ {@html showCurPath} +
+ + +
+
+ + +
+
+
+ +
+
+ +
+
+ + + + + + + + + +
+
+ { + refreshDocList(); + }} + on:keydown={handleKeyDownDefault} + > + + +
+
+
+
+ {#each documentItems as item} +
    +
  • + + {#if item.icon} + {@html item.icon} + {:else} + 📄 + {/if} + + + {@html item.fileBlock.content} + + + {#if item.refCount} + + {item.refCount} + + {/if} +
  • +
+ {/each} +
+
+ +
+ + +
+ + diff --git a/src/components/setting/SettingManager.ts b/src/components/setting/SettingManager.ts new file mode 100644 index 0000000..6dc0ba2 --- /dev/null +++ b/src/components/setting/SettingManager.ts @@ -0,0 +1,27 @@ +import { EnvConfig } from "@/config/EnvConfig"; +import { Dialog } from "siyuan"; +import SettingPageSvelte from "@/components/setting/setting-page.svelte" + + + + +export function openSettingsDialog() { + let isMobile = EnvConfig.ins.isMobile; + // 生成Dialog内容 + const dialogId = "backlink-panel-setting-" + Date.now(); + // 创建dialog + const settingDialog = new Dialog({ + title: "二级文档列表插件设置", + content: ` +
+ `, + width: isMobile ? "92vw" : "1040px", + height: isMobile ? "50vw" : "80vh", + }); + + new SettingPageSvelte({ + target: settingDialog.element.querySelector(`#${dialogId}`), + }); + + +} \ No newline at end of file diff --git a/src/components/setting/inputs/setting-input.svelte b/src/components/setting/inputs/setting-input.svelte new file mode 100644 index 0000000..c6f9c46 --- /dev/null +++ b/src/components/setting/inputs/setting-input.svelte @@ -0,0 +1,41 @@ + + +{#if itemProperty.type === "text"} + +{:else if itemProperty.type === "number"} + +{/if} diff --git a/src/components/setting/inputs/setting-select.svelte b/src/components/setting/inputs/setting-select.svelte new file mode 100644 index 0000000..60fac5f --- /dev/null +++ b/src/components/setting/inputs/setting-select.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/components/setting/inputs/setting-switch.svelte b/src/components/setting/inputs/setting-switch.svelte new file mode 100644 index 0000000..c037d42 --- /dev/null +++ b/src/components/setting/inputs/setting-switch.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/components/setting/setting-item.svelte b/src/components/setting/setting-item.svelte new file mode 100644 index 0000000..5c3fe40 --- /dev/null +++ b/src/components/setting/setting-item.svelte @@ -0,0 +1,16 @@ + + +
+
+ {itemProperty.name} +
+ {@html itemProperty.description} +
+
+
+ +
diff --git a/src/components/setting/setting-page.svelte b/src/components/setting/setting-page.svelte new file mode 100644 index 0000000..63dd5ab --- /dev/null +++ b/src/components/setting/setting-page.svelte @@ -0,0 +1,67 @@ + + + + +
+ +
+ {#each tabArray as tab} + {#if activeTab === tab.key} +
+ {#each tab.props as itemProperty} + + {#if itemProperty.type == "switch"} + + {:else if itemProperty.type == "select"} + + {:else if itemProperty.type == "number" || itemProperty.type == "text"} + + {:else} + 不能载入设置项,请检查设置代码实现。 Key: {itemProperty.key} +
+ can't load settings, check code please. Key: + {itemProperty.key} + {/if} +
+ {/each} +
+ {/if} + {/each} +
+
diff --git a/src/config/EnvConfig.ts b/src/config/EnvConfig.ts new file mode 100644 index 0000000..6f201aa --- /dev/null +++ b/src/config/EnvConfig.ts @@ -0,0 +1,64 @@ + +import { getNotebookMap, getNotebookMapByApi } from "@/utils/api"; +import Instance from "@/utils/Instance"; +import { App, I18N, Plugin, getFrontend } from "siyuan"; + +export class EnvConfig { + + + public static get ins(): EnvConfig { + return Instance.get(EnvConfig); + } + + get isMobile(): boolean { + let frontEnd: string = getFrontend(); + let isMobile = frontEnd === "mobile" || frontEnd === "browser-mobile"; + return isMobile; + } + + private _plugin: Plugin; + get plugin(): Plugin { + return this._plugin; + } + + get app(): App { + return this._plugin.app; + } + + get i18n(): I18N { + if (this._plugin) { + return this._plugin.i18n; + } + const i18nObject: I18N = { + // 添加你需要的属性和方法 + }; + return i18nObject; + } + + public lastViewedDocId: string; + + + public init(plugin: Plugin) { + this._plugin = plugin; + } + + + // docSearchDock: { config: IPluginDockTab, model: IDockModel }; + // flatDocTreeDock: { config: IPluginDockTab, model: IDockModel }; + + + private _notebookMap: Map = new Map(); + public get notebookMap(): Map { + if (!this._notebookMap || this._notebookMap.size == 0) { + this.refreshNotebookMap(); + return getNotebookMap(window.siyuan.notebooks); + } + return this._notebookMap; + } + + public async refreshNotebookMap(): Promise> { + this._notebookMap = await getNotebookMapByApi(); + return this._notebookMap; + } + +} \ No newline at end of file diff --git a/src/index.scss b/src/index.scss new file mode 100644 index 0000000..254bcda --- /dev/null +++ b/src/index.scss @@ -0,0 +1,37 @@ +.box-path__icon { + position: relative; + // cursor: pointer; + overflow: hidden; + + // text-align: center; + // font-size: 16px; + font-family: var(--b3-font-family-emoji); + margin-right: 4px; + // line-height: 22px; + transition: var(--b3-transition); + // height: 22px; + // padding: 0 4px; + // flex-shrink: 0; + border-radius: var(--b3-border-radius); +} + +.box-path__icon img, +.box-path__icon svg { + max-width: 100%; + vertical-align: middle; + border: 0; + // height: auto; + -ms-interpolation-mode: bicubic; + overflow: hidden; + float: left; + margin: 3px 0; + height: 17px; + width: 17px; + color: var(--b3-theme-on-surface); + border-radius: var(--b3-border-radius); + +} + +.scroll-container span { + font-size: 88%; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6ed670d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,47 @@ +import { + Plugin, +} from "siyuan"; +import "@/index.scss"; +import { DocListManager } from "./components/doc-list/DocListManager"; +import { EnvConfig } from "./config/EnvConfig"; +import { CUSTOM_ICON_MAP } from "./models/icon-constant"; +import { openSettingsDialog } from "./components/setting/SettingManager"; +import { SettingService } from "./service/setting/SettingService"; + + + +export default class PluginSample extends Plugin { + + async onload() { + EnvConfig.ins.init(this); + SettingService.ins.init(); + DocListManager.ins.init(); + + // 图标的制作参见帮助文档 + for (const key in CUSTOM_ICON_MAP) { + if (Object.prototype.hasOwnProperty.call(CUSTOM_ICON_MAP, key)) { + const item = CUSTOM_ICON_MAP[key]; + this.addIcons(item.source); + } + } + } + + onLayoutReady() { + + } + + async onunload() { } + + uninstall() { } + + openSetting(): void { + openSettingsDialog(); + } + + + + + +} + + diff --git a/src/libs/siyuan/functions.ts b/src/libs/siyuan/functions.ts new file mode 100644 index 0000000..b589548 --- /dev/null +++ b/src/libs/siyuan/functions.ts @@ -0,0 +1,3 @@ +export const isTouchDevice = () => { + return ("ontouchstart" in window) && navigator.maxTouchPoints > 1; +}; diff --git a/src/libs/siyuan/hasClosest.ts b/src/libs/siyuan/hasClosest.ts new file mode 100644 index 0000000..1bbc696 --- /dev/null +++ b/src/libs/siyuan/hasClosest.ts @@ -0,0 +1,54 @@ +export const hasClosestByTag = (element: Node, nodeName: string) => { + if (!element) { + return false; + } + if (element.nodeType === 3) { + element = element.parentElement; + } + let e = element as HTMLElement; + let isClosest = false; + while (e && !isClosest && !e.classList.contains("b3-typography")) { + if (e.nodeName.indexOf(nodeName) === 0) { + isClosest = true; + } else { + e = e.parentElement; + } + } + return isClosest && e; +}; + +export const hasTopClosestByTag = (element: Node, nodeName: string) => { + let closest = hasClosestByTag(element, nodeName); + let parentClosest: boolean | HTMLElement = false; + let findTop = false; + while (closest && !closest.classList.contains("protyle-wysiwyg") && !findTop) { + parentClosest = hasClosestByTag(closest.parentElement, nodeName); + if (parentClosest) { + closest = parentClosest; + } else { + findTop = true; + } + } + return closest || false; +}; + +export const hasClosestByAttribute = (element: Node, attr: string, value: string | null, top = false) => { + if (!element) { + return false; + } + if (element.nodeType === 3) { + element = element.parentElement; + } + let e = element as HTMLElement; + let isClosest = false; + while (e && !isClosest && (top ? e.tagName !== "BODY" : !e.classList.contains("protyle-wysiwyg"))) { + if (typeof value === "string" && e.getAttribute(attr)?.split(" ").includes(value)) { + isClosest = true; + } else if (typeof value !== "string" && e.hasAttribute(attr)) { + isClosest = true; + } else { + e = e.parentElement; + } + } + return isClosest && e; +}; diff --git a/src/models/document-model.ts b/src/models/document-model.ts new file mode 100644 index 0000000..da5ed87 --- /dev/null +++ b/src/models/document-model.ts @@ -0,0 +1,12 @@ + +export class DocumentTreeItemInfo { + fileBlock: FileBlock; + fileName: string; + filePath: string; + ariaLabel: string; + icon: string; + boxName: string; + refCount: number; + index: number; +} + diff --git a/src/models/icon-constant.ts b/src/models/icon-constant.ts new file mode 100644 index 0000000..b879145 --- /dev/null +++ b/src/models/icon-constant.ts @@ -0,0 +1,36 @@ + +export const CUSTOM_ICON_MAP = +{ + iconDualDocList: { + id: "iconDualDocList", + source: ` + + ` + }, + iconShowSubDoc: { + id: "iconShowSubDoc", + source: ` + + + + ` + }, + iconLockPath: { + id: "iconLockPath", + source: ` + + + ` + }, + iconLockSort: { + id: "iconLockSort", + source: ` + + + + + ` + }, + + +}; \ No newline at end of file diff --git a/src/models/search-model.ts b/src/models/search-model.ts new file mode 100644 index 0000000..59bb5ab --- /dev/null +++ b/src/models/search-model.ts @@ -0,0 +1,59 @@ +export class DocumentItem { + block: Block; + subItems: BlockItem[]; + isCollapsed: boolean; + icon: string; + index: number; + path: string; + ariaLabel: string; +} + +export class BlockItem { + block: Block; + icon: string; + index: number; +} + + +export class DocumentSqlQueryModel { + searchCriterion: DocumentQueryCriteria; + documentItems: DocumentItem[]; + documentCount: number; + status: "success" | "param_null"; +} + + +export class DocumentQueryCriteria { + parentDocId: string; + showSubDocuments: boolean; + keywords: string[]; + fullTextSearch: boolean; + pages: number[]; + documentSortMethod: DocumentSortMode; + includeTypes: string[]; + includeConcatFields: string[]; + includeRootIds: string[]; + includeNotebookIds: string[]; + + constructor( + docPath: string, + showSubDocuments: boolean, + keywords: string[], + fullTextSearch: boolean, + pages: number[], + documentSortMethod: DocumentSortMode, + includeTypes: string[], + includeConcatFields: string[], + includeNotebookIds: string[], + ) { + this.parentDocId = docPath; + this.showSubDocuments = showSubDocuments; + this.keywords = keywords; + this.fullTextSearch = fullTextSearch; + this.pages = pages; + this.documentSortMethod = documentSortMethod; + this.includeTypes = includeTypes; + this.includeConcatFields = includeConcatFields; + this.includeNotebookIds = includeNotebookIds; + } +} diff --git a/src/models/setting-constant.ts b/src/models/setting-constant.ts new file mode 100644 index 0000000..3548bef --- /dev/null +++ b/src/models/setting-constant.ts @@ -0,0 +1,171 @@ +import { ItemProperty, IOption, TabProperty } from "./setting-model"; + +export function getSettingTabArray(): TabProperty[] { + + let tabProperties: TabProperty[] = [ + + ]; + + tabProperties.push( + new TabProperty({ + key: "function-setting", name: "功能", iconKey: "iconFilter", props: [ + new ItemProperty({ key: "showEmbedDualDocList", type: "switch", name: "显示嵌入的二级文档列表", description: "", tips: "" }), + + new ItemProperty({ key: "doubleClickToggleNotebook", type: "switch", name: "双击展开/折叠笔记本", description: "", tips: "" }), + + ] + + }), + new TabProperty({ + key: "query-setting", name: "查询相关", iconKey: "iconLink", props: [ + // new ItemProperty({ key: "lockSortMode", type: "switch", name: "锁定排序方式", description: "", tips: "", min: 0 }), + new ItemProperty({ key: "showSubDocOfSubDoc", type: "switch", name: "默认显示子文档的子文档", description: "", tips: "" }), + new ItemProperty({ key: "fullTextSearch", type: "switch", name: "全文搜索", description: "", tips: "" }), + new ItemProperty({ key: "defaultDbQuerySortOrder", type: "select", name: "数据库默认查询方式", description: "何时会用到这个配置?
当使用数据库查询文档,且笔记本排序方式为“文档大小”、“子文档数”、“自定义” 排序时,会重置为此方式。", tips: "", options: getDocDbQuerySortMethodElement() }), + new ItemProperty({ key: "allDocsQueryLimit", type: "number", name: "显示所有文档最大数量", description: "", tips: "", min: 0 }), + + ] + + }), + new TabProperty({ + key: "style-setting", name: "样式", iconKey: "iconPlugin", props: [ + new ItemProperty({ key: "embedDocListViewFlex", type: "number", name: "二级文档列表与文档树比例", description: "数字越大二级文档列表越宽。", tips: "", min: 0, }), + ] + }), + new TabProperty({ + key: "other-setting", name: "其他", iconKey: "iconPlugin", props: [ + new ItemProperty({ key: "doubleClickTimeout", type: "number", name: "双击时间阈值(毫秒)", description: "", tips: "", min: 0, }), + ] + }), + ); + + return tabProperties; +} + + + +function getDocDbQuerySortMethodElement(): IOption[] { + let docDbQuerySortMethodElements = SETTING_DOCUMENT_LIST_DB_SORT_METHOD_ELEMENT(); + let options: IOption[] = []; + for (const element of docDbQuerySortMethodElements) { + options.push(element); + } + + return options; +} + + +export function SETTING_DOCUMENT_LIST_SORT_METHOD_ELEMENT(): { text: string, value: DocumentSortMode }[] { + return [ + { + text: window.siyuan.languages.modifiedASC, + value: "UpdatedASC", + }, + { + text: window.siyuan.languages.modifiedDESC, + value: "UpdatedDESC", + }, + { + text: window.siyuan.languages.createdASC, + value: "CreatedASC", + }, + { + text: window.siyuan.languages.createdDESC, + value: "CreatedDESC", + }, + { + text: window.siyuan.languages.fileNameASC, + value: "NameASC", + }, + { + text: window.siyuan.languages.fileNameDESC, + value: "NameDESC", + }, + { + text: window.siyuan.languages.fileNameNatASC, + value: "AlphanumASC", + }, + { + text: window.siyuan.languages.fileNameNatDESC, + value: "AlphanumDESC", + }, + { + text: window.siyuan.languages.refCountASC, + value: "RefCountASC", + }, + { + text: window.siyuan.languages.refCountDESC, + value: "RefCountDESC", + }, + { + text: window.siyuan.languages.docSizeASC, + value: "SizeASC", + }, + { + text: window.siyuan.languages.docSizeDESC, + value: "SizeDESC", + }, + { + text: window.siyuan.languages.subDocCountASC, + value: "SubDocCountASC", + }, + { + text: window.siyuan.languages.subDocCountDESC, + value: "SubDocCountDESC", + }, + { + text: window.siyuan.languages.customSort, + value: "Custom", + }, + // { + // text: window.siyuan.languages.sortByFiletree, + // value: "FileTree", + // }, + ]; +} + +export function SETTING_DOCUMENT_LIST_DB_SORT_METHOD_ELEMENT(): { name: string, value: DocumentSortMode }[] { + return [ + { + name: window.siyuan.languages.modifiedASC, + value: "UpdatedASC", + }, + { + name: window.siyuan.languages.modifiedDESC, + value: "UpdatedDESC", + }, + { + name: window.siyuan.languages.createdASC, + value: "CreatedASC", + }, + { + name: window.siyuan.languages.createdDESC, + value: "CreatedDESC", + }, + { + name: window.siyuan.languages.fileNameASC, + value: "NameASC", + }, + { + name: window.siyuan.languages.fileNameDESC, + value: "NameDESC", + }, + { + name: window.siyuan.languages.fileNameNatASC, + value: "AlphanumASC", + }, + { + name: window.siyuan.languages.fileNameNatDESC, + value: "AlphanumDESC", + }, + { + name: window.siyuan.languages.refCountASC, + value: "RefCountASC", + }, + { + name: window.siyuan.languages.refCountDESC, + value: "RefCountDESC", + }, + + ]; +} \ No newline at end of file diff --git a/src/models/setting-model.ts b/src/models/setting-model.ts new file mode 100644 index 0000000..0782c94 --- /dev/null +++ b/src/models/setting-model.ts @@ -0,0 +1,100 @@ +import { isStrNotBlank } from "@/utils/string-util"; + + +export class SettingConfig { + // 显示嵌入的二级文档列表 + showEmbedDualDocList: boolean; + // 改为双击切换笔记本的折叠展开 + doubleClickToggleNotebook: boolean; + + // 切换路径时使用笔记本排序方式。 + // lockSortMode: boolean; + // 显示子文档的子文档 + showSubDocOfSubDoc: boolean; + // 全文搜索 + fullTextSearch: boolean; + // 使用数据库查询时的默认查询方式 + defaultDbQuerySortOrder: DocumentSortMode; + // 显示所有文档时的数量限制 + allDocsQueryLimit: number; + + + + + // 样式相关 + embedDocListViewFlex: number; + + // 双击阈值 + doubleClickTimeout: number; + + + // 数据库查询相关,暂不支持设置 + includeConcatFields: string[]; + fullTextSearchBlockType: BlockType[]; + +} + + +interface ITabProperty { + key: string; + name: string; + props: Array; + iconKey?: string; +} + + +export class TabProperty { + key: string; + name: string; + iconKey: string; + props: ItemProperty[]; + + constructor({ key, name, iconKey, props }: ITabProperty) { + this.key = key; + this.name = name; + if (isStrNotBlank(iconKey)) { + this.iconKey = iconKey; + } else { + this.iconKey = "setting"; + } + this.props = props; + + } + +} + +export interface IOption { + name: string; + desc?: string; + value: string; +} + + + + +export class ItemProperty { + key: string; + type: IItemPropertyType; + name: string; + description: string; + tips?: string; + + min?: number; + max?: number; + btndo?: () => void; + options?: IOption[]; + + + constructor({ key, type, name, description, tips, min, max, btndo, options }: ItemProperty) { + this.key = key; + this.type = type; + this.min = min; + this.max = max; + this.btndo = btndo; + this.options = options ?? []; + this.name = name; + this.description = description; + this.tips = tips; + } + +} diff --git a/src/models/siyuan-constant.ts b/src/models/siyuan-constant.ts new file mode 100644 index 0000000..ab9e940 --- /dev/null +++ b/src/models/siyuan-constant.ts @@ -0,0 +1,5 @@ +export abstract class SiyuanConstants { + public static readonly SIYUAN_IMAGE_FILE: string = "1f4c4"; + public static readonly SIYUAN_IMAGE_NOTE: string = "1f5c3"; + public static readonly SIYUAN_IMAGE_FOLDER: string = "1f4d1"; +} \ No newline at end of file diff --git a/src/service/plugin/DockService.ts b/src/service/plugin/DockService.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/service/search/search-sql.ts b/src/service/search/search-sql.ts new file mode 100644 index 0000000..d566024 --- /dev/null +++ b/src/service/search/search-sql.ts @@ -0,0 +1,390 @@ +import { DocumentQueryCriteria } from "@/models/search-model"; +import { isArrayNotEmpty } from "@/utils/array-util"; +import { isStrBlank, isStrNotBlank } from "@/utils/string-util"; + + + +export function generateDocumentListSql( + queryCriteria: DocumentQueryCriteria, +): string { + + let parentDocId = queryCriteria.parentDocId; + let showSubDocuments = queryCriteria.showSubDocuments; + let keywords = queryCriteria.keywords; + let fullTextSearch = queryCriteria.fullTextSearch; + let pages = queryCriteria.pages; + let includeNotebookIds = queryCriteria.includeNotebookIds; + let documentSortMethod = queryCriteria.documentSortMethod; + let includeConcatFields = queryCriteria.includeConcatFields; + let columns: string[] = [" * ", + ` (SELECT count(1) FROM refs WHERE def_block_root_id = blocks.id) refCount `, + // 子文档数量查询sql,太影响性能了 + // ` ( SELECT count( 1 ) FROM blocks sub WHERE type = 'd' AND path = REPLACE(blocks.path,'.sy','/') || id || '.sy' ) subDocCount ` + ]; + + + let boxInSql = " " + if (isArrayNotEmpty(includeNotebookIds)) { + boxInSql = generateAndInConditions("box", includeNotebookIds); + if (showSubDocuments) { + + } else if (isStrBlank(parentDocId)) { + boxInSql += ` AND path = '/' || id || '.sy' ` + } + } + + let pathLikeSql = " "; + if (isStrNotBlank(parentDocId)) { + if (showSubDocuments) { + pathLikeSql = ` AND path LIKE '%/${parentDocId}/%'`; + } else { + pathLikeSql = ` AND path LIKE '%/${parentDocId}/' || id || '.sy' `; + } + } + + + let contentParamSql = " "; + + if (keywords && keywords.length > 0) { + let concatConcatFieldSql = getConcatFieldSql("concatContent", includeConcatFields); + columns.push(` ${concatConcatFieldSql} `); + if (fullTextSearch) { + let documentIdSql = generateDocumentIdContentTableSql(queryCriteria); + contentParamSql = ` AND id in (${documentIdSql}) `; + } else { + contentParamSql = " AND " + generateAndLikeConditions("concatContent", keywords); + } + } + + let orders = []; + + if (keywords && keywords.length > 0) { + let orderCaseCombinationSql = generateRelevanceOrderSql("concatContent", keywords, false); + orders = [orderCaseCombinationSql]; + } + if (documentSortMethod == 'UpdatedASC') { + orders.push([" updated ASC "]); + } else if (documentSortMethod == 'UpdatedDESC') { + orders.push([" updated DESC "]); + } else if (documentSortMethod == 'CreatedASC') { + orders.push([" created ASC "]); + } else if (documentSortMethod == 'CreatedDESC') { + orders.push([" created DESC "]); + } else if (documentSortMethod == 'RefCountASC') { + orders.push([" refCount ASC ", " updated DESC "]); + } else if (documentSortMethod == 'RefCountDESC') { + orders.push([" refCount DESC ", " updated DESC "]); + } else if (documentSortMethod == 'NameASC') { + orders.push([" content ASC "]); + } else if (documentSortMethod == 'NameDESC') { + orders.push([" content DESC "]); + } + + let columnSql = columns.join(" , "); + let orderSql = generateOrderSql(orders); + let limitSql = generateLimitSql(pages); + + + let basicSql = ` + SELECT + ${columnSql} + + FROM + blocks + WHERE + type = 'd' + ${boxInSql} + ${pathLikeSql} + ${contentParamSql} + + ${orderSql} + ${limitSql} + ` + + return cleanSpaceText(basicSql); +} + + + + + +function generateDocumentIdContentTableSql( + queryCriteria: DocumentQueryCriteria +): string { + let keywords = queryCriteria.keywords; + let includeTypes = queryCriteria.includeTypes; + let includeConcatFields = queryCriteria.includeConcatFields; + let includeRootIds = queryCriteria.includeRootIds; + let includeNotebookIds = queryCriteria.includeNotebookIds; + let excludeNotebookIds = []; + + let concatDocumentConcatFieldSql = getConcatFieldSql(null, includeConcatFields); + let columns = ["root_id"] + let contentLikeField = `GROUP_CONCAT( ${concatDocumentConcatFieldSql} )`; + + let orders = []; + + + let documentIdContentTableSql = generateDocumentContentLikeSql( + columns, keywords, contentLikeField, includeTypes, includeRootIds, includeNotebookIds, excludeNotebookIds, orders, null); + + return documentIdContentTableSql; +} + +function generateDocumentContentLikeSql( + columns: string[], + keywords: string[], + contentLikeField: string, + includeTypes: string[], + includeRootIds: string[], + includeNotebookIds: string[], + excludeNotebookIds: string[], + orders: string[], + pages: number[]): string { + + let columnSql = columns.join(","); + let typeInSql = generateAndInConditions("type", includeTypes); + let rootIdInSql = " "; + let boxInSql = " "; + let boxNotInSql = " "; + // 如果文档id不为空,则忽略过滤的笔记本id。 + if (includeRootIds && includeRootIds.length > 0) { + rootIdInSql = generateAndInConditions("root_id", includeRootIds); + } else if (includeNotebookIds && includeNotebookIds.length > 0) { + boxInSql = generateAndInConditions("box", includeNotebookIds); + } else { + boxNotInSql = generateAndNotInConditions("box", excludeNotebookIds); + } + + // let contentOrLikeSql = generateOrLikeConditions("content", keywords); + // if (contentOrLikeSql) { + // contentOrLikeSql = ` AND ( ${contentOrLikeSql} ) `; + // } + let aggregatedContentAndLikeSql = generateAndLikeConditions( + ` ${contentLikeField} `, + keywords, + ); + if (aggregatedContentAndLikeSql) { + aggregatedContentAndLikeSql = ` AND ( ${aggregatedContentAndLikeSql} ) `; + } + + let orderSql = generateOrderSql(orders); + + let limitSql = generateLimitSql(pages); + + + let sql = ` + SELECT ${columnSql} + FROM + blocks + WHERE + 1 = 1 + ${typeInSql} + ${rootIdInSql} + ${boxInSql} + ${boxNotInSql} + GROUP BY + root_id + HAVING + 1 = 1 + ${aggregatedContentAndLikeSql} + ${orderSql} + ${limitSql} + `; + return sql; +} + +function getConcatFieldSql(asFieldName: string, fields: string[]): string { + if (!fields || fields.length <= 0) { + return ""; + } + // let sql = ` ( ${fields.join(" || ' ' || ")} ) `; + let sql = ` ( ${fields.join(" || ")} ) ` + if (asFieldName) { + sql += ` AS ${asFieldName} `; + } + + return sql; +} + +function cleanSpaceText(inputText: string): string { + // 去除换行 + let cleanedText = inputText.replace(/[\r\n]+/g, ' '); + + // 将多个空格转为一个空格 + cleanedText = cleanedText.replace(/\s+/g, ' '); + + // 去除首尾空格 + cleanedText = cleanedText.trim(); + + return cleanedText; +} + +function generateOrLikeConditions( + fieldName: string, + params: string[], +): string { + if (params.length === 0) { + return " "; + } + + const conditions = params.map( + (param) => `${fieldName} LIKE '%${param}%'`, + ); + const result = conditions.join(" OR "); + + return result; +} + +function generateAndLikeConditions( + fieldName: string, + params: string[], +): string { + if (params.length === 0) { + return " "; + } + + const conditions = params.map( + (param) => `${fieldName} LIKE '%${param}%'`, + ); + const result = conditions.join(" AND "); + + return result; +} + +function generateAndInConditions( + fieldName: string, + params: string[], +): string { + if (!params || params.length === 0) { + return " "; + } + let result = ` AND ${fieldName} IN (` + const conditions = params + .filter(param => isStrNotBlank(param)) + .map(param => `'${param}'`); + result = result + conditions.join(" , ") + " ) "; + + return result; +} + +function generateAndNotInConditions( + fieldName: string, + params: string[], +): string { + if (!params || params.length === 0) { + return " "; + } + let result = ` AND ${fieldName} NOT IN (` + const conditions = params.map( + (param) => ` '${param}' `, + ); + result = result + conditions.join(" , ") + " ) "; + + return result; +} + + +function generateOrderCaseCombination(columnName: string, keywords: string[], orderAsc: boolean, index?: number, iterationOffset?: number): string { + let whenCombinationSql = ""; + if (!index) { + index = 0; + } + let endIndex = keywords.length; + if (iterationOffset != null) { + endIndex = endIndex - Math.abs(iterationOffset); + } + + for (; index < endIndex; index++) { + let combination = keywords.length - index; + whenCombinationSql += generateWhenCombination(columnName, keywords, combination) + index; + } + + let caseCombinationSql = ""; + if (whenCombinationSql) { + let sortDirection = orderAsc ? " ASC " : " DESC "; + caseCombinationSql = `( + CASE + ${whenCombinationSql} + ELSE 99 + END ) ${sortDirection} + `; + } + return caseCombinationSql; +} + +function generateWhenCombination(columnName: string, keywords: string[], combinationCount: number): string { + if (combinationCount < 1 || combinationCount > keywords.length) { + return ""; + } + const combinations: string[][] = []; + // 生成所有可能的组合 + const generateCombinations = (current: string[], start: number) => { + if (current.length === combinationCount) { + combinations.push([...current]); + return; + } + for (let i = start; i < keywords.length; i++) { + current.push(keywords[i]); + generateCombinations(current, i + 1); + current.pop(); + } + }; + generateCombinations([], 0); + // 生成查询字符串 + const queryString = combinations + .map((combination) => { + const conditions = combination.map((item) => ` ${columnName} LIKE '%${item}%' `).join(" AND "); + return `(${conditions})`; + }) + .join(" OR "); + + return ` WHEN ${queryString} THEN `; +} + + +function generateRelevanceOrderSql(columnName: string, keywords: string[], orderAsc: boolean): string { + let subSql = ""; + + for (let i = 0; i < keywords.length; i++) { + let key = keywords[i]; + subSql += ` (${columnName} LIKE '%${key}%') `; + if (i < keywords.length - 1) { + subSql += ' + '; + } + } + + let orderSql = ""; + if (subSql) { + let sortDirection = orderAsc ? " ASC " : " DESC "; + orderSql = `( ${subSql} ) ${sortDirection}`; + } + return orderSql; +} + + +function generateOrderSql(orders: string[]): string { + let orderSql = ''; + if (orders) { + orders = orders.filter((order) => order); + let orderParam = orders.join(","); + if (orderParam) { + orderSql = ` ORDER BY ${orderParam} `; + } + } + return orderSql; +} + +function generateLimitSql(pages: number[]): string { + let limitSql = ''; + if (pages) { + const limit = pages[1]; + if (pages.length == 1) { + limitSql = ` LIMIT ${limit} `; + } else if (pages.length == 2) { + const offset = (pages[0] - 1) * pages[1]; + limitSql = ` LIMIT ${limit} OFFSET ${offset} `; + } + } + return limitSql; +} \ No newline at end of file diff --git a/src/service/search/search-util.ts b/src/service/search/search-util.ts new file mode 100644 index 0000000..bcabb73 --- /dev/null +++ b/src/service/search/search-util.ts @@ -0,0 +1,540 @@ +import { EnvConfig } from "@/config/EnvConfig"; +import { BlockItem, DocumentQueryCriteria } from "@/models/search-model"; +import { SettingConfig } from "@/models/setting-model"; +import { getBlockIndex, getBlocksIndexes, listDocsByPath, sql } from "@/utils/api"; +import { isArrayEmpty, isArrayNotEmpty } from "@/utils/array-util"; +import { convertIalStringToObject, convertIconInIal } from "@/utils/icon-util"; +import { containsAllKeywords, isStrBlank, isStrNotBlank, } from "@/utils/string-util"; +import { generateDocumentListSql } from "./search-sql"; +import { DocumentTreeItemInfo } from "@/models/document-model"; +import { convertNumberToSordMode, convertSordModeToNumber, getFileArialLabel, highlightBlockContent } from "@/utils/siyuan-util"; +import { SiyuanConstants } from "@/models/siyuan-constant"; +import { SettingService } from "../setting/SettingService"; + + +export async function queryDocumentByPath( + notebookId: string, + docPath: string, + keywords: string[], + docSortMethod: DocumentSortMode, +): Promise { + const startTime = performance.now(); // 记录开始时间 + let sortNumber = convertSordModeToNumber(docSortMethod); + let data = await listDocsByPath(notebookId, docPath, sortNumber, null, null, null); + let docBlockArray: FileBlock[] = []; + for (const file of data.files) { + let fileBlock: FileBlock = { + id: file.id, + box: data.box, + content: file.name.replace(/\.sy$/, ""), + name: file.name1, + alias: file.alias, + memo: file.memo, + bookmark: file.bookmark, + path: file.path, + + icon: file.icon, + refCount: file.count, + subFileCount: file.subFileCount, + sort: file.sort, + + created: file.hCtime, + updated: file.hMtime, + hSize: file.hSize, + + dueFlashcardCount: file.flashcardCount, + newFlashcardCount: file.flashcardCount, + flashcardCount: file.flashcardCount, + }; + docBlockArray.push(fileBlock); + } + + let documentItems = processQueryResults( + docBlockArray, + keywords, + true, + ); + + const endTime = performance.now(); // 记录结束时间 + const executionTime = endTime - startTime; // 计算时间差 + console.log( + `通过文件路径接口或取文档列表 : ${executionTime} ms `, + ); + return documentItems; +} + +export async function queryDocumentByDb( + notebookId: string, + parentDocId: string, + keywords: string[], + showSubDocuments: boolean, + fullTextSearch: boolean, + docSortMethod: DocumentSortMode, +): Promise { + let settingConfig = SettingService.ins.SettingConfig; + const startTime = performance.now(); // 记录开始时间 + let includeConcatFields = settingConfig.includeConcatFields; + let fullTextSearchBlockType = settingConfig.fullTextSearchBlockType; + + let includeNotebookIds = []; + if (isStrNotBlank(notebookId)) { + includeNotebookIds.push(notebookId); + } + + let pages = [1, 9999999]; + + // 如果笔记本id和文档id都为空,表示查询所有文档,此时需要限制数量。 + if (isStrBlank(notebookId) && isStrBlank(parentDocId)) { + pages[1] = settingConfig.allDocsQueryLimit; + } + + let queryCriteria: DocumentQueryCriteria = new DocumentQueryCriteria( + parentDocId, + showSubDocuments, + keywords, + fullTextSearch, + pages, + docSortMethod, + fullTextSearchBlockType, + includeConcatFields, + includeNotebookIds, + ); + + let documentListSql = generateDocumentListSql(queryCriteria); + let documentSearchResults: FileBlock[] = await sql(documentListSql); + /* + // 数据量大的时候也快不起来,毕竟一个路径下面有很多文档。。 + // let boxPathSet = new Set(); + // let docByPathPromises = []; + // for (const document of documentSearchResults) { + // let path = processPath(document.path); + // let bp = document.box + "&&&" + document.path; + // if (!boxPathSet.has(bp)) { + // boxPathSet.add(bp); + // let apiPromise = listDocsByPath( + // document.box, + // path, + // true, + // 0, + // false, + // ); + // docByPathPromises.push(apiPromise); + // } + // } + // let filse = await Promise.all(docByPathPromises); + // console.log("queryDocumentByDb", filse); + + // 尝试使用 getDocInfo 接口,这个接口不错,不过数据量大了终归很费时间。决定从业务角度改变,舍弃子文档数量等排序方式。 + // let docInfoPromises = []; + // for (const document of documentSearchResults) { + // let apiPromise = getDocInfo(document.id); + // docInfoPromises.push(apiPromise); + // } + // let docInfos = await Promise.all(docInfoPromises); + // console.log("queryDocumentByDb", docInfos); + */ + let documentItems = processQueryResults( + documentSearchResults, + keywords, + false, + ); + + if (docSortMethod.startsWith("Alphanum")) { + documentSort(documentItems, docSortMethod); + } + + const endTime = performance.now(); // 记录结束时间 + const executionTime = endTime - startTime; // 计算时间差 + console.log( + `通过数据库获取文档列表 : ${executionTime} ms `, + ); + + return documentItems; +} + +function processQueryResults( + fileBlockArray: FileBlock[], + keywordArray: string[], + isFilteredByKeyword: boolean +): DocumentTreeItemInfo[] { + if (isArrayEmpty(fileBlockArray)) { + return []; + } + + let documentBlockInfos: DocumentTreeItemInfo[] = []; + + let index = 0; + for (const fileBlock of fileBlockArray) { + if (!fileBlock) { + continue; + } + if (isFilteredByKeyword && isArrayNotEmpty(keywordArray)) { + let fileBlockConcat = + fileBlock.content + + fileBlock.name + + fileBlock.alias + + fileBlock.memo + + fileBlock.alias + + fileBlock.memo + + fileBlock.bookmark; + if (!containsAllKeywords(fileBlockConcat, keywordArray)) { + continue; + } + } + highlightBlockContent(fileBlock, keywordArray); + + let icon = convertIconInIal(SiyuanConstants.SIYUAN_IMAGE_FILE); + if (fileBlock.ial) { + let ial = convertIalStringToObject(fileBlock.ial); + icon = convertIconInIal(ial.icon); + } else if (fileBlock.icon) { + icon = convertIconInIal(fileBlock.icon); + } + + let notebookInfo = EnvConfig.ins.notebookMap.get(fileBlock.box); + let boxName = fileBlock.box; + if (notebookInfo) { + boxName = notebookInfo.name; + } + let refCount = fileBlock.refCount; + + let ariaLabel = getFileArialLabel(fileBlock, boxName); + let documentBlockInfo = new DocumentTreeItemInfo(); + documentBlockInfo.fileBlock = fileBlock; + + documentBlockInfo.icon = icon; + documentBlockInfo.boxName = boxName; + documentBlockInfo.refCount = refCount; + documentBlockInfo.ariaLabel = ariaLabel; + documentBlockInfo.index = index; + documentBlockInfos.push(documentBlockInfo); + index++; + } + + + return documentBlockInfos; +} + + + +function processPath(input: string): string { + const lastSlashIndex = input.lastIndexOf("/"); // 找到最后一个斜杠的位置 + const lastDotIndex = input.lastIndexOf("."); // 找到最后一个点的位置 + + if ( + lastSlashIndex === -1 || + lastDotIndex === -1 || + lastSlashIndex == 0 + ) { + return "/"; + } + + if (lastSlashIndex < lastDotIndex) { + // 如果最后一个斜杠在最后一个点之前,说明是符合的路径 + return ( + input.substring(0, lastSlashIndex) + + input.substring(lastDotIndex) + ); + } + return "/"; +} + +export function isQueryDocByPathApi( + showSubDocuments: boolean, + notebookId: string, + docPath: string, + keywords: string[], + fullTextSearch: boolean, +): boolean { + // return false; + // 满足以下情况使用路径查询子文档 + // 不查询子文档的子文档 + // notebookId 和 docPath 不为空 + // (关键字为空 或 不使用全文搜索) + return ( + !showSubDocuments && + isStrNotBlank(notebookId) && + isStrNotBlank(docPath) && + (isArrayEmpty(keywords) || !fullTextSearch) + ); +} + +export function selectItemByArrowKeys( + event: KeyboardEvent, + selectedItemIndex: number, + documentItems: DocumentTreeItemInfo[], +): DocumentTreeItemInfo { + let selectedItem: DocumentTreeItemInfo = null; + + if (!event || !event.key) { + return selectedItem; + } + let keydownKey = event.key; + if ( + keydownKey !== "ArrowUp" && + keydownKey !== "ArrowDown" && + keydownKey !== "Enter" + ) { + return selectedItem; + } + if (selectedItemIndex == null || selectedItemIndex == undefined) { + selectedItemIndex = 0; + } + + event.stopPropagation(); + + if (event.key === "ArrowUp") { + if (selectedItemIndex > 0) { + selectedItemIndex -= 1; + } + } else if (event.key === "ArrowDown") { + let lastDocumentItem = documentItems[documentItems.length - 1]; + if (!lastDocumentItem) { + return selectedItem; + } + let lastIndex = lastDocumentItem.index; + if (selectedItemIndex < lastIndex) { + selectedItemIndex += 1; + } + } + for (const item of documentItems) { + if (selectedItemIndex == item.index) { + selectedItem = item; + break; + } + } + + return selectedItem; +} + + +function documentSort(searchResults: DocumentTreeItemInfo[], documentSortMethod: DocumentSortMode) { + // 文档排序 + let documentSortFun = getDocumentSortFun(documentSortMethod); + searchResults.sort(documentSortFun); +} + +function getDocumentSortFun(documentSortMethod: DocumentSortMode) + : ( + a: DocumentTreeItemInfo, + b: DocumentTreeItemInfo, + ) => number { + let documentSortFun: ( + a: DocumentTreeItemInfo, + b: DocumentTreeItemInfo, + ) => number; + + switch (documentSortMethod) { + case "UpdatedASC": + documentSortFun = function ( + a: DocumentTreeItemInfo, + b: DocumentTreeItemInfo, + ): number { + let rank = getDocumentBlockRankDescSort(a, b); + if (rank != 0) { + return rank; + } + return Number(a.fileBlock.updated) - Number(b.fileBlock.updated); + }; + break; + case "UpdatedDESC": + documentSortFun = function ( + a: DocumentTreeItemInfo, + b: DocumentTreeItemInfo, + ): number { + let rank = getDocumentBlockRankDescSort(a, b); + if (rank != 0) { + return rank; + } + return Number(b.fileBlock.updated) - Number(a.fileBlock.updated); + }; + break; + case "CreatedASC": + documentSortFun = function ( + a: DocumentTreeItemInfo, + b: DocumentTreeItemInfo, + ): number { + let rank = getDocumentBlockRankDescSort(a, b); + if (rank != 0) { + return rank; + } + return Number(a.fileBlock.created) - Number(b.fileBlock.created); + }; + break; + case "CreatedDESC": + documentSortFun = function ( + a: DocumentTreeItemInfo, + b: DocumentTreeItemInfo, + ): number { + let rank = getDocumentBlockRankDescSort(a, b); + if (rank != 0) { + return rank; + } + return Number(b.fileBlock.created) - Number(a.fileBlock.created); + }; + break; + case "AlphanumASC": + documentSortFun = function ( + a: DocumentTreeItemInfo, + b: DocumentTreeItemInfo, + ): number { + let rank = getDocumentBlockRankDescSort(a, b); + if (rank != 0) { + return rank; + } + + let aContent = a.fileBlock.content.replace("", "").replace("", ""); + let bContent = b.fileBlock.content.replace("", "").replace("", ""); + let result = aContent.localeCompare(bContent, undefined, { sensitivity: 'base', usage: 'sort', numeric: true }); + if (result == 0) { + result = Number(b.fileBlock.updated) - Number(a.fileBlock.updated); + } + return result; + }; + break; + case "AlphanumDESC": + documentSortFun = function ( + a: DocumentTreeItemInfo, + b: DocumentTreeItemInfo, + ): number { + let rank = getDocumentBlockRankDescSort(a, b); + if (rank != 0) { + return rank; + } + let aContent = a.fileBlock.content.replace("", "").replace("", ""); + let bContent = b.fileBlock.content.replace("", "").replace("", ""); + let result = bContent.localeCompare(aContent, undefined, { sensitivity: 'base', usage: 'sort', numeric: true }); + if (result == 0) { + result = Number(b.fileBlock.updated) - Number(a.fileBlock.updated); + } + return result; + }; + break; + } + return documentSortFun; +} + +function getDocumentBlockRankDescSort(a: DocumentTreeItemInfo, b: DocumentTreeItemInfo): number { + let aRank: number = calculateBlockRank(a.fileBlock); + let bRank: number = calculateBlockRank(b.fileBlock); + let result = bRank - aRank; + return result; +} + +function calculateBlockRank(block: any): number { + + let includeAttrFields = SettingService.ins.SettingConfig.includeConcatFields; + let rank = block.content.split("").length - 1; + + if (includeAttrFields.includes("name")) { + rank += block.name.split("").length - 1; + } + if (includeAttrFields.includes("alias")) { + rank += block.alias.split("").length - 1; + } + if (includeAttrFields.includes("memo")) { + rank += block.memo.split("").length - 1; + } + return rank; +} + + +function countKeywords(content: string, keywords: string[]): number { + let count = 0; + keywords.forEach(keyword => { + const regex = new RegExp(keyword, 'gi'); // 创建全局、不区分大小写的正则表达式 + const matches = content.match(regex); // 在文本中查找匹配的关键字 + if (matches) { + count += matches.length; // 更新匹配关键字的数量 + } + }); + return count; +} + + + +async function searchItemSortByContent(blockItems: BlockItem[]) { + + let ids = blockItems.map(item => item.block.id); + let idMap: Map = await getBatchBlockIdIndex(ids); + blockItems.sort((a, b) => { + if (a.block.type === "d") { + return -1; + } + if (b.block.type === "d") { + return 1; + } + let aIndex = idMap.get(a.block.id) || 0; + let bIndex = idMap.get(b.block.id) || 0; + let result = aIndex - bIndex; + if (result == 0) { + result = Number(a.block.created) - Number(b.block.created); + } + if (result == 0) { + result = a.block.sort - b.block.sort; + } + return result; + }); + + return blockItems; +} + + +async function searchItemSortByTypeAndContent(blockItems: BlockItem[]) { + let ids = blockItems.map(item => item.block.id); + let idMap: Map = await getBatchBlockIdIndex(ids); + blockItems.sort((a, b) => { + if (a.block.type === "d") { + return -1; + } + if (b.block.type === "d") { + return 1; + } + let result = a.block.sort - b.block.sort; + if (result == 0) { + let aIndex = idMap.get(a.block.id) || 0; + let bIndex = idMap.get(b.block.id) || 0; + result = aIndex - bIndex; + } + if (result == 0) { + result = Number(a.block.created) - Number(b.block.created); + } + return result; + }); + + return blockItems; +} + +async function getBatchBlockIdIndex(ids: string[]): Promise> { + let idMap: Map = new Map(); + let getSuccess = true; + try { + let idObject = await getBlocksIndexes(ids); + // 遍历对象的键值对,并将它们添加到 Map 中 + for (const key in idObject) { + if (Object.prototype.hasOwnProperty.call(idObject, key)) { + const value = idObject[key]; + idMap.set(key, value); + } + } + } catch (err) { + getSuccess = false; + console.error("批量获取块索引报错,可能是旧版本不支持批量接口 : ", err) + } + + if (!getSuccess) { + for (const id of ids) { + let index = 0 + try { + index = await getBlockIndex(id); + } catch (err) { + console.error("获取块索引报错 : ", err) + } + idMap.set(id, index) + } + } + + return idMap; +} + + diff --git a/src/service/setting/SettingService.ts b/src/service/setting/SettingService.ts new file mode 100644 index 0000000..1929ea9 --- /dev/null +++ b/src/service/setting/SettingService.ts @@ -0,0 +1,129 @@ +import { EnvConfig } from "@/config/EnvConfig"; +import { SettingConfig } from "@/models/setting-model"; +import Instance from "@/utils/Instance"; +import { setReplacer } from "@/utils/json-util"; +import { mergeObjects } from "@/utils/object-util"; + +const SettingFileName = 'backlink-panel-setting.json'; + +export class SettingService { + + public static get ins(): SettingService { + return Instance.get(SettingService); + } + + private _settingConfig: SettingConfig; + + public get SettingConfig() { + if (this._settingConfig) { + return this._settingConfig; + } + this.init() + return getDefaultSettingConfig() + + } + + public async init() { + let persistentConfig = await getPersistentConfig(); + this._settingConfig = mergeObjects(persistentConfig, getDefaultSettingConfig()); + } + + + public async updateSettingCofnigValue(key: string, newValue: any) { + let oldValue = this._settingConfig[key]; + if (oldValue == newValue) { + return; + } + + this._settingConfig[key] = newValue; + let paramJson = JSON.stringify(this._settingConfig, setReplacer); + let plugin = EnvConfig.ins.plugin; + if (!plugin) { + return; + } + console.log(`二级文档列表插件 更新设置配置文件: ${paramJson}`); + plugin.saveData(SettingFileName, paramJson); + } + + public async updateSettingCofnig(settingConfigParam: SettingConfig) { + let plugin = EnvConfig.ins.plugin; + if (!plugin) { + return; + } + + let curSettingConfigJson = ""; + if (this._settingConfig) { + curSettingConfigJson = JSON.stringify(this._settingConfig, setReplacer); + } + let paramJson = JSON.stringify(settingConfigParam, setReplacer); + if (paramJson == curSettingConfigJson) { + return; + } + console.log(`二级文档列表插件 更新设置配置文件: ${paramJson}`); + this._settingConfig = { ...settingConfigParam }; + plugin.saveData(SettingFileName, paramJson); + } + + +} + + + +async function getPersistentConfig(): Promise { + let plugin = EnvConfig.ins.plugin; + let settingConfig = null; + if (!plugin) { + return settingConfig; + } + let loaded = await plugin.loadData(SettingFileName); + if (loaded == null || loaded == undefined || loaded == '') { + console.info(`二级文档列表插件 没有配置文件,使用默认配置`) + } else { + //如果有配置文件,则使用配置文件 + // console.info(`读入配置文件: ${SettingFileName}`) + if (typeof loaded === 'string') { + loaded = JSON.parse(loaded); + } + try { + settingConfig = new SettingConfig(); + for (let key in loaded) { + setKeyValue(settingConfig, key, loaded[key]); + } + } catch (error_msg) { + console.log(`Setting load error: ${error_msg}`); + } + } + return settingConfig; +} + +function setKeyValue(settingConfig, key: any, value: any) { + if (!(key in settingConfig)) { + console.error(`"${key}" is not a setting`); + return; + } + settingConfig[key] = value; +} + +function getDefaultSettingConfig() { + let defaultConfig = new SettingConfig(); + defaultConfig.showEmbedDualDocList = true; + defaultConfig.doubleClickToggleNotebook = false; + + // defaultConfig.lockSortMode = false; + defaultConfig.showSubDocOfSubDoc = false; + defaultConfig.fullTextSearch = false; + defaultConfig.defaultDbQuerySortOrder = "UpdatedDESC"; + defaultConfig.allDocsQueryLimit = 50; + + defaultConfig.embedDocListViewFlex = 1.0; + + + defaultConfig.doubleClickTimeout = 200; + + + defaultConfig.includeConcatFields = ["content", "tag", "name", "alias", "memo"]; + defaultConfig.fullTextSearchBlockType = ["d", "h", "c", "m", "t", "p", "html", "av", "video", "audio"]; + return defaultConfig; +} + + 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/custon.d.ts b/src/types/custon.d.ts new file mode 100644 index 0000000..bb492df --- /dev/null +++ b/src/types/custon.d.ts @@ -0,0 +1,41 @@ +type FileBlock = { + id: string; + box: string; + content: string; + name: string; + alias: string; + memo: string; + bookmark?: string; + tag?: string; + path: string; + hpath?: string; + + icon?: string; + ial?: string; + + refCount: number; + subFileCount?: number; + + sort: number; + + created: string; + updated: string; + hMtime?: string; + hCtime?: string; + hSize?: string; + + dueFlashcardCount?: string; + newFlashcardCount?: string; + flashcardCount?: string; +} + + +type IItemPropertyType = + "select" | + "text" | + "number" | + "button" | + "textarea" | + "switch" | + "order" | + "tips"; \ No newline at end of file diff --git a/src/types/index.d.ts b/src/types/index.d.ts new file mode 100644 index 0000000..6bd9a96 --- /dev/null +++ b/src/types/index.d.ts @@ -0,0 +1,151 @@ +/* + * 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; + +interface INotebook { + name: string + id: string + closed: boolean + icon: string + sort: number + dueFlashcardCount?: string; + newFlashcardCount?: string; + flashcardCount?: string; + sortMode: number +} + +interface IFile { + icon: string; + name1: string; + alias: string; + memo: string; + bookmark: string; + path: string; + name: string; + hMtime: string; + hCtime: string; + hSize: string; + dueFlashcardCount?: string; + newFlashcardCount?: string; + flashcardCount?: string; + id: string; + count: number; + sort: number; + subFileCount: number; +} + +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' + | "video" + | '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; + dragElement: any; + reqIds: any; + }; + Lute: any; +} + + +interface IBacklinkData { + blockPaths: IBreadcrumb[]; + dom: string; + expand: boolean; + backlinkBlock: Block; + includeChildListItemIdArray: string[]; + excludeChildLisetItemIdArray: string[]; +} + +interface IBreadcrumb { + id: string; + name: string; + type: string; + subType: string; + children: []; +} diff --git a/src/types/setting.d.ts b/src/types/setting.d.ts new file mode 100644 index 0000000..f281f26 --- /dev/null +++ b/src/types/setting.d.ts @@ -0,0 +1,33 @@ +type SettingDialogType = + | "settingNotebook" // 笔记本 + | "settingType" // 类型 + | "settingAttr" // 属性 + | "settingOther" // 其他 + | "settingHub" + ; + +type DocumentSortMode = + | "NameASC" + | "NameDESC" + | "UpdatedASC" + | "UpdatedDESC" + | "AlphanumASC" + | "AlphanumDESC" + | "Custom" + | "RefCountASC" + | "RefCountDESC" + | "CreatedASC" + | "CreatedDESC" + | "SizeASC" + | "SizeDESC" + | "SubDocCountASC" + | "SubDocCountDESC" + | "FileTree" + ; + + + +type ClickMode = + | "click" + | "doubleClick" + ; \ No newline at end of file diff --git a/src/utils/Instance.ts b/src/utils/Instance.ts new file mode 100644 index 0000000..65d9ffc --- /dev/null +++ b/src/utils/Instance.ts @@ -0,0 +1,11 @@ +export type IClazz = new (...param: any[]) => T; + +export default class Instance { + + public static get(clazz: IClazz, ...param: any[]): T { + if (clazz["__Instance__"] == null) { + clazz["__Instance__"] = new clazz(...param); + } + return clazz["__Instance__"]; + } +} \ No newline at end of file diff --git a/src/utils/api.ts b/src/utils/api.ts new file mode 100644 index 0000000..99d4f18 --- /dev/null +++ b/src/utils/api.ts @@ -0,0 +1,623 @@ +/** + * 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 { fetchSyncPost, IWebSocketData } from "siyuan"; +import { isBoolean } from "./object-util"; +import { convertIconInIal } from "./icon-util"; + + + +async function request(url: string, data: any) { + let response: IWebSocketData = await fetchSyncPost(url, data); + let res = response.code === 0 ? response.data : null; + if (response.code != 0) { + console.log(`二级文档列表插件接口异常 url : ${url} , msg : ${response.msg}`) + } + return res; +} + + +// **************************************** Noteboook **************************************** + + +export async function lsNotebooks(): Promise { + let url = '/api/notebook/lsNotebooks'; + return request(url, ''); +} + +export async function getNotebookMapByApi(): Promise> { + let notebooks: INotebook[] = (await lsNotebooks()).notebooks; + return getNotebookMap(notebooks,); +} + + +export function getNotebookMap(notebooks: INotebook[]): Map { + let notebookMap: Map = new Map(); + if (!notebooks) { + return notebookMap; + } + for (const notebook of notebooks) { + + notebook.icon = convertIconInIal(notebook.icon); + notebookMap.set(notebook.id, notebook); + } + return notebookMap; +} + + + +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); +} + +export async function listDocsByPath( + notebook: NotebookId, + path: string, + sort: number, + ignoreMaxListHint: boolean, + maxListCount: number, + showHidden: boolean, +): Promise<{ files: IFile[], box: string, path: string }> { + /** + * { + "notebook": "20220902115243-k6ogmmk", + "path": "/20220902221817-v7xndwe.sy", + "ignoreMaxListHint": true, + "maxListCount": 0, + "showHidden": false +} + */ + let data = { + notebook: notebook, + path: path, + sort: sort, + ignoreMaxListHint: ignoreMaxListHint, + maxListCount: maxListCount, + showHidden: showHidden, + }; + let url = '/api/filetree/listDocsByPath'; + 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 **************************************** +export async function getDocInfo(id: BlockId): Promise { + let data = { + id: id + } + let url = '/api/block/getDocInfo'; + return request(url, data); +} + +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 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); +} + +export async function getBlockIndex(id: BlockId): Promise { + let data = { + id: id + } + let url = '/api/block/getBlockIndex'; + + return request(url, data); +} + +export async function getBlocksIndexes(ids: BlockId[]): Promise { + let data = { + ids: ids + } + let url = '/api/block/getBlocksIndexes'; + + return request(url, data); +} + +export async function getBlockIsFolded(id: string): Promise { + + let response = await checkBlockFold(id); + let result: boolean; + if (isBoolean(response)) { + result = response as boolean; + } else { + result = response.isFolded; + } + // console.log(`getBlockIsFolded response : ${JSON.stringify(response)}, result : ${result} `) + return result; +}; + +export async function checkBlockFold(id: string): Promise { + if (!id) { + // 参数校验失败,返回拒绝 + return Promise.reject(new Error('参数错误')); + } + let data = { + id: id + } + let url = '/api/block/checkBlockFold'; + + return request(url, data); +}; + + +export async function getBatchBlockIdIndex(ids: string[]): Promise> { + let idMap: Map = new Map(); + let getSuccess = true; + try { + let idObject = await getBlocksIndexes(ids); + // 遍历对象的键值对,并将它们添加到 Map 中 + for (const key in idObject) { + if (Object.prototype.hasOwnProperty.call(idObject, key)) { + const value = idObject[key]; + idMap.set(key, value); + } + } + } catch (err) { + getSuccess = false; + console.error("批量获取块索引报错,可能是旧版本不支持批量接口 : ", err) + } + + if (!getSuccess) { + for (const id of ids) { + let index = 0 + try { + index = await getBlockIndex(id); + } catch (err) { + console.error("获取块索引报错 : ", err) + } + idMap.set(id, index) + } + } + + return idMap; +} + +// **************************************** 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'; + try { + let file = await fetchSyncPost(url, data); + return file; + } catch (error_msg) { + return null; + } +} + +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', {}); +} + + + +export async function getBacklinkDoc(defID: string, refTreeID: string, keyword: string, containChildren: boolean): Promise<{ backlinks: IBacklinkData[] }> { + let data = { + defID: defID, + refTreeID: refTreeID, + keyword: keyword, + containChildren: containChildren, + } + let url = '/api/ref/getBacklinkDoc'; + + return request(url, data); +} + +/** + * +{ + "sort": "3", + "mSort": "3", + "k": "", + "mk": "", + "id": "20240808122601-yuhti2c" +} + * @param id 文档ID,聚焦就是聚焦后的ID + * @param k 反链关键字 + * @param mk 提及关键字 + * @param sort 反链排序 + * @param msort 提及排序 + * @returns + */ +export async function getBacklink2(id: string, k: string, mk: string, sort: string, msort: string): Promise { + let data = { + id: id, + k: k, + mk: mk, + sort: sort, + msort: msort, + } + let url = '/api/ref/getBacklink2'; + + return request(url, data); +} \ No newline at end of file diff --git a/src/utils/array-util.ts b/src/utils/array-util.ts new file mode 100644 index 0000000..da549d1 --- /dev/null +++ b/src/utils/array-util.ts @@ -0,0 +1,58 @@ +export function paginate(array: T[], pageNumber: number, pageSize: number): T[] { + // 计算起始索引 + const startIndex = (pageNumber - 1) * pageSize; + // 计算结束索引 + const endIndex = startIndex + pageSize; + // 返回对应的数组片段 + return array.slice(startIndex, endIndex); +} + +export function getLastItem(list: T[]): T | undefined { + return list.length > 0 ? list[list.length - 1] : undefined; +} + +export function isArrayEmpty(array: T[]): boolean { + return !array || array.length == 0; +} + + +export function isArrayNotEmpty(array: T[]): boolean { + return Array.isArray(array) && array.length > 0; +} + +export function isSetEmpty(set: Set): boolean { + return !set || set.size == 0; +} + +export function isSetNotEmpty(set: Set): boolean { + return set && set.size > 0; +} + +// 求交集。 +export function intersectionArray(array1: T[], array2: T[]): T[] { + if (isArrayEmpty(array1) || isArrayEmpty(array2)) { + return []; + } + // 使用 Set 来提高查找的效率 + // const set1 = new Set(array1); + const set2 = new Set(array2); + + // 过滤 array1 中的元素,只保留那些也在 set2 中的元素 + return array1.filter(item => set2.has(item)); +} + + +// 求交集。 +export function intersectionSet(set1: Set, set2: Set): T[] { + if (isSetEmpty(set1) || isSetEmpty(set2)) { + return []; + } + + const result = []; + for (const item of set1) { + if (set2.has(item)) { + result.push(item); + } + } + return result; +} diff --git a/src/utils/cache-util.ts b/src/utils/cache-util.ts new file mode 100644 index 0000000..8ca8145 --- /dev/null +++ b/src/utils/cache-util.ts @@ -0,0 +1,69 @@ + + +export class CacheUtil { + + + private cache: Map = new Map(); + + /** + * 设置缓存 + * @param key 缓存键 + * @param value 缓存值 + * @param ttl 缓存有效时间(毫秒) + */ + set(key: string, value: any, ttl: number): void { + const expiry = Date.now() + ttl; + this.cache.set(key, { value, expiry }); + } + + /** + * 获取缓存 + * @param key 缓存键 + * @returns 缓存值或 null + */ + get(key: string): any | null { + const cachedItem = this.cache.get(key); + if (cachedItem) { + if (cachedItem.expiry > Date.now()) { + return cachedItem.value; + } else { + this.cache.delete(key); + } + } + return null; + } + /** + * 主动丢弃缓存 + * @param key 缓存键 + */ + delete(key: string): void { + this.cache.delete(key); + } + + /** + * 清除所有过期的缓存项 + */ + cleanUp(): void { + const now = Date.now(); + for (const [key, { expiry }] of this.cache) { + if (expiry <= now) { + this.cache.delete(key); + } + } + } + + clearByPrefix(prefix: string): void { + for (const key of this.cache.keys()) { + if (key.startsWith(prefix)) { + this.cache.delete(key); + } + } + } +} + + +export function generateKey(...parts: string[]): string { + // 使用指定的分隔符连接所有字符串 + const separator = ':'; + return parts.join(separator); +} diff --git a/src/utils/html-util.ts b/src/utils/html-util.ts new file mode 100644 index 0000000..b4a5382 --- /dev/null +++ b/src/utils/html-util.ts @@ -0,0 +1,254 @@ +import { isArrayEmpty } from "./array-util"; + +export const escapeAttr = (html: string) => { + return html.replace(/"/g, """).replace(/'/g, "'"); +}; +export async function highlightElementTextByCss( + contentElement: HTMLElement, + keywords: string[], + nextMatchFocusIndex: number, +): Promise { + if (!contentElement || !keywords) { + return; + } + // If the CSS Custom Highlight API is not supported, + // display a message and bail-out. + if (!CSS.highlights) { + console.log("CSS Custom Highlight API not supported."); + return; + } + + // Find all text nodes in the article. We'll search within + // these text nodes. + const treeWalker = document.createTreeWalker( + contentElement, + NodeFilter.SHOW_TEXT, + ); + const allTextNodes: Node[] = []; + let currentNode = treeWalker.nextNode(); + while (currentNode) { + allTextNodes.push(currentNode); + currentNode = treeWalker.nextNode(); + } + + // Clear the HighlightRegistry to remove the + // previous search results. + clearCssHighlights(); + + // Clean-up the search query and bail-out if + // if it's empty. + + let allMatchRanges: Range[] = []; + let targetElementMatchRanges: Range[] = []; + + // Iterate over all text nodes and find matches. + allTextNodes + .map((el: Node) => { + return { el, text: el.textContent.toLowerCase() }; + }) + .map(({ el, text }) => { + const indices: { index: number; length: number }[] = []; + for (const queryStr of keywords) { + if (!queryStr) { + continue; + } + let startPos = 0; + while (startPos < text.length) { + const index = text.indexOf( + queryStr.toLowerCase(), + startPos, + ); + if (index === -1) break; + let length = queryStr.length; + indices.push({ index, length }); + startPos = index + length; + } + } + + indices + .sort((a, b) => a.index - b.index) + .map(({ index, length }) => { + const range = new Range(); + range.setStart(el, index); + range.setEnd(el, index + length); + allMatchRanges.push(range); + // if (getNodeId(el) == targetBlockId) { + targetElementMatchRanges.push(range); + // } + }); + }); + + // Create a Highlight object for the ranges. + allMatchRanges = allMatchRanges.flat(); + if (!allMatchRanges || allMatchRanges.length <= 0) { + return; + } + let matchFocusRange: Range; + let nextMatchIndexRemainder = + nextMatchFocusIndex % targetElementMatchRanges.length; + for (let i = 0; i < targetElementMatchRanges.length; i++) { + if (i == nextMatchIndexRemainder) { + matchFocusRange = targetElementMatchRanges[i]; + break; + } + } + + allMatchRanges = allMatchRanges.filter( + (obj) => obj !== matchFocusRange, + ); + + const searchResultsHighlight = new Highlight(...allMatchRanges); + + // Register the Highlight object in the registry. + CSS.highlights.set("search-result-mark", searchResultsHighlight); + + if (matchFocusRange) { + CSS.highlights.set( + "search-result-focus", + new Highlight(matchFocusRange), + ); + return matchFocusRange; + } + + return; +} + +export function scrollByRange(matchRange: Range, position: ScrollLogicalPosition) { + if (!matchRange) { + return; + } + position = position ? position : "center"; + + const matchElement = + matchRange.commonAncestorContainer.parentElement; + if (!matchElement) { + return; + } + + if ( + matchElement.clientHeight > + document.documentElement.clientHeight + ) { + // 特殊情况:如果一个段落中软换行非常多,此时如果定位到匹配节点的首行, + // 是看不到查询的文本的,需要通过 Range 的精确位置进行定位。 + const scrollingElement = findScrollingElement(matchElement); + const contentRect = scrollingElement.getBoundingClientRect(); + let scrollTop = + scrollingElement.scrollTop + + matchRange.getBoundingClientRect().top - + contentRect.top - + contentRect.height / 2; + scrollingElement.scrollTo({ + top: scrollTop, + behavior: "smooth", + }); + } else { + matchElement.scrollIntoView({ + behavior: "smooth", + block: position, + inline: position, + }); + } +} + + +export function clearCssHighlights() { + CSS.highlights.delete("search-result-mark"); + CSS.highlights.delete("search-result-focus"); +} + +// 查找包含指定元素的最近的滚动容器 +function findScrollingElement( + element: HTMLElement, +): HTMLElement | null { + let parentElement = element.parentElement; + while (parentElement) { + if (parentElement.scrollHeight > parentElement.clientHeight) { + return parentElement; // 找到第一个具有滚动条的父级元素 + } + parentElement = parentElement.parentElement; + } + return null; // 没有找到具有滚动条的父级元素 +} + + +export function clearProtyleGutters(target: HTMLElement) { + if (!target) { + return; + } + target.querySelectorAll(".protyle-gutters").forEach((item) => { + item.classList.add("fn__none"); + item.innerHTML = ""; + }); +} + + +// 查找可滚动的父级元素 +export function findScrollableParent(element: HTMLElement) { + if (!element) { + return null; + } + + // const hasScrollableSpace = element.scrollHeight > element.clientHeight; + const hasVisibleOverflow = getComputedStyle(element).overflowY !== 'visible'; + + if (hasVisibleOverflow) { + return element; + } + + return findScrollableParent(element.parentElement); +} + + +function escapeHtml(input: string): string { + const escapeMap: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + + return input.replace(/[&<>"']/g, (match) => escapeMap[match]); +} + + +export function getRangeByElement(element: Element): Range { + if (!element) { + return; + } + let elementRange = document.createRange(); + elementRange.selectNodeContents(element); + return elementRange; +} + + +export function getAttributeRecursively(element: HTMLElement | null, attributeName: string): string | null { + // 如果元素不存在,直接返回 null + if (!element) { + return null; + } + // 尝试获取当前元素的指定属性 + const attributeValue = element.getAttribute(attributeName); + + // 如果当前元素有该属性值,返回它 + if (attributeValue) { + return attributeValue; + } + + // 否则,递归从父元素中查找 + return getAttributeRecursively(element.parentElement, attributeName); +} + + +export function findParentElementWithAttribute(element: HTMLElement, types: string[], depth: number): HTMLElement | null { + let temp = element; + for (let i = 0; i < depth && temp; i++) { + const type = temp.getAttribute("data-type"); + if (types.includes(type)) { + return temp; + } + temp = temp.parentElement; + } + return null; +} diff --git a/src/utils/icon-util.ts b/src/utils/icon-util.ts new file mode 100644 index 0000000..6ac9793 --- /dev/null +++ b/src/utils/icon-util.ts @@ -0,0 +1,108 @@ + +export function convertIconInIal(icon: string): string { + if (icon) { + if (icon.includes(".")) { + // 如果包含 ".",则认为是图片,生成标签 + return ``; + } else { + // 如果是Emoji,转换为表情符号 + let emoji = ""; + try { + icon.split("-").forEach(item => { + if (item.length < 5) { + emoji += String.fromCodePoint(parseInt("0" + item, 16)); + } else { + emoji += String.fromCodePoint(parseInt(item, 16)); + } + }); + } catch (e) { + // 自定义表情搜索报错 https://github.com/siyuan-note/siyuan/issues/5883 + // 这里忽略错误不做处理 + } + return emoji; + } + } + // 既不是Emoji也不是图片,返回null + return null; +} + +export function convertIalStringToObject(ial: string): { [key: string]: string } { + const keyValuePairs = ial.match(/\w+="[^"]*"/g); + + if (!keyValuePairs) { + return {}; + } + + const resultObject: { [key: string]: string } = {}; + + keyValuePairs.forEach((pair) => { + const [key, value] = pair.split('='); + resultObject[key] = value.replace(/"/g, ''); // 去除值中的双引号 + }); + + return resultObject; +} + + + +export function getBlockTypeIconHref(type: string, subType: string): string { + let iconHref = ""; + if (type) { + if (type === "d") { + iconHref = "#iconFile"; + } else if (type === "h") { + if (subType === "h1") { + iconHref = "#iconH1"; + } else if (subType === "h2") { + iconHref = "#iconH2"; + } else if (subType === "h3") { + iconHref = "#iconH3"; + } else if (subType === "h4") { + iconHref = "#iconH4"; + } else if (subType === "h5") { + iconHref = "#iconH5"; + } else if (subType === "h6") { + iconHref = "#iconH6"; + } + } else if (type === "c") { + iconHref = "#iconCode"; + } else if (type === "html") { + iconHref = "#iconHTML5"; + } else if (type === "p") { + iconHref = "#iconParagraph"; + } else if (type === "m") { + iconHref = "#iconMath"; + } else if (type === "t") { + iconHref = "#iconTable"; + } else if (type === "b") { + iconHref = "#iconQuote"; + } else if (type === "l") { + if (subType === "o") { + iconHref = "#iconOrderedList"; + } else if (subType === "u") { + iconHref = "#iconList"; + } else if (subType === "t") { + iconHref = "#iconCheck"; + } + } else if (type === "i") { + iconHref = "#iconListItem"; + } else if (type === "av") { + iconHref = "#iconDatabase"; + } else if (type === "s") { + iconHref = "#iconSuper"; + } else if (type === "audio") { + iconHref = "#iconRecord"; + } else if (type === "video") { + iconHref = "#iconVideo"; + } else if (type === "query_embed") { + iconHref = "#iconSQL"; + } else if (type === "tb") { + iconHref = "#iconLine"; + } else if (type === "widget") { + iconHref = "#iconBoth"; + } else if (type === "iframe") { + iconHref = "#iconLanguage"; + } + } + return iconHref; +} \ No newline at end of file diff --git a/src/utils/json-util.ts b/src/utils/json-util.ts new file mode 100644 index 0000000..e18325b --- /dev/null +++ b/src/utils/json-util.ts @@ -0,0 +1,19 @@ +// 自定义 replacer 函数,在序列化时将 Set 对象转换为数组 +export function setReplacer(key, value) { + if (value instanceof Set) { + return { + _type: 'Set', + _value: [...value] + }; + } + return value; +} + +// 自定义 reviver 函数,在反序列化时将数组转换回 Set 对象 +export function setReviver(key, value) { + if (value && value._type === 'Set') { + return new Set(value._value); + } + return value; +} + diff --git a/src/utils/object-util.ts b/src/utils/object-util.ts new file mode 100644 index 0000000..1ebfc3a --- /dev/null +++ b/src/utils/object-util.ts @@ -0,0 +1,49 @@ +export function getObjectSizeInKB(obj: any): number { + try { + // 将 JSON 对象转换为字符串 + const jsonString = JSON.stringify(obj); + + // 计算字符串的字节数 + const bytes = new Blob([jsonString]).size; + + // 将字节数转换为 KB + const kilobytes = bytes / 1024; + return kilobytes; + } catch (err) { + console.log("计算对象大小报错") + } + return 0; +} + + +export function isBoolean(value: any): value is boolean { + return typeof value === 'boolean'; +} + +export function isObject(value: any): value is object { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + + +/** + * obj1 字段为空的值由 obj2 补上。 + * @param obj1 + * @param obj2 默认配置对象 + * @returns + */ +export function mergeObjects(obj1: T, obj2: U): T & U { + + const result = { ...obj1 } as T & U; + + for (const key in obj2) { + if (obj2.hasOwnProperty(key)) { + // 仅当 obj1[key] 为 null 或 undefined 时才覆盖 + if (result[key] === null || result[key] === undefined) { + (result as any)[key] = obj2[key]; + } + } + } + + return result; +} + diff --git a/src/utils/siyuan-util.ts b/src/utils/siyuan-util.ts new file mode 100644 index 0000000..a1d4bb7 --- /dev/null +++ b/src/utils/siyuan-util.ts @@ -0,0 +1,336 @@ +import { EnvConfig } from "@/config/EnvConfig"; +import { isArrayEmpty } from "@/utils/array-util"; +import { removePrefixAndSuffix } from "@/utils/string-util"; + +export function getActiveTab(): HTMLDivElement { + let tab = document.querySelector("div.layout__wnd--active ul.layout-tab-bar>li.item--focus"); + let dataId: string = tab?.getAttribute("data-id"); + if (!dataId) { + return null; + } + const activeTab: HTMLDivElement = document.querySelector( + `.layout-tab-container.fn__flex-1>div.protyle[data-id="${dataId}"]` + ) as HTMLDivElement; + return activeTab; +} + +export function highlightBlockContent(block: FileBlock, keywords: string[]) { + if (!block || isArrayEmpty(keywords)) { + return; + } + let contentHtml = getHighlightedContent(block.content, keywords); + let nameHml = getHighlightedContent(block.name, keywords); + let aliasHtml = getHighlightedContent(block.alias, keywords); + let memoHtml = getHighlightedContent(block.memo, keywords); + let tagHtml = getHighlightedContent(block.tag, keywords); + block.content = contentHtml; + block.name = nameHml; + block.alias = aliasHtml; + block.memo = memoHtml; + block.tag = tagHtml; +} + +export function getNodeId(node: Node | null): string | null { + if (!node) { + return null; + } + if (node instanceof Element) { + const nodeId = (node as HTMLElement).getAttribute("data-node-id"); + if (nodeId) { + return nodeId; + } + } + // 递归查找父节点 + return getNodeId(node.parentNode); +} + + + +let bgFadeTimeoutId: NodeJS.Timeout; + +export function bgFade(element: Element) { + if (bgFadeTimeoutId) { + clearTimeout(bgFadeTimeoutId); + bgFadeTimeoutId = null; + } + element.parentElement.querySelectorAll(".protyle-wysiwyg--hl").forEach((hlItem) => { + hlItem.classList.remove("protyle-wysiwyg--hl"); + }); + element.classList.add("protyle-wysiwyg--hl"); + bgFadeTimeoutId = setTimeout(function () { + element.classList.remove("protyle-wysiwyg--hl"); + }, 1536); +}; + +export function highlightContent(content: string, keywords: string[]): string { + if (!content) { + return content; + } + let contentHtml = getHighlightedContent(content, keywords); + return contentHtml; +} + +export function getHighlightedContent( + content: string, + keywords: string[], +): string { + if (!content) { + return content; + } + // let highlightedContent: string = escapeHtml(content); + let highlightedContent: string = content; + + if (keywords) { + highlightedContent = highlightMatches(highlightedContent, keywords); + } + return highlightedContent; +} + +function highlightMatches(content: string, keywords: string[]): string { + if (!keywords.length || !content) { + return content; // 返回原始字符串,因为没有需要匹配的内容 + } + + const regexPattern = new RegExp(`(${keywords.join("|")})`, "gi"); + const highlightedString = content.replace( + regexPattern, + "$1", + ); + return highlightedString; +} + +export function parseDateTimeInBlock(dateTimeString: string): Date | null { + if (dateTimeString.length !== 14) { + console.error("Invalid date time string format. It should be 'yyyyMMddhhmmss'."); + return null; + } + + const year = parseInt(dateTimeString.slice(0, 4), 10); + const month = parseInt(dateTimeString.slice(4, 6), 10) - 1; // 月份从 0 开始 + const day = parseInt(dateTimeString.slice(6, 8), 10); + const hour = parseInt(dateTimeString.slice(8, 10), 10); + const minute = parseInt(dateTimeString.slice(10, 12), 10); + const second = parseInt(dateTimeString.slice(12, 14), 10); + + return new Date(year, month, day, hour, minute, second); +} + + +export function convertDateTimeInBlock(dateTimeString: string): string { + if (dateTimeString.length !== 14) { + console.error("Invalid date time string format. It should be 'yyyyMMddhhmmss'."); + return null; + } + const year = dateTimeString.slice(0, 4); + const month = dateTimeString.slice(4, 6); + const day = dateTimeString.slice(6, 8); + const hour = dateTimeString.slice(8, 10); + const minute = dateTimeString.slice(10, 12); + const second = dateTimeString.slice(12, 14); + + return `${year}-${month}-${day} ${hour}:${minute}:${second}`; +} + + + +export function getFileArialLabel(fileBlock: FileBlock, boxName: string): string { + let ariaLabelRow: string[] = []; + ariaLabelRow.push(fileBlock.content); + if (fileBlock.name) { + ariaLabelRow.push( + `\n${window.siyuan.languages.name} ${fileBlock.name}`, + ); + } + if (fileBlock.alias) { + ariaLabelRow.push( + `\n${window.siyuan.languages.alias} ${fileBlock.alias}`, + ); + } + if (fileBlock.tag) { + ariaLabelRow.push( + `\n${window.siyuan.languages.tag} ${fileBlock.tag}`, + ); + } + if (fileBlock.memo) { + ariaLabelRow.push( + `\n${window.siyuan.languages.memo} ${fileBlock.memo}`, + ); + } + + ariaLabelRow.push(`
${EnvConfig.ins.i18n.notebook} ${boxName}`); + if (fileBlock.hpath) { + ariaLabelRow.push(`\n${EnvConfig.ins.i18n.path} ${fileBlock.hpath}`); + } + + let subFileCount = fileBlock.subFileCount; + if (subFileCount) { + ariaLabelRow.push(`${window.siyuan.languages.includeSubFile.replace("x", subFileCount)} `); + } + + let updated = fileBlock.updated; + let created = fileBlock.created; + if (updated.length === 14) { + updated = convertDateTimeInBlock(fileBlock.updated); + updated += ", " + formatRelativeTimeInBlock(fileBlock.updated); + } + if (created.length === 14) { + created = convertDateTimeInBlock(fileBlock.created); + created += ", " + formatRelativeTimeInBlock(fileBlock.created); + } + + ariaLabelRow.push( + `\n${window.siyuan.languages.modifiedAt} ${updated}`, + ); + ariaLabelRow.push( + `\n${window.siyuan.languages.createdAt} ${created}`, + ); + + let ariaLabel = ariaLabelRow.join(""); + ariaLabel = removePrefixAndSuffix(ariaLabel, "\n", ""); + + return ariaLabel; +} + + +export function formatRelativeTimeInBlock(dateTimeString: string): string { + let timestamp = parseDateTimeInBlock(dateTimeString).getTime(); + return formatRelativeTime(timestamp); +} + + + +export function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + const minute = 60 * 1000; + const hour = 60 * minute; + const day = 24 * hour; + const month = 30 * day; + const year = 365 * day; + + if (diff < minute) { + return `${Math.floor(diff / 1000)}秒前`; + } else if (diff < hour) { + return `${Math.floor(diff / minute)}分钟前`; + } else if (diff < day) { + return `${Math.floor(diff / hour)}小时前`; + } else if (diff < month) { + return `${Math.floor(diff / day)}天前`; + } else if (diff < year) { + return `${Math.floor(diff / month)}个月前`; + } else { + return `${Math.floor(diff / year)}年前`; + } +} + + +export function convertSordModeToNumber(sortMode: DocumentSortMode): number { + let sortCode = null; + switch (sortMode) { + case "NameASC": + sortCode = 0 + break; + case "NameDESC": + sortCode = 1; + break; + case "UpdatedASC": + sortCode = 2; + break; + case "UpdatedDESC": + sortCode = 3; + break; + case "AlphanumASC": + sortCode = 4; + break; + case "AlphanumDESC": + sortCode = 5; + break; + case "Custom": + sortCode = 6; + break; + case "RefCountASC": + sortCode = 7; + break; + case "RefCountDESC": + sortCode = 8; + break; + case "CreatedASC": + sortCode = 9; + break; + case "CreatedDESC": + sortCode = 10; + break; + case "SizeASC": + sortCode = 11; + break; + case "SizeDESC": + sortCode = 12; + break; + case "SubDocCountASC": + sortCode = 13; + break; + case "SubDocCountDESC": + sortCode = 14; + break; + case "FileTree": + sortCode = 15; + break; + } + return sortCode; +} + +export function convertNumberToSordMode(sortCode: number): DocumentSortMode { + let sortMode = null; + switch (sortCode) { + case 0: + sortMode = "NameASC"; + break; + case 1: + sortMode = "NameDESC"; + break; + case 2: + sortMode = "UpdatedASC"; + break; + case 3: + sortMode = "UpdatedDESC"; + break; + case 4: + sortMode = "AlphanumASC"; + break; + case 5: + sortMode = "AlphanumDESC"; + break; + case 6: + sortMode = "Custom"; + break; + case 7: + sortMode = "RefCountASC"; + break; + case 8: + sortMode = "RefCountDESC"; + break; + case 9: + sortMode = "CreatedASC"; + break; + case 10: + sortMode = "CreatedDESC"; + break; + case 11: + sortMode = "SizeASC"; + break; + case 12: + sortMode = "SizeDESC"; + break; + case 13: + sortMode = "SubDocCountASC"; + break; + case 14: + sortMode = "SubDocCountDESC"; + break; + case 15: + sortMode = "FileTree"; + break; + } + return sortMode; + +} \ No newline at end of file diff --git a/src/utils/string-util.ts b/src/utils/string-util.ts new file mode 100644 index 0000000..7825bb0 --- /dev/null +++ b/src/utils/string-util.ts @@ -0,0 +1,114 @@ +import { isArrayEmpty } from "./array-util"; + +export function removePrefix(input: string, prefix: string): string { + if (input.startsWith(prefix)) { + return input.substring(prefix.length); + } else { + return input; + } +} + +export function removeSuffix(input: string, suffix: string): string { + if (input.endsWith(suffix)) { + return input.substring(0, input.length - suffix.length); + } else { + return input; + } +} + +export function removePrefixAndSuffix(input: string, prefix: string, suffix: string): string { + let result = input; + + if (result.startsWith(prefix)) { + result = result.substring(prefix.length); + } + + if (result.endsWith(suffix)) { + result = result.substring(0, result.length - suffix.length); + } + + return result; +} + +export function containsAllKeywords( + str: string, + keywords: string[], +): boolean { + return keywords.every(keyword => str.includes(keyword)); +} + + +export function longestCommonSubstring(s1: string, s2: string): string { + if (!s1 || !s2) { + return ""; + } + s1 = s1 ? s1 : ""; + s2 = s2 ? s2 : ""; + const len1 = s1.length; + const len2 = s2.length; + const dp: number[][] = Array.from({ length: len1 + 1 }, () => + Array(len2 + 1).fill(0), + ); + + let maxLength = 0; + let endIndex = 0; + + for (let i = 1; i <= len1; i++) { + for (let j = 1; j <= len2; j++) { + if (s1[i - 1] === s2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + if (dp[i][j] > maxLength) { + maxLength = dp[i][j]; + endIndex = i; + } + } + } + } + + return s1.substring(endIndex - maxLength, endIndex); +} + + +export function countOccurrences(str: string, subStr: string): number { + // 创建一个正则表达式,全局搜索指定的子字符串 + const regex = new RegExp(subStr, 'g'); + // 使用 match 方法匹配所有出现的子字符串,返回匹配结果数组 + const matches = str.match(regex); + // 返回匹配的次数,如果没有匹配到则返回 0 + return matches ? matches.length : 0; +} + +/** + * 判定字符串是否有效 + * @param s 需要检查的字符串(或其他类型的内容) + * @returns true / false 是否为有效的字符串 + */ +export function isStrNotBlank(s: any): boolean { + if (s == undefined || s == null || s === '') { + return false; + } + return true; +} + +export function isStrBlank(s: any): boolean { + return !isStrNotBlank(s); +} + + +export function splitKeywordStringToArray(keywordStr: string): string[] { + let keywordArray = []; + if (!isStrNotBlank(keywordStr)) { + return keywordArray; + } + // 分离空格 + keywordArray = keywordStr.trim().replace(/\s+/g, " ").split(" "); + if (isArrayEmpty(keywordArray)) { + return keywordArray; + } + // 去重 + keywordArray = Array.from(new Set( + keywordArray.filter((keyword) => keyword.length > 0), + )); + return keywordArray; + +} \ No newline at end of file diff --git a/src/utils/timing-util.ts b/src/utils/timing-util.ts new file mode 100644 index 0000000..38f7cc2 --- /dev/null +++ b/src/utils/timing-util.ts @@ -0,0 +1,24 @@ + +export function delayedTwiceRefresh(executeFun: () => void, firstTimeout: number) { + if (!executeFun) { + return; + } + if (!firstTimeout) { + firstTimeout = 0; + } + let refreshPreviewHighlightTimeout = 140; + setTimeout(() => { + executeFun(); + + if ( + refreshPreviewHighlightTimeout && + refreshPreviewHighlightTimeout > 0 + ) { + setTimeout(() => { + executeFun(); + }, refreshPreviewHighlightTimeout); + } + + }, firstTimeout); +} + 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..809da57 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,131 @@ +import { resolve } from "path" +import { defineConfig, loadEnv } from "vite" +import minimist from "minimist" +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 args = minimist(process.argv.slice(2)) +const isWatch = args.watch || args.w || false +const devDistDir = "dev" +const distDir = isWatch ? devDistDir : "dist" + +console.log("isWatch=>", isWatch) +console.log("distDir=>", distDir) + +export default defineConfig({ + resolve: { + alias: { + "@": resolve(__dirname, "src"), + } + }, + + plugins: [ + svelte(), + + vitePluginYamlI18n({ + inDir: 'public/i18n', + outDir: `${distDir}/i18n` + }), + + viteStaticCopy({ + targets: [ + { + src: "./README*.md", + dest: "./", + }, + { + src: "./plugin.json", + dest: "./", + }, + { + src: "./preview.png", + dest: "./", + }, + { + src: "./icon.png", + dest: "./", + } + ], + }), + ], + + // https://github.com/vitejs/vite/issues/1930 + // https://vitejs.dev/guide/env-and-mode.html#env-files + // https://github.com/vitejs/vite/discussions/3058#discussioncomment-2115319 + // 在这里自定义变量 + define: { + "process.env.DEV_MODE": `"${isWatch}"`, + "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV) + }, + + build: { + // 输出路径 + outDir: distDir, + emptyOutDir: false, + + // 构建后是否生成 source map 文件 + sourcemap: isWatch ? 'inline' : false, + + // 设置为 false 可以禁用最小化混淆 + // 或是用来指定是应用哪种混淆器 + // boolean | 'terser' | 'esbuild' + // 不压缩,用于调试 + minify: !isWatch, + + lib: { + // Could also be a dictionary or array of multiple entry points + entry: resolve(__dirname, "src/index.ts"), + // the proper extensions will be added + fileName: "index", + formats: ["cjs"], + }, + rollupOptions: { + plugins: [ + ...( + isWatch ? [ + livereload(devDistDir), + { + //监听静态资源文件 + name: 'watch-external', + async buildStart() { + const files = await fg([ + 'public/i18n/**', + './README*.md', + './plugin.json' + ]); + for (let file of files) { + this.addWatchFile(file); + } + } + } + ] : [ + zipPack({ + inDir: './dist', + outDir: './', + outFileName: 'package.zip' + }) + ] + ) + ], + + // make sure to externalize deps that shouldn't be bundled + // into your library + external: ["siyuan", "process"], + + output: { + entryFileNames: "[name].js", + assetFileNames: (assetInfo) => { + if (assetInfo.name === "style.css") { + return "index.css" + } + return assetInfo.name + }, + }, + }, + } +}) 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}`); + } + } + } + }, + }; +}