From 81f4c835c66bb1bc36545dbf42d7292d102f9d0a Mon Sep 17 00:00:00 2001 From: Misuzu2027 <154575405+Misuzu2027@users.noreply.github.com> Date: Wed, 18 Sep 2024 14:58:39 +0800 Subject: [PATCH] init --- .github/workflows/release.yml | 62 ++ .gitignore | 11 + CHANGELOG.md | 112 ++ LICENSE | 21 + README.md | 28 + README_zh_CN.md | 28 + asset/action.png | Bin 0 -> 42301 bytes icon.png | Bin 0 -> 2912 bytes package.json | 36 + plugin.json | 38 + preview.png | Bin 0 -> 12208 bytes public/i18n/en_US.json | 83 ++ public/i18n/zh_CN.json | 84 ++ scripts/.gitignore | 5 + scripts/elevate.ps1 | 24 + scripts/make_dev_link.js | 66 ++ scripts/make_install.js | 57 + scripts/update_version.js | 141 +++ scripts/utils.js | 182 ++++ src/components/doc-list/DocListManager.ts | 209 ++++ src/components/doc-list/doc-list.svelte | 981 ++++++++++++++++++ src/components/setting/SettingManager.ts | 27 + .../setting/inputs/setting-input.svelte | 41 + .../setting/inputs/setting-select.svelte | 21 + .../setting/inputs/setting-switch.svelte | 18 + src/components/setting/setting-item.svelte | 16 + src/components/setting/setting-page.svelte | 67 ++ src/config/EnvConfig.ts | 64 ++ src/index.scss | 37 + src/index.ts | 47 + src/libs/siyuan/functions.ts | 3 + src/libs/siyuan/hasClosest.ts | 54 + src/models/document-model.ts | 12 + src/models/icon-constant.ts | 36 + src/models/search-model.ts | 59 ++ src/models/setting-constant.ts | 171 +++ src/models/setting-model.ts | 100 ++ src/models/siyuan-constant.ts | 5 + src/service/plugin/DockService.ts | 0 src/service/search/search-sql.ts | 390 +++++++ src/service/search/search-util.ts | 540 ++++++++++ src/service/setting/SettingService.ts | 129 +++ src/types/api.d.ts | 65 ++ src/types/custon.d.ts | 41 + src/types/index.d.ts | 151 +++ src/types/setting.d.ts | 33 + src/utils/Instance.ts | 11 + src/utils/api.ts | 623 +++++++++++ src/utils/array-util.ts | 58 ++ src/utils/cache-util.ts | 69 ++ src/utils/html-util.ts | 254 +++++ src/utils/icon-util.ts | 108 ++ src/utils/json-util.ts | 19 + src/utils/object-util.ts | 49 + src/utils/siyuan-util.ts | 336 ++++++ src/utils/string-util.ts | 114 ++ src/utils/timing-util.ts | 24 + svelte.config.js | 26 + tsconfig.json | 59 ++ tsconfig.node.json | 12 + vite.config.ts | 131 +++ yaml-plugin.js | 60 ++ 62 files changed, 6248 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README_zh_CN.md create mode 100644 asset/action.png create mode 100644 icon.png create mode 100644 package.json create mode 100644 plugin.json create mode 100644 preview.png create mode 100644 public/i18n/en_US.json create mode 100644 public/i18n/zh_CN.json create mode 100644 scripts/.gitignore create mode 100644 scripts/elevate.ps1 create mode 100644 scripts/make_dev_link.js create mode 100644 scripts/make_install.js create mode 100644 scripts/update_version.js create mode 100644 scripts/utils.js create mode 100644 src/components/doc-list/DocListManager.ts create mode 100644 src/components/doc-list/doc-list.svelte create mode 100644 src/components/setting/SettingManager.ts create mode 100644 src/components/setting/inputs/setting-input.svelte create mode 100644 src/components/setting/inputs/setting-select.svelte create mode 100644 src/components/setting/inputs/setting-switch.svelte create mode 100644 src/components/setting/setting-item.svelte create mode 100644 src/components/setting/setting-page.svelte create mode 100644 src/config/EnvConfig.ts create mode 100644 src/index.scss create mode 100644 src/index.ts create mode 100644 src/libs/siyuan/functions.ts create mode 100644 src/libs/siyuan/hasClosest.ts create mode 100644 src/models/document-model.ts create mode 100644 src/models/icon-constant.ts create mode 100644 src/models/search-model.ts create mode 100644 src/models/setting-constant.ts create mode 100644 src/models/setting-model.ts create mode 100644 src/models/siyuan-constant.ts create mode 100644 src/service/plugin/DockService.ts create mode 100644 src/service/search/search-sql.ts create mode 100644 src/service/search/search-util.ts create mode 100644 src/service/setting/SettingService.ts create mode 100644 src/types/api.d.ts create mode 100644 src/types/custon.d.ts create mode 100644 src/types/index.d.ts create mode 100644 src/types/setting.d.ts create mode 100644 src/utils/Instance.ts create mode 100644 src/utils/api.ts create mode 100644 src/utils/array-util.ts create mode 100644 src/utils/cache-util.ts create mode 100644 src/utils/html-util.ts create mode 100644 src/utils/icon-util.ts create mode 100644 src/utils/json-util.ts create mode 100644 src/utils/object-util.ts create mode 100644 src/utils/siyuan-util.ts create mode 100644 src/utils/string-util.ts create mode 100644 src/utils/timing-util.ts create mode 100644 svelte.config.js create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts create mode 100644 yaml-plugin.js 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 0000000000000000000000000000000000000000..a884045ef7c3e8f88aa7268a736f7b2adf98568f GIT binary patch literal 42301 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 literal 0 HcmV?d00001 diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4b6609f9e070a14bfa90d840f06481662eed00fb GIT binary patch literal 2912 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 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} +
  • { + activeTab = tab.key; + }} + on:keydown={handleKeyDownDefault} + > + + + + {tab.name} +
  • + {/each} +
+
+ {#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}`); + } + } + } + }, + }; +}