├── .github └── workflows │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── README_zh_CN.md ├── asset └── action.png ├── icon.png ├── package.json ├── plugin.json ├── preview.png ├── public └── i18n │ ├── README.md │ ├── en_US.json │ └── zh_CN.json ├── scripts ├── .gitignore ├── elevate.ps1 ├── make_dev_link.js ├── make_install.js ├── update_version.js └── utils.js ├── siyuan_doctree_fake_subfolder.zip ├── src ├── api.ts ├── hello.svelte ├── helpers.ts ├── index.scss ├── index.ts ├── libs │ ├── components │ │ ├── Form │ │ │ ├── form-input.svelte │ │ │ ├── form-wrap.svelte │ │ │ └── index.ts │ │ ├── b3-typography.svelte │ │ └── setting-panel.svelte │ ├── const.ts │ ├── dialog.ts │ ├── index.d.ts │ ├── promise-pool.ts │ └── setting-utils.ts ├── setting-example.svelte └── types │ ├── api.d.ts │ └── index.d.ts ├── svelte.config.js ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yaml-plugin.js /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release on Tag Push 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | # Checkout 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | 16 | # Install Node.js 17 | - name: Install Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 20 21 | registry-url: "https://registry.npmjs.org" 22 | 23 | # Install pnpm 24 | - name: Install pnpm 25 | uses: pnpm/action-setup@v4 26 | id: pnpm-install 27 | with: 28 | run_install: false 29 | 30 | # Get pnpm store directory 31 | - name: Get pnpm store directory 32 | id: pnpm-cache 33 | shell: bash 34 | run: | 35 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 36 | 37 | # Setup pnpm cache 38 | - name: Setup pnpm cache 39 | uses: actions/cache@v3 40 | with: 41 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 42 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 43 | restore-keys: | 44 | ${{ runner.os }}-pnpm-store- 45 | 46 | # Install dependencies 47 | - name: Install dependencies 48 | run: pnpm install 49 | 50 | # Build for production, 这一步会生成一个 package.zip 51 | - name: Build for production 52 | run: pnpm build 53 | 54 | - name: Release 55 | uses: ncipollo/release-action@v1 56 | with: 57 | allowUpdates: true 58 | artifactErrorsFailBuild: true 59 | artifacts: "package.zip" 60 | token: ${{ secrets.GITHUB_TOKEN }} 61 | prerelease: false 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .DS_Store 4 | pnpm-lock.yaml 5 | package-lock.json 6 | package.zip 7 | node_modules 8 | dev 9 | dist 10 | build 11 | tmp 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.3.5 2024-04-30 4 | 5 | * [Add `direction` to plugin method `Setting.addItem`](https://github.com/siyuan-note/siyuan/issues/11183) 6 | 7 | 8 | ## 0.3.4 2024-02-20 9 | 10 | * [Add plugin event bus `click-flashcard-action`](https://github.com/siyuan-note/siyuan/issues/10318) 11 | 12 | ## 0.3.3 2024-01-24 13 | 14 | * Update dock icon class 15 | 16 | ## 0.3.2 2024-01-09 17 | 18 | * [Add plugin `protyleOptions`](https://github.com/siyuan-note/siyuan/issues/10090) 19 | * [Add plugin api `uninstall`](https://github.com/siyuan-note/siyuan/issues/10063) 20 | * [Add plugin method `updateCards`](https://github.com/siyuan-note/siyuan/issues/10065) 21 | * [Add plugin function `lockScreen`](https://github.com/siyuan-note/siyuan/issues/10063) 22 | * [Add plugin event bus `lock-screen`](https://github.com/siyuan-note/siyuan/pull/9967) 23 | * [Add plugin event bus `open-menu-inbox`](https://github.com/siyuan-note/siyuan/pull/9967) 24 | 25 | 26 | ## 0.3.1 2023-12-06 27 | 28 | * [Support `Dock Plugin` and `Command Palette` on mobile](https://github.com/siyuan-note/siyuan/issues/9926) 29 | 30 | ## 0.3.0 2023-12-05 31 | 32 | * Upgrade Siyuan to 0.9.0 33 | * Support more platforms 34 | 35 | ## 0.2.9 2023-11-28 36 | 37 | * [Add plugin method `openMobileFileById`](https://github.com/siyuan-note/siyuan/issues/9738) 38 | 39 | 40 | ## 0.2.8 2023-11-15 41 | 42 | * [`resize` cannot be triggered after dragging to unpin the dock](https://github.com/siyuan-note/siyuan/issues/9640) 43 | 44 | ## 0.2.7 2023-10-31 45 | 46 | * [Export `Constants` to plugin](https://github.com/siyuan-note/siyuan/issues/9555) 47 | * [Add plugin `app.appId`](https://github.com/siyuan-note/siyuan/issues/9538) 48 | * [Add plugin event bus `switch-protyle`](https://github.com/siyuan-note/siyuan/issues/9454) 49 | 50 | ## 0.2.6 2023-10-24 51 | 52 | * [Deprecated `loaded-protyle` use `loaded-protyle-static` instead](https://github.com/siyuan-note/siyuan/issues/9468) 53 | 54 | ## 0.2.5 2023-10-10 55 | 56 | * [Add plugin event bus `open-menu-doctree`](https://github.com/siyuan-note/siyuan/issues/9351) 57 | 58 | ## 0.2.4 2023-09-19 59 | 60 | * Supports use in windows 61 | * [Add plugin function `transaction`](https://github.com/siyuan-note/siyuan/issues/9172) 62 | 63 | ## 0.2.3 2023-09-05 64 | 65 | * [Add plugin function `transaction`](https://github.com/siyuan-note/siyuan/issues/9172) 66 | * [Plugin API add openWindow and command.globalCallback](https://github.com/siyuan-note/siyuan/issues/9032) 67 | 68 | ## 0.2.2 2023-08-29 69 | 70 | * [Add plugin event bus `destroy-protyle`](https://github.com/siyuan-note/siyuan/issues/9033) 71 | * [Add plugin event bus `loaded-protyle-dynamic`](https://github.com/siyuan-note/siyuan/issues/9021) 72 | 73 | ## 0.2.1 2023-08-21 74 | 75 | * [Plugin API add getOpenedTab method](https://github.com/siyuan-note/siyuan/issues/9002) 76 | * [Plugin API custom.fn => custom.id in openTab](https://github.com/siyuan-note/siyuan/issues/8944) 77 | 78 | ## 0.2.0 2023-08-15 79 | 80 | * [Add plugin event bus `open-siyuan-url-plugin` and `open-siyuan-url-block`](https://github.com/siyuan-note/siyuan/pull/8927) 81 | 82 | 83 | ## 0.1.12 2023-08-01 84 | 85 | * Upgrade siyuan to 0.7.9 86 | 87 | ## 0.1.11 88 | 89 | * [Add `input-search` event bus to plugins](https://github.com/siyuan-note/siyuan/issues/8725) 90 | 91 | 92 | ## 0.1.10 93 | 94 | * [Add `bind this` example for eventBus in plugins](https://github.com/siyuan-note/siyuan/issues/8668) 95 | * [Add `open-menu-breadcrumbmore` event bus to plugins](https://github.com/siyuan-note/siyuan/issues/8666) 96 | 97 | ## 0.1.9 98 | 99 | * [Add `open-menu-xxx` event bus for plugins ](https://github.com/siyuan-note/siyuan/issues/8617) 100 | 101 | ## 0.1.8 102 | 103 | * [Add protyleSlash to the plugin](https://github.com/siyuan-note/siyuan/issues/8599) 104 | * [Add plugin API protyle](https://github.com/siyuan-note/siyuan/issues/8445) 105 | 106 | ## 0.1.7 107 | 108 | * [Support build js and json](https://github.com/siyuan-note/plugin-sample/pull/8) 109 | 110 | ## 0.1.6 111 | 112 | * add `fetchPost` example 113 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | AGPL 3.0 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Doctree Nested folder support for SiYuan 2 | 3 | This plugin is protected by AGLP 3.0 license 4 | 5 | - I know it's painful every time you open secondary or multi-level folders. This plugin will free you from that. 6 | - If you enable auto mode, it will detectyou document, and if it's empty and it has sub document, it will be treated as sub doc. 7 | - In the settings, fill in emojis. For example, if you enter "🗃️,📂,📁", all documents with these three emojis will be treated as sub-notebooks. You can click to expand them, and clicking won't enter the document (but your data won't be lost). 8 | - If you fill in a document ID in the settings, that document will be treated as a sub-notebook. You can click to expand it, and clicking won't enter the document (but your data won't be lost). 9 | 10 | # Credit 11 | ## Donator (Leave an issue here if you didn't meant to be anonymous) 12 | - Mr / Mrs. anonymous 13 | 14 | ## Contributous 15 | - [ioxenus](https://github.com/ioxenus) 16 | 17 | 18 | ## Contributors 19 | - [wilsons](https://ld246.com/member/wilsons) : Written SQL and found getTreeStat API in designing the fully automated mode. Thank you! 20 | - [OpaqueGlass](https://github.com/OpaqueGlass) : Although this plugin does not use OpaqueGlass’s exact code, I have more or less been inspired by OpaqueGlass’s [syplugin-doubleClickFileTree](https://github.com/OpaqueGlass/syplugin-doubleClickFileTree) project, as I read their code before writing this plugin. Thank you! 21 | -------------------------------------------------------------------------------- /README_zh_CN.md: -------------------------------------------------------------------------------- 1 | # 文档树多层文件夹 2 | ## 乞讨 3 | 这款插件是开源免费的,因此我需要您的鼓励。 4 | 如果您喜欢这款插件为您带来的功能,您可以考虑捐款给我,帮助我解决温饱问题:[捐款](https://pipe.b3log.org/blogs/zxkmm/articles/2025/02/08/1738993480704)。(捐款不会解锁更多功能) 5 | 如果您经济不宽裕,您可以考虑给[我的GitHub仓库](https://github.com/zxkmm/siyuan_doctree_fake_subfolder)点一下免费的星星鼓励我。 6 | 如果您认为您或者您认识的人需要我的技能,欢迎随时雇用我。 7 | 8 | 9 | ## 功能 10 | - 我知道你每次点开二级或多层文件夹很痛苦。这个插件将解放你。 11 | - 启用自动模式后,当你点击某个文档,如果它是空的,而且它有子文档,它会被当作是一个子笔记本,您就不需要使用点击箭头来处理它。 12 | - 设置项中填入emoji,例如你填入"🗃️,📂,📁"的话,所有的这三个emoji文档都会被认为是子笔记本,你可以点击展开,点击不会进入文档(但你的数据不会丢失) 13 | - 设置项中填入文档ID,则这个文档会被视为是子笔记本,你可以点击展开,点击不会进入文档(但你的数据不会丢失) 14 | 15 | # 致谢 16 | ## 捐助者 (如果您无意匿名,请发issue解除匿名) 17 | - 匿名 先生/女士 18 | ## 贡献者 19 | - [ioxenus](https://github.com/ioxenus) 20 | 21 | ## Contributors 22 | - [wilsons](https://ld246.com/member/wilsons) : 在设计全自动模式时候帮我写 SQL 和找到 getTreeStat API。谢谢! 23 | - [OpaqueGlass](https://github.com/OpaqueGlass) : 虽然这个插件没有使用OpaqueGlass的确切代码,但我或多或少从OpaqueGlass的[syplugin-doubleClickFileTree](https://github.com/OpaqueGlass/syplugin-doubleClickFileTree)项目中得到过灵感,因为我在写这个插件之前就读了ta的代码。谢谢! 24 | -------------------------------------------------------------------------------- /asset/action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zxkmm/siyuan_doctree_fake_subfolder/84953de5462183529abe70d782e9281e2ccef591/asset/action.png -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zxkmm/siyuan_doctree_fake_subfolder/84953de5462183529abe70d782e9281e2ccef591/icon.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "siyuan_doctree_fake_subfolder", 3 | "version": "1.0.4", 4 | "type": "module", 5 | "description": "Provide capabilities to create nested folders in doctree", 6 | "repository": "", 7 | "homepage": "", 8 | "author": "zxkmm", 9 | "license": "AGPL-3.0", 10 | "scripts": { 11 | "dev": "cross-env NODE_ENV=development VITE_SOURCEMAP=inline vite build --watch", 12 | "build": "cross-env NODE_ENV=production vite build", 13 | "make-link": "node --no-warnings ./scripts/make_dev_link.js", 14 | "make-link-win": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File ./scripts/elevate.ps1 -scriptPath ./scripts/make_dev_link.js", 15 | "update-version": "node --no-warnings ./scripts/update_version.js", 16 | "make-install": "vite build && node --no-warnings ./scripts/make_install.js" 17 | }, 18 | "devDependencies": { 19 | "@sveltejs/vite-plugin-svelte": "^3.1.0", 20 | "@tsconfig/svelte": "^4.0.1", 21 | "@types/node": "^20.3.0", 22 | "cross-env": "^7.0.3", 23 | "fast-glob": "^3.2.12", 24 | "glob": "^10.0.0", 25 | "js-yaml": "^4.1.0", 26 | "minimist": "^1.2.8", 27 | "rollup-plugin-livereload": "^2.0.5", 28 | "sass": "^1.63.3", 29 | "siyuan": "1.0.4", 30 | "svelte": "^4.2.19", 31 | "ts-node": "^10.9.1", 32 | "typescript": "^5.1.3", 33 | "vite": "^5.2.9", 34 | "vite-plugin-static-copy": "^1.0.2", 35 | "vite-plugin-zip-pack": "^1.0.5" 36 | }, 37 | "packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf" 38 | } 39 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "siyuan_doctree_fake_subfolder", 3 | "author": "zxkmm", 4 | "url": "https://github.com/zxkmm/siyuan_doctree_fake_subfolder", 5 | "version": "1.0.4", 6 | "minAppVersion": "3.0.12", 7 | "backends": [ 8 | "windows", 9 | "linux", 10 | "darwin", 11 | "ios", 12 | "android", 13 | "harmony", 14 | "docker" 15 | ], 16 | "frontends": [ 17 | "desktop", 18 | "mobile", 19 | "browser-desktop", 20 | "browser-mobile", 21 | "desktop-window" 22 | ], 23 | "displayName": { 24 | "en_US": "Doctree Fake Subfolder", 25 | "zh_CN": "多层笔记本" 26 | }, 27 | "description": { 28 | "en_US": "Provide capabilities to create nested folders in doctree", 29 | "zh_CN": "提供在思源文档树中创建多层笔记本的能力(点击子文档可展开但是不打开)" 30 | }, 31 | "readme": { 32 | "en_US": "README.md", 33 | "zh_CN": "README_zh_CN.md" 34 | }, 35 | "funding": { 36 | "custom": [ 37 | "" 38 | ] 39 | }, 40 | "keywords": [ 41 | "siyuan_doctree_fake_subfolder", 42 | "zxkmm", 43 | "文档树", 44 | "笔记本", 45 | "嵌套", 46 | "子文档", 47 | "展开" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zxkmm/siyuan_doctree_fake_subfolder/84953de5462183529abe70d782e9281e2ccef591/preview.png -------------------------------------------------------------------------------- /public/i18n/README.md: -------------------------------------------------------------------------------- 1 | 思源支持的 i18n 文件范围,可以在控制台 `siyuan.config.langs` 中查看。以下是目前(2024-10-24)支持的语言方案: 2 | 3 | The range of i18n files supported by SiYuan can be viewed in the console under `siyuan.config.langs`. Below are the language schemes currently supported as of now (October 24, 2024) : 4 | 5 | ```js 6 | >>> siyuan.config.langs.map( lang => lang.name) 7 | ['de_DE', 'en_US', 'es_ES', 'fr_FR', 'he_IL', 'it_IT', 'ja_JP', 'pl_PL', 'ru_RU', 'zh_CHT', 'zh_CN'] 8 | ``` 9 | 10 | 在插件开发中,默认使用 JSON 格式作为国际化(i18n)的载体文件。如果您更喜欢使用 YAML 语法,可以将 JSON 文件替换为 YAML 文件(例如 `en_US.yaml`),并在其中编写 i18n 文本。本模板提供了相关的 Vite 插件,可以在编译时自动将 YAML 文件转换为 JSON 文件(请参见 `/yaml-plugin.js`)。本 MD 文件 和 YAML 文件会在 `npm run build` 时自动从 `dist` 目录下删除,仅保留必要的 JSON 文件共插件系统使用。 11 | 12 | In plugin development, JSON format is used by default as the carrier file for internationalization (i18n). If you prefer to use YAML syntax, you can replace the JSON file with a YAML file (e.g., `en_US.yaml`) and write the i18n text within it. This template provides a related Vite plugin that can automatically convert YAML files to JSON files during the compilation process (see `/yaml-plugin.js`). This markdown file and YAML files will be automatically removed from the `dist` directory during `npm run build`, leaving only the necessary JSON files for plugin system to use. 13 | -------------------------------------------------------------------------------- /public/i18n/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | "consideredThisDocumentAsSubfolder": "Considered this document as subfolder:", 4 | "recoveredThisDocumentFromSubfolder": "Recovered this document from subfolder:", 5 | "enterNormalMode": "Enter normal mode", 6 | "enterCaptureMode": "Enter capture mode, click the item in document tree to add it to the subfolder list", 7 | "enterRevealMode": "Enter reveal mode, click the item in document tree to view it temporarily(but not recover to normal document)", 8 | "normalMode": "Document tree subfolder plugin: Normal mode", 9 | "captureMode": "Document tree subfolder plugin: Capture mode", 10 | "revealMode": "Document tree subfolder plugin: Reveal mode", 11 | "enableModeSwitchButtons": "Enable mode switch buttons", 12 | "enableModeSwitchButtonsDesc": "When enabled, mode switch buttons will be displayed at the top of the document tree, which can be used to switch between normal mode, capture mode and reveal mode", 13 | 14 | "enableUsingEmojiAsSubfolderIdentify": "Enable using emoji as subfolder identify", 15 | "enableUsingEmojiAsSubfolderIdentifyDesc": "When enabled, selected emoji will be used as subfolder identify, that those documents that contains these emoji will be treated as subfolder", 16 | "emojisThatShouldBeTreatedAsSubfolder": "Emojis that should be treated as subfolder", 17 | "emojisThatShouldBeTreatedAsSubfolderDesc": "seperate by comma", 18 | "enableUsingIdAsSubfolderIdentify": "Enable using id as subfolder identify", 19 | "enableUsingIdAsSubfolderIdentifyDesc": "When enabled, selected id will be used as subfolder identify, that those documents that contains these id will be treated as subfolder", 20 | "idsThatShouldBeTreatedAsSubfolder": "Ids that should be treated as subfolder", 21 | "idsThatShouldBeTreatedAsSubfolderDesc": "seperate by comma", 22 | "enableAutoMode": "Enable auto mode", 23 | "enableAutoModeDesc": "When enabled, the plugin will automatically determine if the document is empty, and if it is and also it has sub document(s), it will automatically treat the document as a subfolder. Please note: this mode is not 'capture mode', it will not add documents that meet the conditions to the 'subfolder' list, but will judge each time", 24 | "hintTitle":"About", 25 | "hintDesc":"siyuan_doctree_fake_subfolder
AGLP3.0 License
💻 @zxkmm", 26 | 27 | "beggingTitle": "🙏 Begging 🙏", 28 | "beggingDesc": "This plugin is open source and free, so I need your encouragement.
If you like the features this plugin brings you, you can consider donating to help me with basic living expenses 🍚: Donate. (Donations won't unlock additional features)
If you're not financially comfortable, you can encourage me by giving a free ⭐ star to my GitHub repository.
If you or someone you know needs my skills, feel free to hire me anytime." 29 | 30 | } 31 | -------------------------------------------------------------------------------- /public/i18n/zh_CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "consideredThisDocumentAsSubfolder": "已将此文档视为子文件夹:", 3 | "recoveredThisDocumentFromSubfolder": "已将此文档恢复为普通文档:", 4 | "enterNormalMode": "已进入普通模式", 5 | "enterCaptureMode": "已进入捕获模式,点击文档树的项目以把它们加入“视为子文件夹”列表", 6 | "enterRevealMode": "已进入揭露模式, 点击已被视为子文件夹的文档以临时查看(但不会恢复为普通文档)", 7 | "normalMode": "文档树子文档插件:普通模式", 8 | "captureMode": "文档树子文档插件:捕获模式", 9 | "revealMode": "文档树子文档插件:揭露模式", 10 | "enableModeSwitchButtons": "启用模式切换按钮", 11 | "enableModeSwitchButtonsDesc": "启用后,在文档树上方会显示模式切换按钮,可以方便切换普通模式/捕获模式/揭露模式", 12 | 13 | 14 | "enableUsingEmojiAsSubfolderIdentify": "启用使用 emoji 作为子文件夹标识", 15 | "enableUsingEmojiAsSubfolderIdentifyDesc": "启用后,那些使用这些 emoji 作为图标的文档将被视为子文件夹(子笔记本)", 16 | "emojisThatShouldBeTreatedAsSubfolder": "应该被视为子文件夹的文档的 emoji", 17 | "emojisThatShouldBeTreatedAsSubfolderDesc": "中英文逗号分割,空格和空行不影响", 18 | "enableUsingIdAsSubfolderIdentify": "启用使用 id 作为子文件夹标识", 19 | "enableUsingIdAsSubfolderIdentifyDesc": "启用后,那些使用这些 id 作为图标的文档将被视为子文件夹(子笔记本)", 20 | "idsThatShouldBeTreatedAsSubfolder": "应该被视为子文件夹的文档的 id", 21 | "idsThatShouldBeTreatedAsSubfolderDesc": "中英文逗号分割,空格和空行不影响", 22 | "enableAutoMode": "启用全自动模式", 23 | "enableAutoModeDesc": "启用后,插件会自动识别文档是否为空,如果为空而且它有子文档,则自动将文档视为子文件夹。请注意:此模式不是“捕获模式”, 不会把符合条件的文档加入“视为子文件夹”列表, 而是每次都进行判断", 24 | "hintTitle": "关于", 25 | 26 | 27 | "beggingTitle": "🙏乞讨🙏", 28 | "beggingDesc": "这款插件是开源免费的,因此我需要您的鼓励.
如果您喜欢这款插件为您带来的功能,您可以考虑捐款给我,帮助我解决温饱🍚问题:捐款。(捐款不会解锁更多功能)
如果您经济不宽裕,您可以考虑给我的GitHub仓库点一下免费的⭐星星鼓励我。
如果您认为您或者您认识的人需要我的技能,欢迎随时雇用我。", 29 | 30 | 31 | "hintDesc": "🔗 siyuan_doctree_fake_subfolder
AGLP3.0 协议开源
💻 @zxkmm" 32 | } -------------------------------------------------------------------------------- /scripts/.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | build 3 | dist 4 | *.exe 5 | *.spec 6 | -------------------------------------------------------------------------------- /scripts/elevate.ps1: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 by frostime. All Rights Reserved. 2 | # @Author : frostime 3 | # @Date : 2024-09-06 19:15:53 4 | # @FilePath : /scripts/elevate.ps1 5 | # @LastEditTime : 2024-09-06 19:39:13 6 | # @Description : Force to elevate the script to admin privilege. 7 | 8 | param ( 9 | [string]$scriptPath 10 | ) 11 | 12 | $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition 13 | $projectDir = Split-Path -Parent $scriptDir 14 | 15 | if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) { 16 | $args = "-NoProfile -ExecutionPolicy Bypass -File `"" + $MyInvocation.MyCommand.Path + "`" -scriptPath `"" + $scriptPath + "`"" 17 | Start-Process powershell.exe -Verb RunAs -ArgumentList $args -WorkingDirectory $projectDir 18 | exit 19 | } 20 | 21 | Set-Location -Path $projectDir 22 | & node $scriptPath 23 | 24 | pause 25 | -------------------------------------------------------------------------------- /scripts/make_dev_link.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2023-07-15 15:31:31 5 | * @FilePath : /scripts/make_dev_link.js 6 | * @LastEditTime : 2024-09-06 18:13:53 7 | * @Description : 8 | */ 9 | // make_dev_link.js 10 | import fs from 'fs'; 11 | import { log, error, getSiYuanDir, chooseTarget, getThisPluginName, makeSymbolicLink } from './utils.js'; 12 | 13 | let targetDir = '/home/zxkmm/Documents/siyuan_dev/data/plugins'; 14 | 15 | /** 16 | * 1. Get the parent directory to install the plugin 17 | */ 18 | log('>>> Try to visit constant "targetDir" in make_dev_link.js...'); 19 | if (targetDir === '') { 20 | log('>>> Constant "targetDir" is empty, try to get SiYuan directory automatically....'); 21 | let res = await getSiYuanDir(); 22 | 23 | if (!res || res.length === 0) { 24 | log('>>> Can not get SiYuan directory automatically, try to visit environment variable "SIYUAN_PLUGIN_DIR"....'); 25 | let env = process.env?.SIYUAN_PLUGIN_DIR; 26 | if (env) { 27 | targetDir = env; 28 | log(`\tGot target directory from environment variable "SIYUAN_PLUGIN_DIR": ${targetDir}`); 29 | } else { 30 | error('\tCan not get SiYuan directory from environment variable "SIYUAN_PLUGIN_DIR", failed!'); 31 | process.exit(1); 32 | } 33 | } else { 34 | targetDir = await chooseTarget(res); 35 | } 36 | 37 | log(`>>> Successfully got target directory: ${targetDir}`); 38 | } 39 | if (!fs.existsSync(targetDir)) { 40 | error(`Failed! Plugin directory not exists: "${targetDir}"`); 41 | error('Please set the plugin directory in scripts/make_dev_link.js'); 42 | process.exit(1); 43 | } 44 | 45 | /** 46 | * 2. The dev directory, which contains the compiled plugin code 47 | */ 48 | const devDir = `${process.cwd()}/dev`; 49 | if (!fs.existsSync(devDir)) { 50 | fs.mkdirSync(devDir); 51 | } 52 | 53 | 54 | /** 55 | * 3. The target directory to make symbolic link to dev directory 56 | */ 57 | const name = getThisPluginName(); 58 | if (name === null) { 59 | process.exit(1); 60 | } 61 | const targetPath = `${targetDir}/${name}`; 62 | 63 | /** 64 | * 4. Make symbolic link 65 | */ 66 | makeSymbolicLink(devDir, targetPath); 67 | -------------------------------------------------------------------------------- /scripts/make_install.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2024-03-28 20:03:59 5 | * @FilePath : /scripts/make_install.js 6 | * @LastEditTime : 2024-09-06 18:08:19 7 | * @Description : 8 | */ 9 | // make_install.js 10 | import fs from 'fs'; 11 | import { log, error, getSiYuanDir, chooseTarget, copyDirectory, getThisPluginName } from './utils.js'; 12 | 13 | let targetDir = ''; 14 | 15 | /** 16 | * 1. Get the parent directory to install the plugin 17 | */ 18 | log('>>> Try to visit constant "targetDir" in make_install.js...'); 19 | if (targetDir === '') { 20 | log('>>> Constant "targetDir" is empty, try to get SiYuan directory automatically....'); 21 | let res = await getSiYuanDir(); 22 | 23 | if (res === null || res === undefined || res.length === 0) { 24 | error('>>> Can not get SiYuan directory automatically'); 25 | process.exit(1); 26 | } else { 27 | targetDir = await chooseTarget(res); 28 | } 29 | log(`>>> Successfully got target directory: ${targetDir}`); 30 | } 31 | if (!fs.existsSync(targetDir)) { 32 | error(`Failed! Plugin directory not exists: "${targetDir}"`); 33 | error('Please set the plugin directory in scripts/make_install.js'); 34 | process.exit(1); 35 | } 36 | 37 | /** 38 | * 2. The dist directory, which contains the compiled plugin code 39 | */ 40 | const distDir = `${process.cwd()}/dist`; 41 | if (!fs.existsSync(distDir)) { 42 | fs.mkdirSync(distDir); 43 | } 44 | 45 | /** 46 | * 3. The target directory to install the plugin 47 | */ 48 | const name = getThisPluginName(); 49 | if (name === null) { 50 | process.exit(1); 51 | } 52 | const targetPath = `${targetDir}/${name}`; 53 | 54 | /** 55 | * 4. Copy the compiled plugin code to the target directory 56 | */ 57 | copyDirectory(distDir, targetPath); 58 | -------------------------------------------------------------------------------- /scripts/update_version.js: -------------------------------------------------------------------------------- 1 | // const fs = require('fs'); 2 | // const path = require('path'); 3 | // const readline = require('readline'); 4 | import fs from 'node:fs'; 5 | import path from 'node:path'; 6 | import readline from 'node:readline'; 7 | 8 | // Utility to read JSON file 9 | function readJsonFile(filePath) { 10 | return new Promise((resolve, reject) => { 11 | fs.readFile(filePath, 'utf8', (err, data) => { 12 | if (err) return reject(err); 13 | try { 14 | const jsonData = JSON.parse(data); 15 | resolve(jsonData); 16 | } catch (e) { 17 | reject(e); 18 | } 19 | }); 20 | }); 21 | } 22 | 23 | // Utility to write JSON file 24 | function writeJsonFile(filePath, jsonData) { 25 | return new Promise((resolve, reject) => { 26 | fs.writeFile(filePath, JSON.stringify(jsonData, null, 2), 'utf8', (err) => { 27 | if (err) return reject(err); 28 | resolve(); 29 | }); 30 | }); 31 | } 32 | 33 | // Utility to prompt the user for input 34 | function promptUser(query) { 35 | const rl = readline.createInterface({ 36 | input: process.stdin, 37 | output: process.stdout 38 | }); 39 | return new Promise((resolve) => rl.question(query, (answer) => { 40 | rl.close(); 41 | resolve(answer); 42 | })); 43 | } 44 | 45 | // Function to parse the version string 46 | function parseVersion(version) { 47 | const [major, minor, patch] = version.split('.').map(Number); 48 | return { major, minor, patch }; 49 | } 50 | 51 | // Function to auto-increment version parts 52 | function incrementVersion(version, type) { 53 | let { major, minor, patch } = parseVersion(version); 54 | 55 | switch (type) { 56 | case 'major': 57 | major++; 58 | minor = 0; 59 | patch = 0; 60 | break; 61 | case 'minor': 62 | minor++; 63 | patch = 0; 64 | break; 65 | case 'patch': 66 | patch++; 67 | break; 68 | default: 69 | break; 70 | } 71 | 72 | return `${major}.${minor}.${patch}`; 73 | } 74 | 75 | // Main script 76 | (async function () { 77 | try { 78 | const pluginJsonPath = path.join(process.cwd(), 'plugin.json'); 79 | const packageJsonPath = path.join(process.cwd(), 'package.json'); 80 | 81 | // Read both JSON files 82 | const pluginData = await readJsonFile(pluginJsonPath); 83 | const packageData = await readJsonFile(packageJsonPath); 84 | 85 | // Get the current version from both files (assuming both have the same version) 86 | const currentVersion = pluginData.version || packageData.version; 87 | console.log(`\n🌟 Current version: \x1b[36m${currentVersion}\x1b[0m\n`); 88 | 89 | // Calculate potential new versions for auto-update 90 | const newPatchVersion = incrementVersion(currentVersion, 'patch'); 91 | const newMinorVersion = incrementVersion(currentVersion, 'minor'); 92 | const newMajorVersion = incrementVersion(currentVersion, 'major'); 93 | 94 | // Prompt the user with formatted options 95 | console.log('🔄 How would you like to update the version?\n'); 96 | console.log(` 1️⃣ Auto update \x1b[33mpatch\x1b[0m version (new version: \x1b[32m${newPatchVersion}\x1b[0m)`); 97 | console.log(` 2️⃣ Auto update \x1b[33mminor\x1b[0m version (new version: \x1b[32m${newMinorVersion}\x1b[0m)`); 98 | console.log(` 3️⃣ Auto update \x1b[33mmajor\x1b[0m version (new version: \x1b[32m${newMajorVersion}\x1b[0m)`); 99 | console.log(` 4️⃣ Input version \x1b[33mmanually\x1b[0m`); 100 | // Press 0 to skip version update 101 | console.log(' 0️⃣ Quit without updating\n'); 102 | 103 | const updateChoice = await promptUser('👉 Please choose (1/2/3/4): '); 104 | 105 | let newVersion; 106 | 107 | switch (updateChoice.trim()) { 108 | case '1': 109 | newVersion = newPatchVersion; 110 | break; 111 | case '2': 112 | newVersion = newMinorVersion; 113 | break; 114 | case '3': 115 | newVersion = newMajorVersion; 116 | break; 117 | case '4': 118 | newVersion = await promptUser('✍️ Please enter the new version (in a.b.c format): '); 119 | break; 120 | case '0': 121 | console.log('\n🛑 Skipping version update.'); 122 | return; 123 | default: 124 | console.log('\n❌ Invalid option, no version update.'); 125 | return; 126 | } 127 | 128 | // Update the version in both plugin.json and package.json 129 | pluginData.version = newVersion; 130 | packageData.version = newVersion; 131 | 132 | // Write the updated JSON back to files 133 | await writeJsonFile(pluginJsonPath, pluginData); 134 | await writeJsonFile(packageJsonPath, packageData); 135 | 136 | console.log(`\n✅ Version successfully updated to: \x1b[32m${newVersion}\x1b[0m\n`); 137 | 138 | } catch (error) { 139 | console.error('❌ Error:', error); 140 | } 141 | })(); 142 | -------------------------------------------------------------------------------- /scripts/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2024-09-06 17:42:57 5 | * @FilePath : /scripts/utils.js 6 | * @LastEditTime : 2024-09-06 19:23:12 7 | * @Description : 8 | */ 9 | // common.js 10 | import fs from 'fs'; 11 | import path from 'node:path'; 12 | import http from 'node:http'; 13 | import readline from 'node:readline'; 14 | 15 | // Logging functions 16 | export const log = (info) => console.log(`\x1B[36m%s\x1B[0m`, info); 17 | export const error = (info) => console.log(`\x1B[31m%s\x1B[0m`, info); 18 | 19 | // HTTP POST headers 20 | export const POST_HEADER = { 21 | "Content-Type": "application/json", 22 | }; 23 | 24 | // Fetch function compatible with older Node.js versions 25 | export async function myfetch(url, options) { 26 | return new Promise((resolve, reject) => { 27 | let req = http.request(url, options, (res) => { 28 | let data = ''; 29 | res.on('data', (chunk) => { 30 | data += chunk; 31 | }); 32 | res.on('end', () => { 33 | resolve({ 34 | ok: true, 35 | status: res.statusCode, 36 | json: () => JSON.parse(data) 37 | }); 38 | }); 39 | }); 40 | req.on('error', (e) => { 41 | reject(e); 42 | }); 43 | req.end(); 44 | }); 45 | } 46 | 47 | /** 48 | * Fetch SiYuan workspaces from port 6806 49 | * @returns {Promise} 50 | */ 51 | export async function getSiYuanDir() { 52 | let url = 'http://127.0.0.1:6806/api/system/getWorkspaces'; 53 | let conf = {}; 54 | try { 55 | let response = await myfetch(url, { 56 | method: 'POST', 57 | headers: POST_HEADER 58 | }); 59 | if (response.ok) { 60 | conf = await response.json(); 61 | } else { 62 | error(`\tHTTP-Error: ${response.status}`); 63 | return null; 64 | } 65 | } catch (e) { 66 | error(`\tError: ${e}`); 67 | error("\tPlease make sure SiYuan is running!!!"); 68 | return null; 69 | } 70 | return conf?.data; // 保持原始返回值 71 | } 72 | 73 | /** 74 | * Choose target workspace 75 | * @param {{path: string}[]} workspaces 76 | * @returns {string} The path of the selected workspace 77 | */ 78 | export async function chooseTarget(workspaces) { 79 | let count = workspaces.length; 80 | log(`>>> Got ${count} SiYuan ${count > 1 ? 'workspaces' : 'workspace'}`); 81 | workspaces.forEach((workspace, i) => { 82 | log(`\t[${i}] ${workspace.path}`); 83 | }); 84 | 85 | if (count === 1) { 86 | return `${workspaces[0].path}/data/plugins`; 87 | } else { 88 | const rl = readline.createInterface({ 89 | input: process.stdin, 90 | output: process.stdout 91 | }); 92 | let index = await new Promise((resolve) => { 93 | rl.question(`\tPlease select a workspace[0-${count - 1}]: `, (answer) => { 94 | resolve(answer); 95 | }); 96 | }); 97 | rl.close(); 98 | return `${workspaces[index].path}/data/plugins`; 99 | } 100 | } 101 | 102 | /** 103 | * Check if two paths are the same 104 | * @param {string} path1 105 | * @param {string} path2 106 | * @returns {boolean} 107 | */ 108 | export function cmpPath(path1, path2) { 109 | path1 = path1.replace(/\\/g, '/'); 110 | path2 = path2.replace(/\\/g, '/'); 111 | if (path1[path1.length - 1] !== '/') { 112 | path1 += '/'; 113 | } 114 | if (path2[path2.length - 1] !== '/') { 115 | path2 += '/'; 116 | } 117 | return path1 === path2; 118 | } 119 | 120 | export function getThisPluginName() { 121 | if (!fs.existsSync('./plugin.json')) { 122 | process.chdir('../'); 123 | if (!fs.existsSync('./plugin.json')) { 124 | error('Failed! plugin.json not found'); 125 | return null; 126 | } 127 | } 128 | 129 | const plugin = JSON.parse(fs.readFileSync('./plugin.json', 'utf8')); 130 | const name = plugin?.name; 131 | if (!name) { 132 | error('Failed! Please set plugin name in plugin.json'); 133 | return null; 134 | } 135 | 136 | return name; 137 | } 138 | 139 | export function copyDirectory(srcDir, dstDir) { 140 | if (!fs.existsSync(dstDir)) { 141 | fs.mkdirSync(dstDir); 142 | log(`Created directory ${dstDir}`); 143 | } 144 | 145 | fs.readdirSync(srcDir, { withFileTypes: true }).forEach((file) => { 146 | const src = path.join(srcDir, file.name); 147 | const dst = path.join(dstDir, file.name); 148 | 149 | if (file.isDirectory()) { 150 | copyDirectory(src, dst); 151 | } else { 152 | fs.copyFileSync(src, dst); 153 | log(`Copied file: ${src} --> ${dst}`); 154 | } 155 | }); 156 | log(`All files copied!`); 157 | } 158 | 159 | 160 | export function makeSymbolicLink(srcPath, targetPath) { 161 | if (!fs.existsSync(targetPath)) { 162 | // fs.symlinkSync(srcPath, targetPath, 'junction'); 163 | //Go 1.23 no longer supports junctions as symlinks 164 | //Please refer to https://github.com/siyuan-note/siyuan/issues/12399 165 | fs.symlinkSync(srcPath, targetPath, 'dir'); 166 | log(`Done! Created symlink ${targetPath}`); 167 | return; 168 | } 169 | 170 | //Check the existed target path 171 | let isSymbol = fs.lstatSync(targetPath).isSymbolicLink(); 172 | if (!isSymbol) { 173 | error(`Failed! ${targetPath} already exists and is not a symbolic link`); 174 | return; 175 | } 176 | let existedPath = fs.readlinkSync(targetPath); 177 | if (cmpPath(existedPath, srcPath)) { 178 | log(`Good! ${targetPath} is already linked to ${srcPath}`); 179 | } else { 180 | error(`Error! Already exists symbolic link ${targetPath}\nBut it links to ${existedPath}`); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /siyuan_doctree_fake_subfolder.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zxkmm/siyuan_doctree_fake_subfolder/84953de5462183529abe70d782e9281e2ccef591/siyuan_doctree_fake_subfolder.zip -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2023 frostime. All rights reserved. 3 | * https://github.com/frostime/sy-plugin-template-vite 4 | * 5 | * See API Document in [API.md](https://github.com/siyuan-note/siyuan/blob/master/API.md) 6 | * API 文档见 [API_zh_CN.md](https://github.com/siyuan-note/siyuan/blob/master/API_zh_CN.md) 7 | */ 8 | 9 | import { fetchPost, fetchSyncPost, IWebSocketData } from "siyuan"; 10 | 11 | 12 | export async function request(url: string, data: any) { 13 | let response: IWebSocketData = await fetchSyncPost(url, data); 14 | let res = response.code === 0 ? response.data : null; 15 | return res; 16 | } 17 | 18 | 19 | // **************************************** Noteboook **************************************** 20 | 21 | 22 | export async function lsNotebooks(): Promise { 23 | let url = '/api/notebook/lsNotebooks'; 24 | return request(url, ''); 25 | } 26 | 27 | 28 | export async function openNotebook(notebook: NotebookId) { 29 | let url = '/api/notebook/openNotebook'; 30 | return request(url, { notebook: notebook }); 31 | } 32 | 33 | 34 | export async function closeNotebook(notebook: NotebookId) { 35 | let url = '/api/notebook/closeNotebook'; 36 | return request(url, { notebook: notebook }); 37 | } 38 | 39 | 40 | export async function renameNotebook(notebook: NotebookId, name: string) { 41 | let url = '/api/notebook/renameNotebook'; 42 | return request(url, { notebook: notebook, name: name }); 43 | } 44 | 45 | 46 | export async function createNotebook(name: string): Promise { 47 | let url = '/api/notebook/createNotebook'; 48 | return request(url, { name: name }); 49 | } 50 | 51 | 52 | export async function removeNotebook(notebook: NotebookId) { 53 | let url = '/api/notebook/removeNotebook'; 54 | return request(url, { notebook: notebook }); 55 | } 56 | 57 | 58 | export async function getNotebookConf(notebook: NotebookId): Promise { 59 | let data = { notebook: notebook }; 60 | let url = '/api/notebook/getNotebookConf'; 61 | return request(url, data); 62 | } 63 | 64 | 65 | export async function setNotebookConf(notebook: NotebookId, conf: NotebookConf): Promise { 66 | let data = { notebook: notebook, conf: conf }; 67 | let url = '/api/notebook/setNotebookConf'; 68 | return request(url, data); 69 | } 70 | 71 | 72 | // **************************************** File Tree **************************************** 73 | export async function createDocWithMd(notebook: NotebookId, path: string, markdown: string): Promise { 74 | let data = { 75 | notebook: notebook, 76 | path: path, 77 | markdown: markdown, 78 | }; 79 | let url = '/api/filetree/createDocWithMd'; 80 | return request(url, data); 81 | } 82 | 83 | 84 | export async function renameDoc(notebook: NotebookId, path: string, title: string): Promise { 85 | let data = { 86 | doc: notebook, 87 | path: path, 88 | title: title 89 | }; 90 | let url = '/api/filetree/renameDoc'; 91 | return request(url, data); 92 | } 93 | 94 | 95 | export async function removeDoc(notebook: NotebookId, path: string) { 96 | let data = { 97 | notebook: notebook, 98 | path: path, 99 | }; 100 | let url = '/api/filetree/removeDoc'; 101 | return request(url, data); 102 | } 103 | 104 | 105 | export async function moveDocs(fromPaths: string[], toNotebook: NotebookId, toPath: string) { 106 | let data = { 107 | fromPaths: fromPaths, 108 | toNotebook: toNotebook, 109 | toPath: toPath 110 | }; 111 | let url = '/api/filetree/moveDocs'; 112 | return request(url, data); 113 | } 114 | 115 | 116 | export async function getHPathByPath(notebook: NotebookId, path: string): Promise { 117 | let data = { 118 | notebook: notebook, 119 | path: path 120 | }; 121 | let url = '/api/filetree/getHPathByPath'; 122 | return request(url, data); 123 | } 124 | 125 | 126 | export async function getHPathByID(id: BlockId): Promise { 127 | let data = { 128 | id: id 129 | }; 130 | let url = '/api/filetree/getHPathByID'; 131 | return request(url, data); 132 | } 133 | 134 | 135 | export async function getIDsByHPath(notebook: NotebookId, path: string): Promise { 136 | let data = { 137 | notebook: notebook, 138 | path: path 139 | }; 140 | let url = '/api/filetree/getIDsByHPath'; 141 | return request(url, data); 142 | } 143 | 144 | // **************************************** Asset Files **************************************** 145 | 146 | export async function upload(assetsDirPath: string, files: any[]): Promise { 147 | let form = new FormData(); 148 | form.append('assetsDirPath', assetsDirPath); 149 | for (let file of files) { 150 | form.append('file[]', file); 151 | } 152 | let url = '/api/asset/upload'; 153 | return request(url, form); 154 | } 155 | 156 | // **************************************** Block **************************************** 157 | type DataType = "markdown" | "dom"; 158 | export async function insertBlock( 159 | dataType: DataType, data: string, 160 | nextID?: BlockId, previousID?: BlockId, parentID?: BlockId 161 | ): Promise { 162 | let payload = { 163 | dataType: dataType, 164 | data: data, 165 | nextID: nextID, 166 | previousID: previousID, 167 | parentID: parentID 168 | } 169 | let url = '/api/block/insertBlock'; 170 | return request(url, payload); 171 | } 172 | 173 | 174 | export async function prependBlock(dataType: DataType, data: string, parentID: BlockId | DocumentId): Promise { 175 | let payload = { 176 | dataType: dataType, 177 | data: data, 178 | parentID: parentID 179 | } 180 | let url = '/api/block/prependBlock'; 181 | return request(url, payload); 182 | } 183 | 184 | 185 | export async function appendBlock(dataType: DataType, data: string, parentID: BlockId | DocumentId): Promise { 186 | let payload = { 187 | dataType: dataType, 188 | data: data, 189 | parentID: parentID 190 | } 191 | let url = '/api/block/appendBlock'; 192 | return request(url, payload); 193 | } 194 | 195 | 196 | export async function updateBlock(dataType: DataType, data: string, id: BlockId): Promise { 197 | let payload = { 198 | dataType: dataType, 199 | data: data, 200 | id: id 201 | } 202 | let url = '/api/block/updateBlock'; 203 | return request(url, payload); 204 | } 205 | 206 | 207 | export async function deleteBlock(id: BlockId): Promise { 208 | let data = { 209 | id: id 210 | } 211 | let url = '/api/block/deleteBlock'; 212 | return request(url, data); 213 | } 214 | 215 | 216 | export async function moveBlock(id: BlockId, previousID?: PreviousID, parentID?: ParentID): Promise { 217 | let data = { 218 | id: id, 219 | previousID: previousID, 220 | parentID: parentID 221 | } 222 | let url = '/api/block/moveBlock'; 223 | return request(url, data); 224 | } 225 | 226 | 227 | export async function foldBlock(id: BlockId) { 228 | let data = { 229 | id: id 230 | } 231 | let url = '/api/block/foldBlock'; 232 | return request(url, data); 233 | } 234 | 235 | 236 | export async function unfoldBlock(id: BlockId) { 237 | let data = { 238 | id: id 239 | } 240 | let url = '/api/block/unfoldBlock'; 241 | return request(url, data); 242 | } 243 | 244 | 245 | export async function getBlockKramdown(id: BlockId): Promise { 246 | let data = { 247 | id: id 248 | } 249 | let url = '/api/block/getBlockKramdown'; 250 | return request(url, data); 251 | } 252 | 253 | 254 | export async function getChildBlocks(id: BlockId): Promise { 255 | let data = { 256 | id: id 257 | } 258 | let url = '/api/block/getChildBlocks'; 259 | return request(url, data); 260 | } 261 | 262 | export async function transferBlockRef(fromID: BlockId, toID: BlockId, refIDs: BlockId[]) { 263 | let data = { 264 | fromID: fromID, 265 | toID: toID, 266 | refIDs: refIDs 267 | } 268 | let url = '/api/block/transferBlockRef'; 269 | return request(url, data); 270 | } 271 | 272 | // **************************************** Attributes **************************************** 273 | export async function setBlockAttrs(id: BlockId, attrs: { [key: string]: string }) { 274 | let data = { 275 | id: id, 276 | attrs: attrs 277 | } 278 | let url = '/api/attr/setBlockAttrs'; 279 | return request(url, data); 280 | } 281 | 282 | 283 | export async function getBlockAttrs(id: BlockId): Promise<{ [key: string]: string }> { 284 | let data = { 285 | id: id 286 | } 287 | let url = '/api/attr/getBlockAttrs'; 288 | return request(url, data); 289 | } 290 | 291 | // **************************************** SQL **************************************** 292 | 293 | export async function sql(sql: string): Promise { 294 | let sqldata = { 295 | stmt: sql, 296 | }; 297 | let url = '/api/query/sql'; 298 | return request(url, sqldata); 299 | } 300 | 301 | export async function getBlockByID(blockId: string): Promise { 302 | let sqlScript = `select * from blocks where id ='${blockId}'`; 303 | let data = await sql(sqlScript); 304 | return data[0]; 305 | } 306 | 307 | // **************************************** Template **************************************** 308 | 309 | export async function render(id: DocumentId, path: string): Promise { 310 | let data = { 311 | id: id, 312 | path: path 313 | } 314 | let url = '/api/template/render'; 315 | return request(url, data); 316 | } 317 | 318 | 319 | export async function renderSprig(template: string): Promise { 320 | let url = '/api/template/renderSprig'; 321 | return request(url, { template: template }); 322 | } 323 | 324 | // **************************************** File **************************************** 325 | 326 | export async function getFile(path: string): Promise { 327 | let data = { 328 | path: path 329 | } 330 | let url = '/api/file/getFile'; 331 | return new Promise((resolve, _) => { 332 | fetchPost(url, data, (content: any) => { 333 | resolve(content) 334 | }); 335 | }); 336 | } 337 | 338 | 339 | /** 340 | * fetchPost will secretly convert data into json, this func merely return Blob 341 | * @param endpoint 342 | * @returns 343 | */ 344 | export const getFileBlob = async (path: string): Promise => { 345 | const endpoint = '/api/file/getFile' 346 | let response = await fetch(endpoint, { 347 | method: 'POST', 348 | body: JSON.stringify({ 349 | path: path 350 | }) 351 | }); 352 | if (!response.ok) { 353 | return null; 354 | } 355 | let data = await response.blob(); 356 | return data; 357 | } 358 | 359 | 360 | export async function putFile(path: string, isDir: boolean, file: any) { 361 | let form = new FormData(); 362 | form.append('path', path); 363 | form.append('isDir', isDir.toString()); 364 | // Copyright (c) 2023, terwer. 365 | // https://github.com/terwer/siyuan-plugin-importer/blob/v1.4.1/src/api/kernel-api.ts 366 | form.append('modTime', Math.floor(Date.now() / 1000).toString()); 367 | form.append('file', file); 368 | let url = '/api/file/putFile'; 369 | return request(url, form); 370 | } 371 | 372 | export async function removeFile(path: string) { 373 | let data = { 374 | path: path 375 | } 376 | let url = '/api/file/removeFile'; 377 | return request(url, data); 378 | } 379 | 380 | 381 | 382 | export async function readDir(path: string): Promise { 383 | let data = { 384 | path: path 385 | } 386 | let url = '/api/file/readDir'; 387 | return request(url, data); 388 | } 389 | 390 | 391 | // **************************************** Export **************************************** 392 | 393 | export async function exportMdContent(id: DocumentId): Promise { 394 | let data = { 395 | id: id 396 | } 397 | let url = '/api/export/exportMdContent'; 398 | return request(url, data); 399 | } 400 | 401 | export async function exportResources(paths: string[], name: string): Promise { 402 | let data = { 403 | paths: paths, 404 | name: name 405 | } 406 | let url = '/api/export/exportResources'; 407 | return request(url, data); 408 | } 409 | 410 | // **************************************** Convert **************************************** 411 | 412 | export type PandocArgs = string; 413 | export async function pandoc(args: PandocArgs[]) { 414 | let data = { 415 | args: args 416 | } 417 | let url = '/api/convert/pandoc'; 418 | return request(url, data); 419 | } 420 | 421 | // **************************************** Notification **************************************** 422 | 423 | // /api/notification/pushMsg 424 | // { 425 | // "msg": "test", 426 | // "timeout": 7000 427 | // } 428 | export async function pushMsg(msg: string, timeout: number = 7000) { 429 | let payload = { 430 | msg: msg, 431 | timeout: timeout 432 | }; 433 | let url = "/api/notification/pushMsg"; 434 | return request(url, payload); 435 | } 436 | 437 | export async function pushErrMsg(msg: string, timeout: number = 7000) { 438 | let payload = { 439 | msg: msg, 440 | timeout: timeout 441 | }; 442 | let url = "/api/notification/pushErrMsg"; 443 | return request(url, payload); 444 | } 445 | 446 | // **************************************** Network **************************************** 447 | export async function forwardProxy( 448 | url: string, method: string = 'GET', payload: any = {}, 449 | headers: any[] = [], timeout: number = 7000, contentType: string = "text/html" 450 | ): Promise { 451 | let data = { 452 | url: url, 453 | method: method, 454 | timeout: timeout, 455 | contentType: contentType, 456 | headers: headers, 457 | payload: payload 458 | } 459 | let url1 = '/api/network/forwardProxy'; 460 | return request(url1, data); 461 | } 462 | 463 | 464 | // **************************************** System **************************************** 465 | 466 | export async function bootProgress(): Promise { 467 | return request('/api/system/bootProgress', {}); 468 | } 469 | 470 | 471 | export async function version(): Promise { 472 | return request('/api/system/version', {}); 473 | } 474 | 475 | 476 | export async function currentTime(): Promise { 477 | return request('/api/system/currentTime', {}); 478 | } 479 | -------------------------------------------------------------------------------- /src/hello.svelte: -------------------------------------------------------------------------------- 1 | 9 | 45 | 46 |
47 |
appId:
48 |
49 |
${app?.appId}
50 |
51 |
52 |
API demo:
53 |
54 |
55 | System current time: {time} 56 |
57 |
58 |
59 |
Protyle demo: id = {blockID}
60 |
61 |
62 |
63 | 64 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | export function stringToSet(str: string): Set { 2 | if (!str) { 3 | return new Set(); 4 | } 5 | return new Set( 6 | str 7 | .split(/[,,]/) 8 | .map((item) => item.trim()) // remove space 9 | .filter((item) => item.length > 0) // remove empty string 10 | ); 11 | } -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zxkmm/siyuan_doctree_fake_subfolder/84953de5462183529abe70d782e9281e2ccef591/src/index.scss -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, getFrontend, getBackend, showMessage } from "siyuan"; 2 | import "@/index.scss"; 3 | import { request, sql } from "./api"; 4 | import { SettingUtils } from "./libs/setting-utils"; 5 | 6 | import { stringToSet } from "./helpers"; 7 | 8 | const STORAGE_NAME = "menu-config"; 9 | 10 | enum DocTreeFakeSubfolderMode { 11 | Normal = "normal", 12 | Capture = "capture", // click to add item into list 13 | Reveal = "reveal", // click to view the actual document 14 | } 15 | 16 | export default class SiyuanDoctreeFakeSubfolder extends Plugin { 17 | private settingUtils: SettingUtils; 18 | private treatAsSubfolderIdSet: Set; 19 | private treatAsSubfolderEmojiSet: Set; 20 | private mode: DocTreeFakeSubfolderMode = DocTreeFakeSubfolderMode.Normal; 21 | private to_normal_mode_count = 0; 22 | //^ this is because when user enter the app, it not should display the "go to -ed normal mode noti", 23 | //thus count and only display for 2nd times whatsoever 24 | private frontend: string; 25 | private backend: string; 26 | private isDesktop: boolean; 27 | private isPhone: boolean; 28 | private isTablet: boolean; 29 | 30 | 31 | /* 32 | * @description: if toggle button has fn__hidden class, it means there is no sub document 33 | * @return: has subfolder: true, no dubfolder: false 34 | */ 35 | private async isProvidedIdHasSubDocument(element: HTMLElement): Promise { 36 | const toggleElement = element.querySelector('.b3-list-item__toggle'); 37 | if (!toggleElement) { 38 | return false; 39 | } 40 | 41 | return !toggleElement.classList.contains('fn__hidden'); 42 | } 43 | 44 | 45 | /* 46 | * @description: return if the document is empty 47 | * @return: empty: true, not empty: false 48 | * 49 | * this APi were found by wilsons 50 | * Thanks! 51 | */ 52 | private async isProvidedIdIsEmptyDocument(id: string): Promise { 53 | let data = { 54 | id: id 55 | }; 56 | let url = '/api/block/getTreeStat'; 57 | const res = await request(url, data); 58 | console.log(res, "res"); 59 | return res.stat.runeCount === 0; 60 | } 61 | 62 | // unit test 63 | private async example() { 64 | const docId = "20250110144712-on18jor"; 65 | const isEmpty = await this.isProvidedIdIsEmptyDocument(docId); 66 | if (isEmpty) { 67 | console.log("empty doc"); 68 | } else { 69 | console.log("not empty doc"); 70 | } 71 | } 72 | 73 | ifProvidedIdInTreatAsSubfolderSet(id: string) { 74 | return this.treatAsSubfolderIdSet.has(id); 75 | } 76 | 77 | ifProvidedLiAreUsingUserDefinedIdentifyIcon(li: HTMLElement) { 78 | const iconElement = li.querySelector(".b3-list-item__icon"); 79 | if (!iconElement) { 80 | return false; 81 | } 82 | 83 | const iconText = iconElement.textContent; 84 | if (!iconText) { 85 | return false; 86 | } 87 | 88 | return this.treatAsSubfolderEmojiSet.has(iconText); 89 | } 90 | 91 | appendIdToTreatAsSubfolderSet(id: string) { 92 | this.treatAsSubfolderIdSet.add(id); 93 | } 94 | 95 | removeIdFromTreatAsSubfolderSet(id: string) { 96 | this.treatAsSubfolderIdSet.delete(id); 97 | } 98 | 99 | onClickDoctreeNode(nodeId: string) { 100 | // dom 101 | const element = document.querySelector(`li[data-node-id="${nodeId}"]`); 102 | if (!element) { 103 | console.warn( 104 | "did not found element, probably caused by theme or something" 105 | ); 106 | return; 107 | } 108 | 109 | // path 110 | const id = element.getAttribute("data-node-id"); 111 | if (!id) { 112 | console.warn( 113 | "node missing id attribute, probably caused by theme or something" 114 | ); 115 | return; 116 | } 117 | 118 | // // debug hint 119 | // if (this.if_provided_id_in_treat_as_subfolder_set(id)) { 120 | // console.log(`forbid open: ${id} (node id: ${nodeId})`); 121 | // } else { 122 | // console.log(`allow open: ${id} (node id: ${nodeId})`); 123 | // } 124 | } 125 | 126 | captureToSetUnsetTreatAsSubfolderSetting(nodeId: string) { 127 | // fetch setting 128 | const idsStr = this.settingUtils.get( 129 | "ids_that_should_be_treated_as_subfolder" 130 | ) as string; 131 | 132 | // into temp set 133 | const tempSet = stringToSet(idsStr); 134 | 135 | // worker 136 | if (tempSet.has(nodeId)) { 137 | // delete 138 | tempSet.delete(nodeId); 139 | showMessage( 140 | `${this.i18n.recoveredThisDocumentFromSubfolder} ${nodeId}`, 141 | 2000, 142 | "error" 143 | ); //not err, just prettier with this style 144 | } else { 145 | // add 146 | tempSet.add(nodeId); 147 | showMessage( 148 | `${this.i18n.consideredThisDocumentAsSubfolder} ${nodeId}`, 149 | 2000 150 | ); 151 | } 152 | 153 | // convery back 154 | const newIdsStr = Array.from(tempSet).join(","); 155 | this.settingUtils.set("ids_that_should_be_treated_as_subfolder", newIdsStr); 156 | this.settingUtils.save(); 157 | 158 | // only need to update local var cuz when next boot it will load from settings anyway 159 | this.treatAsSubfolderIdSet = tempSet; 160 | } 161 | 162 | private initListener() { 163 | console.log("init_listener"); 164 | // 等待 DOM 165 | setTimeout(() => { 166 | const elements = document.querySelectorAll(".b3-list--background"); 167 | if (elements.length === 0) { 168 | console.warn( 169 | "not found .b3-list--background element, probably caused by theme or something" 170 | ); 171 | return; 172 | } 173 | 174 | // NB: this lambda is aysnc 175 | const handleEvent = async (e: MouseEvent | TouchEvent) => { 176 | // this ev were added in later code and this is for checking 177 | if ((e as any).sf_openDoc) { 178 | return; 179 | } 180 | 181 | if (!e.target || !(e.target instanceof Element)) { 182 | console.warn( 183 | "event target is invalid, probably caused by theme or something" 184 | ); 185 | return; 186 | } 187 | 188 | const listItem = e.target.closest( 189 | 'li[data-type="navigation-file"]' 190 | ) as HTMLElement | null; 191 | if (!listItem || e.target.closest(".b3-list-item__action")) { 192 | return; // handle allow clicked emoji/more/etc 193 | } 194 | 195 | const nodeId = listItem.getAttribute("data-node-id"); 196 | const path = listItem.getAttribute("data-path"); 197 | 198 | try { 199 | const clickedToggle = e.target.closest(".b3-list-item__toggle"); 200 | const clickedIcon = e.target.closest(".b3-list-item__icon"); 201 | // TODO: this probably already not needed anymore, 202 | //cuz toggle were already protected previously and emoji also protected earlier, 203 | //but leave as is for now 204 | const isSpecialClick = !!(clickedToggle || clickedIcon); 205 | /* ^ cast to bool */ 206 | 207 | if (!nodeId || !this.mode) { 208 | return; 209 | } 210 | 211 | switch (this.mode) { 212 | case DocTreeFakeSubfolderMode.Normal: 213 | if (!isSpecialClick) { 214 | // cache settings in case if more chaotic 215 | const enableEmoji = this.settingUtils.get( 216 | "enable_using_emoji_as_subfolder_identify" 217 | ); 218 | const enableId = this.settingUtils.get( 219 | "enable_using_id_as_subfolder_identify" 220 | ); 221 | const enableAuto = this.settingUtils.get("enable_auto_mode"); 222 | 223 | // emoji and id in list 224 | const isByEmoji = 225 | enableEmoji && 226 | this.ifProvidedLiAreUsingUserDefinedIdentifyIcon(listItem); 227 | const isById = 228 | enableId && this.ifProvidedIdInTreatAsSubfolderSet(nodeId); 229 | 230 | if (isByEmoji || isById) { 231 | // Treat as folder 232 | e.preventDefault(); 233 | e.stopPropagation(); 234 | this.expandSubfolder(listItem); 235 | return false; // shouldn't waiste it of gone here 236 | } else { 237 | // empty check here 238 | e.preventDefault(); 239 | e.stopPropagation(); 240 | 241 | 242 | const isEmpty = await this.isProvidedIdIsEmptyDocument( 243 | nodeId 244 | ); 245 | const hasSubDocument = await this.isProvidedIdHasSubDocument( 246 | listItem 247 | ); 248 | console.log(isEmpty, hasSubDocument, "isEmpty, hasSubDocument"); 249 | //TODO: it still look up db table even if auto mode disabled. Currently need it and it's not that lagging. will fix it later 250 | if (isEmpty && hasSubDocument && enableAuto) { 251 | // empty 252 | this.expandSubfolder(listItem); 253 | return false; 254 | } else { 255 | // not empty 256 | const newEvent = new MouseEvent("click", { 257 | bubbles: true, 258 | cancelable: true, 259 | }); 260 | Object.defineProperty(newEvent, "sf_openDoc", { 261 | // add trigger ev to indicate if its a manual trigger 262 | value: true, 263 | }); 264 | listItem.dispatchEvent(newEvent); 265 | return false; 266 | } 267 | } 268 | } 269 | // toggle click: always fallthrough is good enough 270 | break; 271 | 272 | case DocTreeFakeSubfolderMode.Capture: 273 | if (!isSpecialClick) { 274 | // capture worker 275 | this.captureToSetUnsetTreatAsSubfolderSetting(nodeId); 276 | } 277 | break; 278 | 279 | case DocTreeFakeSubfolderMode.Reveal: 280 | break; 281 | } 282 | 283 | // fallback 284 | this.onClickDoctreeNode(nodeId); 285 | } catch (err) { 286 | console.error("error when handle document tree node click:", err); 287 | } 288 | }; 289 | 290 | let already_shown_the_incompatible_device_message = false; 291 | 292 | // TODO: this part were written by chatGPT, need to go back and check what exactly changed, but worked anyway 293 | // 监听事件时,不使用事件捕获阶段(第三个参数为 false 或省略) 294 | // 这样可以让思源自身的展开折叠逻辑正常执行 295 | elements.forEach((element) => { 296 | if (this.isDesktop) { 297 | element.addEventListener("click", handleEvent); 298 | element.addEventListener("touchend", handleEvent); 299 | } else if (this.isPhone || this.isTablet) { 300 | element.addEventListener("click", handleEvent); 301 | } else { 302 | if (!already_shown_the_incompatible_device_message) { 303 | showMessage( 304 | "文档树子文件夹插件:开发者没有为您的设备做准备,清将如下信息和你的设备型号反馈给开发者:" + 305 | this.frontend + 306 | " " + 307 | this.backend 308 | ); 309 | showMessage( 310 | "Document Tree Subfolder Plugin: Developer did not prepare for your device, please feedback the following information to the developer: " + 311 | this.frontend + 312 | " " + 313 | this.backend 314 | ); 315 | already_shown_the_incompatible_device_message = true; 316 | } 317 | } 318 | }); 319 | }, 100); 320 | } 321 | 322 | expandSubfolder(item: HTMLElement) { 323 | // console.log(item, "expand_subfolder"); 324 | if (!item) { 325 | console.warn("not found li item, probably caused by theme or something"); 326 | return; 327 | } 328 | 329 | // the toggle btn 330 | const toggleButton = item.querySelector(".b3-list-item__toggle"); 331 | if (!toggleButton) { 332 | console.warn( 333 | "arrow button missing. probably caused by theme or something" 334 | ); 335 | return; 336 | } 337 | 338 | // simulate click 339 | const clickEvent = new MouseEvent("click", { 340 | view: window, 341 | bubbles: true, 342 | cancelable: true, 343 | }); 344 | 345 | toggleButton.dispatchEvent(clickEvent); 346 | } 347 | 348 | async onload() { 349 | this.treatAsSubfolderIdSet = new Set(); 350 | this.treatAsSubfolderEmojiSet = new Set(); 351 | 352 | this.data[STORAGE_NAME] = { readonlyText: "Readonly" }; 353 | 354 | this.settingUtils = new SettingUtils({ 355 | plugin: this, 356 | name: STORAGE_NAME, 357 | }); 358 | this.settingUtils.addItem({ 359 | key: "begging", 360 | value: "", 361 | type: "hint", 362 | title: this.i18n.beggingTitle, 363 | description: this.i18n.beggingDesc, 364 | }); 365 | this.settingUtils.addItem({ 366 | key: "enable_auto_mode", 367 | value: true, 368 | type: "checkbox", 369 | title: this.i18n.enableAutoMode, 370 | description: this.i18n.enableAutoModeDesc, 371 | }); 372 | this.settingUtils.addItem({ 373 | key: "enable_using_emoji_as_subfolder_identify", 374 | value: true, 375 | type: "checkbox", 376 | title: this.i18n.enableUsingEmojiAsSubfolderIdentify, 377 | description: this.i18n.enableUsingEmojiAsSubfolderIdentifyDesc, 378 | }); 379 | this.settingUtils.addItem({ 380 | key: "emojies_that_should_be_treated_as_subfolder", 381 | value: "🗃️,📂,📁", 382 | type: "textarea", 383 | title: this.i18n.emojisThatShouldBeTreatedAsSubfolder, 384 | description: this.i18n.emojisThatShouldBeTreatedAsSubfolderDesc, 385 | }); 386 | this.settingUtils.addItem({ 387 | key: "enable_using_id_as_subfolder_identify", 388 | value: true, 389 | type: "checkbox", 390 | title: this.i18n.enableUsingIdAsSubfolderIdentify, 391 | description: this.i18n.enableUsingIdAsSubfolderIdentifyDesc, 392 | }); 393 | this.settingUtils.addItem({ 394 | key: "ids_that_should_be_treated_as_subfolder", 395 | value: "", 396 | type: "textarea", 397 | title: this.i18n.idsThatShouldBeTreatedAsSubfolder, 398 | description: this.i18n.idsThatShouldBeTreatedAsSubfolderDesc, 399 | }); 400 | this.settingUtils.addItem({ 401 | key: "enable_mode_switch_buttons", 402 | value: true, 403 | type: "checkbox", 404 | title: this.i18n.enableModeSwitchButtons, 405 | description: this.i18n.enableModeSwitchButtonsDesc, 406 | }); 407 | this.settingUtils.addItem({ 408 | key: "Hint", 409 | value: "", 410 | type: "hint", 411 | title: this.i18n.hintTitle, 412 | description: this.i18n.hintDesc, 413 | }); 414 | 415 | try { 416 | this.settingUtils.load(); 417 | } catch (error) { 418 | console.error( 419 | "Error loading settings storage, probably empty config json:", 420 | error 421 | ); 422 | } 423 | 424 | this.addIcons(` 425 | 426 | 427 | 428 | `); 429 | 430 | this.addIcons(` 431 | 432 | 433 | 434 | `); 435 | 436 | this.addIcons(` 437 | 438 | 439 | 440 | `); 441 | 442 | this.frontend = getFrontend(); 443 | this.backend = getBackend(); 444 | this.isPhone = 445 | this.frontend === "mobile" || this.frontend === "browser-mobile"; 446 | this.isTablet = 447 | ((this.frontend === "desktop" || this.frontend === "browser-desktop") && 448 | this.backend === "ios") || 449 | ((this.frontend === "desktop" || this.frontend === "browser-desktop") && 450 | this.backend === "android") || 451 | ((this.frontend === "desktop" || this.frontend === "browser-desktop") && 452 | this.backend === "docker"); 453 | this.isDesktop = 454 | (this.frontend === "desktop" || this.frontend === "browser-desktop") && 455 | this.backend != "ios" && 456 | this.backend != "android" && 457 | this.backend != "docker"; 458 | } 459 | 460 | private updateTopBarButtonStyles( 461 | activeMode: DocTreeFakeSubfolderMode, 462 | buttons: { 463 | normal: HTMLElement; 464 | capture: HTMLElement; 465 | reveal: HTMLElement; 466 | } 467 | ) { 468 | const setButtonStyle = (button: HTMLElement, isActive: boolean) => { 469 | button.style.backgroundColor = isActive 470 | ? "var(--b3-toolbar-color)" 471 | : "var(--b3-toolbar-background)"; 472 | button.style.color = isActive 473 | ? "var(--b3-toolbar-background)" 474 | : "var(--b3-toolbar-color)"; 475 | }; 476 | 477 | setButtonStyle( 478 | buttons.normal, 479 | activeMode === DocTreeFakeSubfolderMode.Normal 480 | ); 481 | setButtonStyle( 482 | buttons.capture, 483 | activeMode === DocTreeFakeSubfolderMode.Capture 484 | ); 485 | setButtonStyle( 486 | buttons.reveal, 487 | activeMode === DocTreeFakeSubfolderMode.Reveal 488 | ); 489 | } 490 | 491 | private switchMode( 492 | mode: DocTreeFakeSubfolderMode, 493 | buttons: { 494 | normal: HTMLElement; 495 | capture: HTMLElement; 496 | reveal: HTMLElement; 497 | } 498 | ) { 499 | this.to_normal_mode_count < 2 ? this.to_normal_mode_count++ : null; 500 | this.mode = mode; 501 | this.updateTopBarButtonStyles(mode, buttons); 502 | 503 | const messages = { 504 | [DocTreeFakeSubfolderMode.Normal]: { 505 | text: this.i18n.enterNormalMode, 506 | duration: 2000, 507 | }, 508 | [DocTreeFakeSubfolderMode.Capture]: { 509 | text: this.i18n.enterCaptureMode, 510 | duration: 8000, 511 | }, 512 | [DocTreeFakeSubfolderMode.Reveal]: { 513 | text: this.i18n.enterRevealMode, 514 | duration: 8000, 515 | }, 516 | }; 517 | 518 | const { text, duration } = messages[mode]; 519 | if (this.to_normal_mode_count >= 2) { 520 | showMessage(text, duration); 521 | } 522 | } 523 | 524 | onLayoutReady() { 525 | console.log(this.frontend, this.backend); 526 | console.log(this.isPhone, this.isTablet, this.isDesktop); 527 | this.initListener(); 528 | this.settingUtils.load(); 529 | 530 | // load emoji setting 531 | const emojisStr = this.settingUtils.get( 532 | "emojies_that_should_be_treated_as_subfolder" 533 | ) as string; 534 | this.treatAsSubfolderEmojiSet = stringToSet(emojisStr); 535 | 536 | // id 537 | const idsStr = this.settingUtils.get( 538 | "ids_that_should_be_treated_as_subfolder" 539 | ) as string; 540 | this.treatAsSubfolderIdSet = stringToSet(idsStr); 541 | 542 | if (this.settingUtils.get("enable_mode_switch_buttons")) { 543 | const buttons = { 544 | normal: this.addTopBar({ 545 | icon: "iconDoctreeFakeSubfolderNormalMode", 546 | title: this.i18n.normalMode, 547 | position: "left", 548 | callback: () => 549 | this.switchMode(DocTreeFakeSubfolderMode.Normal, buttons), 550 | }), 551 | capture: this.addTopBar({ 552 | icon: "iconDoctreeFakeSubfolderCaptureMode", 553 | title: this.i18n.captureMode, 554 | position: "left", 555 | callback: () => 556 | this.switchMode(DocTreeFakeSubfolderMode.Capture, buttons), 557 | }), 558 | reveal: this.addTopBar({ 559 | icon: "iconDoctreeFakeSubfolderRevealMode", 560 | title: this.i18n.revealMode, 561 | position: "left", 562 | callback: () => 563 | this.switchMode(DocTreeFakeSubfolderMode.Reveal, buttons), 564 | }), 565 | }; 566 | 567 | const ifShowCaptureModeButton = this.settingUtils.get("enable_auto_mode") && 568 | !this.settingUtils.get("enable_using_id_as_subfolder_identify"); 569 | 570 | if(ifShowCaptureModeButton){ 571 | buttons.capture.style.display = "none"; 572 | } 573 | 574 | // default to normal mode 575 | this.switchMode(DocTreeFakeSubfolderMode.Normal, buttons); 576 | } 577 | } 578 | 579 | async onunload() {} 580 | 581 | uninstall() {} 582 | } 583 | -------------------------------------------------------------------------------- /src/libs/components/Form/form-input.svelte: -------------------------------------------------------------------------------- 1 | 33 | 34 | {#if type === "checkbox"} 35 | 36 | 44 | {:else if type === "textinput"} 45 | 46 | 56 | {:else if type === "textarea"} 57 |
21 |
22 |
23 |
24 | 25 |
`, 26 | width: args.width ?? "520px", 27 | height: args.height 28 | }); 29 | const target: HTMLTextAreaElement = dialog.element.querySelector(".b3-dialog__content>div.ft__breakword>textarea"); 30 | const btnsElement = dialog.element.querySelectorAll(".b3-button"); 31 | btnsElement[0].addEventListener("click", () => { 32 | if (args?.cancel) { 33 | args.cancel(); 34 | } 35 | dialog.destroy(); 36 | }); 37 | btnsElement[1].addEventListener("click", () => { 38 | if (args?.confirm) { 39 | args.confirm(target.value); 40 | } 41 | dialog.destroy(); 42 | }); 43 | }; 44 | 45 | export const inputDialogSync = async (args: { 46 | title: string, placeholder?: string, defaultText?: string, 47 | width?: string, height?: string 48 | }) => { 49 | return new Promise((resolve) => { 50 | let newargs = { 51 | ...args, confirm: (text) => { 52 | resolve(text); 53 | }, cancel: () => { 54 | resolve(null); 55 | } 56 | }; 57 | inputDialog(newargs); 58 | }); 59 | } 60 | 61 | 62 | interface IConfirmDialogArgs { 63 | title: string; 64 | content: string | HTMLElement; 65 | confirm?: (ele?: HTMLElement) => void; 66 | cancel?: (ele?: HTMLElement) => void; 67 | width?: string; 68 | height?: string; 69 | } 70 | 71 | export const confirmDialog = (args: IConfirmDialogArgs) => { 72 | const { title, content, confirm, cancel, width, height } = args; 73 | 74 | const dialog = new Dialog({ 75 | title, 76 | content: `
77 |
78 |
79 |
80 |
81 |
82 | 83 |
`, 84 | width: width, 85 | height: height 86 | }); 87 | 88 | const target: HTMLElement = dialog.element.querySelector(".b3-dialog__content>div.ft__breakword"); 89 | if (typeof content === "string") { 90 | target.innerHTML = content; 91 | } else { 92 | target.appendChild(content); 93 | } 94 | 95 | const btnsElement = dialog.element.querySelectorAll(".b3-button"); 96 | btnsElement[0].addEventListener("click", () => { 97 | if (cancel) { 98 | cancel(target); 99 | } 100 | dialog.destroy(); 101 | }); 102 | btnsElement[1].addEventListener("click", () => { 103 | if (confirm) { 104 | confirm(target); 105 | } 106 | dialog.destroy(); 107 | }); 108 | }; 109 | 110 | 111 | export const confirmDialogSync = async (args: IConfirmDialogArgs) => { 112 | return new Promise((resolve) => { 113 | let newargs = { 114 | ...args, confirm: (ele: HTMLElement) => { 115 | resolve(ele); 116 | }, cancel: (ele: HTMLElement) => { 117 | resolve(ele); 118 | } 119 | }; 120 | confirmDialog(newargs); 121 | }); 122 | }; 123 | 124 | 125 | export const simpleDialog = (args: { 126 | title: string, ele: HTMLElement | DocumentFragment, 127 | width?: string, height?: string, 128 | callback?: () => void; 129 | }) => { 130 | const dialog = new Dialog({ 131 | title: args.title, 132 | content: `
`, 133 | width: args.width, 134 | height: args.height, 135 | destroyCallback: args.callback 136 | }); 137 | dialog.element.querySelector(".dialog-content").appendChild(args.ele); 138 | return { 139 | dialog, 140 | close: dialog.destroy.bind(dialog) 141 | }; 142 | } 143 | 144 | 145 | export const svelteDialog = (args: { 146 | title: string, constructor: (container: HTMLElement) => SvelteComponent, 147 | width?: string, height?: string, 148 | callback?: () => void; 149 | }) => { 150 | let container = document.createElement('div') 151 | container.style.display = 'contents'; 152 | let component = args.constructor(container); 153 | const { dialog, close } = simpleDialog({ 154 | ...args, ele: container, callback: () => { 155 | component.$destroy(); 156 | if (args.callback) args.callback(); 157 | } 158 | }); 159 | return { 160 | component, 161 | dialog, 162 | close 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/libs/index.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2024-04-19 18:30:12 5 | * @FilePath : /src/libs/index.d.ts 6 | * @LastEditTime : 2024-04-30 16:39:54 7 | * @Description : 8 | */ 9 | type TSettingItemType = "checkbox" | "select" | "textinput" | "textarea" | "number" | "slider" | "button" | "hint" | "custom"; 10 | 11 | interface ISettingItemCore { 12 | type: TSettingItemType; 13 | key: string; 14 | value: any; 15 | placeholder?: string; 16 | slider?: { 17 | min: number; 18 | max: number; 19 | step: number; 20 | }; 21 | options?: { [key: string | number]: string }; 22 | button?: { 23 | label: string; 24 | callback: () => void; 25 | } 26 | } 27 | 28 | interface ISettingItem extends ISettingItemCore { 29 | title: string; 30 | description: string; 31 | direction?: "row" | "column"; 32 | } 33 | 34 | 35 | //Interface for setting-utils 36 | interface ISettingUtilsItem extends ISettingItem { 37 | action?: { 38 | callback: () => void; 39 | } 40 | createElement?: (currentVal: any) => HTMLElement; 41 | getEleVal?: (ele: HTMLElement) => any; 42 | setEleVal?: (ele: HTMLElement, val: any) => void; 43 | } 44 | -------------------------------------------------------------------------------- /src/libs/promise-pool.ts: -------------------------------------------------------------------------------- 1 | export default class PromiseLimitPool { 2 | private maxConcurrent: number; 3 | private currentRunning = 0; 4 | private queue: (() => void)[] = []; 5 | private promises: Promise[] = []; 6 | 7 | constructor(maxConcurrent: number) { 8 | this.maxConcurrent = maxConcurrent; 9 | } 10 | 11 | add(fn: () => Promise): void { 12 | const promise = new Promise((resolve, reject) => { 13 | const run = async () => { 14 | try { 15 | this.currentRunning++; 16 | const result = await fn(); 17 | resolve(result); 18 | } catch (error) { 19 | reject(error); 20 | } finally { 21 | this.currentRunning--; 22 | this.next(); 23 | } 24 | }; 25 | 26 | if (this.currentRunning < this.maxConcurrent) { 27 | run(); 28 | } else { 29 | this.queue.push(run); 30 | } 31 | }); 32 | this.promises.push(promise); 33 | } 34 | 35 | async awaitAll(): Promise { 36 | return Promise.all(this.promises); 37 | } 38 | 39 | /** 40 | * Handles the next task in the queue. 41 | */ 42 | private next(): void { 43 | if (this.queue.length > 0 && this.currentRunning < this.maxConcurrent) { 44 | const nextRun = this.queue.shift()!; 45 | nextRun(); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/libs/setting-utils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2023-12-17 18:28:19 5 | * @FilePath : /src/libs/setting-utils.ts 6 | * @LastEditTime : 2024-05-01 17:44:16 7 | * @Description : 8 | */ 9 | 10 | import { Plugin, Setting } from 'siyuan'; 11 | 12 | 13 | /** 14 | * The default function to get the value of the element 15 | * @param type 16 | * @returns 17 | */ 18 | const createDefaultGetter = (type: TSettingItemType) => { 19 | let getter: (ele: HTMLElement) => any; 20 | switch (type) { 21 | case 'checkbox': 22 | getter = (ele: HTMLInputElement) => { 23 | return ele.checked; 24 | }; 25 | break; 26 | case 'select': 27 | case 'slider': 28 | case 'textinput': 29 | case 'textarea': 30 | getter = (ele: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) => { 31 | return ele.value; 32 | }; 33 | break; 34 | case 'number': 35 | getter = (ele: HTMLInputElement) => { 36 | return parseInt(ele.value); 37 | } 38 | break; 39 | default: 40 | getter = () => null; 41 | break; 42 | } 43 | return getter; 44 | } 45 | 46 | 47 | /** 48 | * The default function to set the value of the element 49 | * @param type 50 | * @returns 51 | */ 52 | const createDefaultSetter = (type: TSettingItemType) => { 53 | let setter: (ele: HTMLElement, value: any) => void; 54 | switch (type) { 55 | case 'checkbox': 56 | setter = (ele: HTMLInputElement, value: any) => { 57 | ele.checked = value; 58 | }; 59 | break; 60 | case 'select': 61 | case 'slider': 62 | case 'textinput': 63 | case 'textarea': 64 | case 'number': 65 | setter = (ele: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, value: any) => { 66 | ele.value = value; 67 | }; 68 | break; 69 | default: 70 | setter = () => {}; 71 | break; 72 | } 73 | return setter; 74 | 75 | } 76 | 77 | 78 | export class SettingUtils { 79 | plugin: Plugin; 80 | name: string; 81 | file: string; 82 | 83 | settings: Map = new Map(); 84 | elements: Map = new Map(); 85 | 86 | constructor(args: { 87 | plugin: Plugin, 88 | name?: string, 89 | callback?: (data: any) => void, 90 | width?: string, 91 | height?: string 92 | }) { 93 | this.name = args.name ?? 'settings'; 94 | this.plugin = args.plugin; 95 | this.file = this.name.endsWith('.json') ? this.name : `${this.name}.json`; 96 | this.plugin.setting = new Setting({ 97 | width: args.width, 98 | height: args.height, 99 | confirmCallback: () => { 100 | for (let key of this.settings.keys()) { 101 | this.updateValueFromElement(key); 102 | } 103 | let data = this.dump(); 104 | if (args.callback !== undefined) { 105 | args.callback(data); 106 | } 107 | this.plugin.data[this.name] = data; 108 | this.save(data); 109 | 110 | window.location.reload(); 111 | }, 112 | destroyCallback: () => { 113 | //Restore the original value 114 | for (let key of this.settings.keys()) { 115 | this.updateElementFromValue(key); 116 | } 117 | } 118 | }); 119 | } 120 | 121 | async load() { 122 | let data = await this.plugin.loadData(this.file); 123 | console.debug('Load config:', data); 124 | if (data) { 125 | for (let [key, item] of this.settings) { 126 | item.value = data?.[key] ?? item.value; 127 | } 128 | } 129 | this.plugin.data[this.name] = this.dump(); 130 | return data; 131 | } 132 | 133 | async save(data?: any) { 134 | data = data ?? this.dump(); 135 | await this.plugin.saveData(this.file, this.dump()); 136 | console.debug('Save config:', data); 137 | return data; 138 | } 139 | 140 | /** 141 | * read the data after saving 142 | * @param key key name 143 | * @returns setting item value 144 | */ 145 | get(key: string) { 146 | return this.settings.get(key)?.value; 147 | } 148 | 149 | /** 150 | * Set data to this.settings, 151 | * but do not save it to the configuration file 152 | * @param key key name 153 | * @param value value 154 | */ 155 | set(key: string, value: any) { 156 | let item = this.settings.get(key); 157 | if (item) { 158 | item.value = value; 159 | this.updateElementFromValue(key); 160 | } 161 | } 162 | 163 | /** 164 | * Set and save setting item value 165 | * If you want to set and save immediately you can use this method 166 | * @param key key name 167 | * @param value value 168 | */ 169 | async setAndSave(key: string, value: any) { 170 | let item = this.settings.get(key); 171 | if (item) { 172 | item.value = value; 173 | this.updateElementFromValue(key); 174 | await this.save(); 175 | } 176 | } 177 | 178 | /** 179 | * Read in the value of element instead of setting obj in real time 180 | * @param key key name 181 | * @param apply whether to apply the value to the setting object 182 | * if true, the value will be applied to the setting object 183 | * @returns value in html 184 | */ 185 | take(key: string, apply: boolean = false) { 186 | let item = this.settings.get(key); 187 | let element = this.elements.get(key) as any; 188 | if (!element) { 189 | return 190 | } 191 | if (apply) { 192 | this.updateValueFromElement(key); 193 | } 194 | return item.getEleVal(element); 195 | } 196 | 197 | /** 198 | * Read data from html and save it 199 | * @param key key name 200 | * @param value value 201 | * @return value in html 202 | */ 203 | async takeAndSave(key: string) { 204 | let value = this.take(key, true); 205 | await this.save(); 206 | return value; 207 | } 208 | 209 | /** 210 | * Disable setting item 211 | * @param key key name 212 | */ 213 | disable(key: string) { 214 | let element = this.elements.get(key) as any; 215 | if (element) { 216 | element.disabled = true; 217 | } 218 | } 219 | 220 | /** 221 | * Enable setting item 222 | * @param key key name 223 | */ 224 | enable(key: string) { 225 | let element = this.elements.get(key) as any; 226 | if (element) { 227 | element.disabled = false; 228 | } 229 | } 230 | 231 | /** 232 | * 将设置项目导出为 JSON 对象 233 | * @returns object 234 | */ 235 | dump(): Object { 236 | let data: any = {}; 237 | for (let [key, item] of this.settings) { 238 | if (item.type === 'button') continue; 239 | data[key] = item.value; 240 | } 241 | return data; 242 | } 243 | 244 | addItem(item: ISettingUtilsItem) { 245 | this.settings.set(item.key, item); 246 | const IsCustom = item.type === 'custom'; 247 | let error = IsCustom && (item.createElement === undefined || item.getEleVal === undefined || item.setEleVal === undefined); 248 | if (error) { 249 | console.error('The custom setting item must have createElement, getEleVal and setEleVal methods'); 250 | return; 251 | } 252 | 253 | if (item.getEleVal === undefined) { 254 | item.getEleVal = createDefaultGetter(item.type); 255 | } 256 | if (item.setEleVal === undefined) { 257 | item.setEleVal = createDefaultSetter(item.type); 258 | } 259 | 260 | if (item.createElement === undefined) { 261 | let itemElement = this.createDefaultElement(item); 262 | this.elements.set(item.key, itemElement); 263 | this.plugin.setting.addItem({ 264 | title: item.title, 265 | description: item?.description, 266 | direction: item?.direction, 267 | createActionElement: () => { 268 | this.updateElementFromValue(item.key); 269 | let element = this.getElement(item.key); 270 | return element; 271 | } 272 | }); 273 | } else { 274 | this.plugin.setting.addItem({ 275 | title: item.title, 276 | description: item?.description, 277 | direction: item?.direction, 278 | createActionElement: () => { 279 | let val = this.get(item.key); 280 | let element = item.createElement(val); 281 | this.elements.set(item.key, element); 282 | return element; 283 | } 284 | }); 285 | } 286 | } 287 | 288 | createDefaultElement(item: ISettingUtilsItem) { 289 | let itemElement: HTMLElement; 290 | //阻止思源内置的回车键确认 291 | const preventEnterConfirm = (e) => { 292 | if (e.key === 'Enter') { 293 | e.preventDefault(); 294 | e.stopImmediatePropagation(); 295 | } 296 | } 297 | switch (item.type) { 298 | case 'checkbox': 299 | let element: HTMLInputElement = document.createElement('input'); 300 | element.type = 'checkbox'; 301 | element.checked = item.value; 302 | element.className = "b3-switch fn__flex-center"; 303 | itemElement = element; 304 | element.onchange = item.action?.callback ?? (() => { }); 305 | break; 306 | case 'select': 307 | let selectElement: HTMLSelectElement = document.createElement('select'); 308 | selectElement.className = "b3-select fn__flex-center fn__size200"; 309 | let options = item?.options ?? {}; 310 | for (let val in options) { 311 | let optionElement = document.createElement('option'); 312 | let text = options[val]; 313 | optionElement.value = val; 314 | optionElement.text = text; 315 | selectElement.appendChild(optionElement); 316 | } 317 | selectElement.value = item.value; 318 | selectElement.onchange = item.action?.callback ?? (() => { }); 319 | itemElement = selectElement; 320 | break; 321 | case 'slider': 322 | let sliderElement: HTMLInputElement = document.createElement('input'); 323 | sliderElement.type = 'range'; 324 | sliderElement.className = 'b3-slider fn__size200 b3-tooltips b3-tooltips__n'; 325 | sliderElement.ariaLabel = item.value; 326 | sliderElement.min = item.slider?.min.toString() ?? '0'; 327 | sliderElement.max = item.slider?.max.toString() ?? '100'; 328 | sliderElement.step = item.slider?.step.toString() ?? '1'; 329 | sliderElement.value = item.value; 330 | sliderElement.onchange = () => { 331 | sliderElement.ariaLabel = sliderElement.value; 332 | item.action?.callback(); 333 | } 334 | itemElement = sliderElement; 335 | break; 336 | case 'textinput': 337 | let textInputElement: HTMLInputElement = document.createElement('input'); 338 | textInputElement.className = 'b3-text-field fn__flex-center fn__size200'; 339 | textInputElement.value = item.value; 340 | textInputElement.onchange = item.action?.callback ?? (() => { }); 341 | itemElement = textInputElement; 342 | textInputElement.addEventListener('keydown', preventEnterConfirm); 343 | break; 344 | case 'textarea': 345 | let textareaElement: HTMLTextAreaElement = document.createElement('textarea'); 346 | textareaElement.className = "b3-text-field fn__block"; 347 | textareaElement.value = item.value; 348 | textareaElement.onchange = item.action?.callback ?? (() => { }); 349 | itemElement = textareaElement; 350 | break; 351 | case 'number': 352 | let numberElement: HTMLInputElement = document.createElement('input'); 353 | numberElement.type = 'number'; 354 | numberElement.className = 'b3-text-field fn__flex-center fn__size200'; 355 | numberElement.value = item.value; 356 | itemElement = numberElement; 357 | numberElement.addEventListener('keydown', preventEnterConfirm); 358 | break; 359 | case 'button': 360 | let buttonElement: HTMLButtonElement = document.createElement('button'); 361 | buttonElement.className = "b3-button b3-button--outline fn__flex-center fn__size200"; 362 | buttonElement.innerText = item.button?.label ?? 'Button'; 363 | buttonElement.onclick = item.button?.callback ?? (() => { }); 364 | itemElement = buttonElement; 365 | break; 366 | case 'hint': 367 | let hintElement: HTMLElement = document.createElement('div'); 368 | hintElement.className = 'b3-label fn__flex-center'; 369 | itemElement = hintElement; 370 | break; 371 | } 372 | return itemElement; 373 | } 374 | 375 | /** 376 | * return the setting element 377 | * @param key key name 378 | * @returns element 379 | */ 380 | getElement(key: string) { 381 | // let item = this.settings.get(key); 382 | let element = this.elements.get(key) as any; 383 | return element; 384 | } 385 | 386 | private updateValueFromElement(key: string) { 387 | let item = this.settings.get(key); 388 | if (item.type === 'button') return; 389 | let element = this.elements.get(key) as any; 390 | item.value = item.getEleVal(element); 391 | } 392 | 393 | private updateElementFromValue(key: string) { 394 | let item = this.settings.get(key); 395 | if (item.type === 'button') return; 396 | let element = this.elements.get(key) as any; 397 | item.setEleVal(element, item.value); 398 | } 399 | } -------------------------------------------------------------------------------- /src/setting-example.svelte: -------------------------------------------------------------------------------- 1 | 90 | 91 |
92 |
    93 | {#each groups as group} 94 | 95 |
  • { 100 | focusGroup = group; 101 | }} 102 | on:keydown={() => {}} 103 | > 104 | {group} 105 |
  • 106 | {/each} 107 |
108 |
109 | { console.debug("Click:", detail.key); }} 115 | > 116 |
117 | 💡 This is our default settings. 118 |
119 |
120 | { console.debug("Click:", detail.key); }} 126 | > 127 | 128 |
129 |
130 | 131 | 139 | 140 | -------------------------------------------------------------------------------- /src/types/api.d.ts: -------------------------------------------------------------------------------- 1 | interface IResGetNotebookConf { 2 | box: string; 3 | conf: NotebookConf; 4 | name: string; 5 | } 6 | 7 | interface IReslsNotebooks { 8 | notebooks: Notebook[]; 9 | } 10 | 11 | interface IResUpload { 12 | errFiles: string[]; 13 | succMap: { [key: string]: string }; 14 | } 15 | 16 | interface IResdoOperations { 17 | doOperations: doOperation[]; 18 | undoOperations: doOperation[] | null; 19 | } 20 | 21 | interface IResGetBlockKramdown { 22 | id: BlockId; 23 | kramdown: string; 24 | } 25 | 26 | interface IResGetChildBlock { 27 | id: BlockId; 28 | type: BlockType; 29 | subtype?: BlockSubType; 30 | } 31 | 32 | interface IResGetTemplates { 33 | content: string; 34 | path: string; 35 | } 36 | 37 | interface IResReadDir { 38 | isDir: boolean; 39 | isSymlink: boolean; 40 | name: string; 41 | } 42 | 43 | interface IResExportMdContent { 44 | hPath: string; 45 | content: string; 46 | } 47 | 48 | interface IResBootProgress { 49 | progress: number; 50 | details: string; 51 | } 52 | 53 | interface IResForwardProxy { 54 | body: string; 55 | contentType: string; 56 | elapsed: number; 57 | headers: { [key: string]: string }; 58 | status: number; 59 | url: string; 60 | } 61 | 62 | interface IResExportResources { 63 | path: string; 64 | } 65 | 66 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2023-08-15 10:28:10 5 | * @FilePath : /src/types/index.d.ts 6 | * @LastEditTime : 2024-06-08 20:50:53 7 | * @Description : Frequently used data structures in SiYuan 8 | */ 9 | 10 | 11 | type DocumentId = string; 12 | type BlockId = string; 13 | type NotebookId = string; 14 | type PreviousID = BlockId; 15 | type ParentID = BlockId | DocumentId; 16 | 17 | type Notebook = { 18 | id: NotebookId; 19 | name: string; 20 | icon: string; 21 | sort: number; 22 | closed: boolean; 23 | } 24 | 25 | type NotebookConf = { 26 | name: string; 27 | closed: boolean; 28 | refCreateSavePath: string; 29 | createDocNameTemplate: string; 30 | dailyNoteSavePath: string; 31 | dailyNoteTemplatePath: string; 32 | } 33 | 34 | type BlockType = 35 | | 'd' 36 | | 'p' 37 | | 'query_embed' 38 | | 'l' 39 | | 'i' 40 | | 'h' 41 | | 'iframe' 42 | | 'tb' 43 | | 'b' 44 | | 's' 45 | | 'c' 46 | | 'widget' 47 | | 't' 48 | | 'html' 49 | | 'm' 50 | | 'av' 51 | | 'audio'; 52 | 53 | 54 | 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"; 55 | 56 | type Block = { 57 | id: BlockId; 58 | parent_id?: BlockId; 59 | root_id: DocumentId; 60 | hash: string; 61 | box: string; 62 | path: string; 63 | hpath: string; 64 | name: string; 65 | alias: string; 66 | memo: string; 67 | tag: string; 68 | content: string; 69 | fcontent?: string; 70 | markdown: string; 71 | length: number; 72 | type: BlockType; 73 | subtype: BlockSubType; 74 | /** string of { [key: string]: string } 75 | * For instance: "{: custom-type=\"query-code\" id=\"20230613234017-zkw3pr0\" updated=\"20230613234509\"}" 76 | */ 77 | ial?: string; 78 | sort: number; 79 | created: string; 80 | updated: string; 81 | } 82 | 83 | type doOperation = { 84 | action: string; 85 | data: string; 86 | id: BlockId; 87 | parentID: BlockId | DocumentId; 88 | previousID: BlockId; 89 | retData: null; 90 | } 91 | 92 | interface Window { 93 | siyuan: { 94 | config: any; 95 | notebooks: any; 96 | menus: any; 97 | dialogs: any; 98 | blockPanels: any; 99 | storage: any; 100 | user: any; 101 | ws: any; 102 | languages: any; 103 | emojis: any; 104 | }; 105 | Lute: any; 106 | } 107 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2023-05-19 19:49:13 5 | * @FilePath : /svelte.config.js 6 | * @LastEditTime : 2024-04-19 19:01:55 7 | * @Description : 8 | */ 9 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte" 10 | 11 | const NoWarns = new Set([ 12 | "a11y-click-events-have-key-events", 13 | "a11y-no-static-element-interactions", 14 | "a11y-no-noninteractive-element-interactions" 15 | ]); 16 | 17 | export default { 18 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess 19 | // for more information about preprocessors 20 | preprocess: vitePreprocess(), 21 | onwarn: (warning, handler) => { 22 | // suppress warnings on `vite dev` and `vite build`; but even without this, things still work 23 | if (NoWarns.has(warning.code)) return; 24 | handler(warning); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": [ 7 | "ES2020", 8 | "DOM", 9 | "DOM.Iterable" 10 | ], 11 | "skipLibCheck": true, 12 | /* Bundler mode */ 13 | "moduleResolution": "Node", 14 | // "allowImportingTsExtensions": true, 15 | "allowSyntheticDefaultImports": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "preserve", 20 | /* Linting */ 21 | "strict": false, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | /* Svelte */ 26 | /** 27 | * Typecheck JS in `.svelte` and `.js` files by default. 28 | * Disable checkJs if you'd like to use dynamic types in JS. 29 | * Note that setting allowJs false does not prevent the use 30 | * of JS in `.svelte` files. 31 | */ 32 | "allowJs": true, 33 | "checkJs": true, 34 | "types": [ 35 | "node", 36 | "vite/client", 37 | "svelte" 38 | ], 39 | // "baseUrl": "./src", 40 | "paths": { 41 | "@/*": ["./src/*"], 42 | "@/libs/*": ["./src/libs/*"], 43 | } 44 | }, 45 | "include": [ 46 | "tools/**/*.ts", 47 | "src/**/*.ts", 48 | "src/**/*.d.ts", 49 | "src/**/*.tsx", 50 | "src/**/*.vue", 51 | "src/**/*.svelte" 52 | ], 53 | "references": [ 54 | { 55 | "path": "./tsconfig.node.json" 56 | } 57 | ], 58 | "root": "." 59 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": [ 10 | "vite.config.ts" 11 | ] 12 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path" 2 | import { defineConfig, loadEnv } from "vite" 3 | import { viteStaticCopy } from "vite-plugin-static-copy" 4 | import livereload from "rollup-plugin-livereload" 5 | import { svelte } from "@sveltejs/vite-plugin-svelte" 6 | import zipPack from "vite-plugin-zip-pack"; 7 | import fg from 'fast-glob'; 8 | 9 | import vitePluginYamlI18n from './yaml-plugin'; 10 | 11 | const env = process.env; 12 | const isSrcmap = env.VITE_SOURCEMAP === 'inline'; 13 | const isDev = env.NODE_ENV === 'development'; 14 | 15 | const outputDir = isDev ? "dev" : "dist"; 16 | 17 | console.log("isDev=>", isDev); 18 | console.log("isSrcmap=>", isSrcmap); 19 | console.log("outputDir=>", outputDir); 20 | 21 | export default defineConfig({ 22 | resolve: { 23 | alias: { 24 | "@": resolve(__dirname, "src"), 25 | } 26 | }, 27 | 28 | plugins: [ 29 | svelte(), 30 | 31 | vitePluginYamlI18n({ 32 | inDir: 'public/i18n', 33 | outDir: `${outputDir}/i18n` 34 | }), 35 | 36 | viteStaticCopy({ 37 | targets: [ 38 | { src: "./README*.md", dest: "./" }, 39 | { src: "./plugin.json", dest: "./" }, 40 | { src: "./preview.png", dest: "./" }, 41 | { src: "./icon.png", dest: "./" } 42 | ], 43 | }), 44 | 45 | ], 46 | 47 | define: { 48 | "process.env.DEV_MODE": JSON.stringify(isDev), 49 | "process.env.NODE_ENV": JSON.stringify(env.NODE_ENV) 50 | }, 51 | 52 | build: { 53 | outDir: outputDir, 54 | emptyOutDir: false, 55 | minify: true, 56 | sourcemap: isSrcmap ? 'inline' : false, 57 | 58 | lib: { 59 | entry: resolve(__dirname, "src/index.ts"), 60 | fileName: "index", 61 | formats: ["cjs"], 62 | }, 63 | rollupOptions: { 64 | plugins: [ 65 | ...(isDev ? [ 66 | livereload(outputDir), 67 | { 68 | name: 'watch-external', 69 | async buildStart() { 70 | const files = await fg([ 71 | 'public/i18n/**', 72 | './README*.md', 73 | './plugin.json' 74 | ]); 75 | for (let file of files) { 76 | this.addWatchFile(file); 77 | } 78 | } 79 | } 80 | ] : [ 81 | // Clean up unnecessary files under dist dir 82 | cleanupDistFiles({ 83 | patterns: ['i18n/*.yaml', 'i18n/*.md'], 84 | distDir: outputDir 85 | }), 86 | zipPack({ 87 | inDir: './dist', 88 | outDir: './', 89 | outFileName: 'package.zip' 90 | }) 91 | ]) 92 | ], 93 | 94 | external: ["siyuan", "process"], 95 | 96 | output: { 97 | entryFileNames: "[name].js", 98 | assetFileNames: (assetInfo) => { 99 | if (assetInfo.name === "style.css") { 100 | return "index.css" 101 | } 102 | return assetInfo.name 103 | }, 104 | }, 105 | }, 106 | } 107 | }); 108 | 109 | 110 | /** 111 | * Clean up some dist files after compiled 112 | * @author frostime 113 | * @param options: 114 | * @returns 115 | */ 116 | function cleanupDistFiles(options: { patterns: string[], distDir: string }) { 117 | const { 118 | patterns, 119 | distDir 120 | } = options; 121 | 122 | return { 123 | name: 'rollup-plugin-cleanup', 124 | enforce: 'post', 125 | writeBundle: { 126 | sequential: true, 127 | order: 'post' as 'post', 128 | async handler() { 129 | const fg = await import('fast-glob'); 130 | const fs = await import('fs'); 131 | // const path = await import('path'); 132 | 133 | // 使用 glob 语法,确保能匹配到文件 134 | const distPatterns = patterns.map(pat => `${distDir}/${pat}`); 135 | console.debug('Cleanup searching patterns:', distPatterns); 136 | 137 | const files = await fg.default(distPatterns, { 138 | dot: true, 139 | absolute: true, 140 | onlyFiles: false 141 | }); 142 | 143 | // console.info('Files to be cleaned up:', files); 144 | 145 | for (const file of files) { 146 | try { 147 | if (fs.default.existsSync(file)) { 148 | const stat = fs.default.statSync(file); 149 | if (stat.isDirectory()) { 150 | fs.default.rmSync(file, { recursive: true }); 151 | } else { 152 | fs.default.unlinkSync(file); 153 | } 154 | console.log(`Cleaned up: ${file}`); 155 | } 156 | } catch (error) { 157 | console.error(`Failed to clean up ${file}:`, error); 158 | } 159 | } 160 | } 161 | } 162 | }; 163 | } 164 | -------------------------------------------------------------------------------- /yaml-plugin.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2024-04-05 21:27:55 5 | * @FilePath : /yaml-plugin.js 6 | * @LastEditTime : 2024-04-05 22:53:34 7 | * @Description : 去妮玛的 json 格式,我就是要用 yaml 写 i18n 8 | */ 9 | // plugins/vite-plugin-parse-yaml.js 10 | import fs from 'fs'; 11 | import yaml from 'js-yaml'; 12 | import { resolve } from 'path'; 13 | 14 | export default function vitePluginYamlI18n(options = {}) { 15 | // Default options with a fallback 16 | const DefaultOptions = { 17 | inDir: 'src/i18n', 18 | outDir: 'dist/i18n', 19 | }; 20 | 21 | const finalOptions = { ...DefaultOptions, ...options }; 22 | 23 | return { 24 | name: 'vite-plugin-yaml-i18n', 25 | buildStart() { 26 | console.log('🌈 Parse I18n: YAML to JSON..'); 27 | const inDir = finalOptions.inDir; 28 | const outDir = finalOptions.outDir 29 | 30 | if (!fs.existsSync(outDir)) { 31 | fs.mkdirSync(outDir, { recursive: true }); 32 | } 33 | 34 | //Parse yaml file, output to json 35 | const files = fs.readdirSync(inDir); 36 | for (const file of files) { 37 | if (file.endsWith('.yaml') || file.endsWith('.yml')) { 38 | console.log(`-- Parsing ${file}`) 39 | //检查是否有同名的json文件 40 | const jsonFile = file.replace(/\.(yaml|yml)$/, '.json'); 41 | if (files.includes(jsonFile)) { 42 | console.log(`---- File ${jsonFile} already exists, skipping...`); 43 | continue; 44 | } 45 | try { 46 | const filePath = resolve(inDir, file); 47 | const fileContents = fs.readFileSync(filePath, 'utf8'); 48 | const parsed = yaml.load(fileContents); 49 | const jsonContent = JSON.stringify(parsed, null, 2); 50 | const outputFilePath = resolve(outDir, file.replace(/\.(yaml|yml)$/, '.json')); 51 | console.log(`---- Writing to ${outputFilePath}`); 52 | fs.writeFileSync(outputFilePath, jsonContent); 53 | } catch (error) { 54 | this.error(`---- Error parsing YAML file ${file}: ${error.message}`); 55 | } 56 | } 57 | } 58 | }, 59 | }; 60 | } 61 | --------------------------------------------------------------------------------