├── .eslintrc.cjs
├── .gitignore
├── .prettierignore
├── .prettierrc.cjs
├── CHANGELOG.md
├── LICENSE
├── README.md
├── README_zh_CN.md
├── icon.png
├── index.html
├── index.styl
├── package-lock.json
├── package.json
├── plugin.json
├── pnpm-lock.yaml
├── preview.png
├── scripts
├── .gitignore
├── README.md
├── make_dev_link.py
├── package.py
├── parse_changelog.py
├── scriptutils.py
└── version.py
├── src
├── Constants.ts
├── api
│ ├── base-api.ts
│ └── kernel-api.ts
├── i18n
│ ├── en_US.json
│ └── zh_CN.json
├── index.ts
├── libs
│ ├── IncrementalConfigPanel.svelte
│ ├── Loading.svelte
│ ├── MetricsPanel.svelte
│ ├── RandomDocContent.svelte
│ └── RandomDocSetting.svelte
├── models
│ ├── IncrementalConfig.ts
│ ├── IncrementalReadingConfig.ts
│ └── RandomDocConfig.ts
├── service
│ └── IncrementalReviewer.ts
├── topbar.ts
└── utils
│ ├── pageUtil.ts
│ ├── svg.ts
│ └── utils.ts
├── svelte.config.js
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: [
3 | "eslint:recommended",
4 | "plugin:@typescript-eslint/recommended",
5 | "plugin:svelte/recommended",
6 | "turbo",
7 | "prettier",
8 | ],
9 |
10 | parser: "@typescript-eslint/parser",
11 |
12 | overrides: [
13 | {
14 | files: ["*.svelte"],
15 | parser: "svelte-eslint-parser",
16 | // Parse the script in `.svelte` as TypeScript by adding the following configuration.
17 | parserOptions: {
18 | parser: "@typescript-eslint/parser",
19 | },
20 | },
21 | ],
22 |
23 | plugins: ["@typescript-eslint", "prettier"],
24 |
25 | rules: {
26 | // Note: you must disable the base rule as it can report incorrect errors
27 | semi: "off",
28 | quotes: "off",
29 | "no-undef": "off",
30 | "no-async-promise-executor": "off",
31 | "@typescript-eslint/no-empty-function": "off",
32 | "@typescript-eslint/no-var-requires": "off",
33 | "@typescript-eslint/no-this-alias": "off",
34 | "@typescript-eslint/no-non-null-assertion": "off",
35 | "@typescript-eslint/no-unused-vars": "off",
36 | "@typescript-eslint/no-explicit-any": "off",
37 | "turbo/no-undeclared-env-vars": "off",
38 | "prettier/prettier": "error",
39 | },
40 | }
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .DS_Store
3 | node_modules
4 | build
5 | dist
6 | __pycache__
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # platform
2 |
3 | # Ignore artifacts:
4 | dist
5 | node_modules
6 |
7 | # Ignore all dts files:
8 | *.d.ts
9 |
10 | # lib
11 | /pnpm-lock.yaml
12 |
--------------------------------------------------------------------------------
/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2025, ebAobS . All rights reserved.
3 | * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 | *
5 | * This code is free software; you can redistribute it and/or modify it
6 | * under the terms of the GNU General Public License version 2 only, as
7 | * published by the Free Software Foundation. ebAobS designates this
8 | * particular file as subject to the "Classpath" exception as provided
9 | * by ebAobS in the LICENSE file that accompanied this code.
10 | *
11 | * This code is distributed in the hope that it will be useful, but WITHOUT
12 | * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 | * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14 | * version 2 for more details (a copy is included in the LICENSE file that
15 | * accompanied this code).
16 | *
17 | * You should have received a copy of the GNU General Public License version
18 | * 2 along with this work; if not, write to the Free Software Foundation,
19 | * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20 | *
21 | * Please contact ebAobS, ebAobs@outlook.com
22 | * or visit https://github.com/ebAobS/roaming-mode-incremental-reading if you need additional information or have any
23 | * questions.
24 | */
25 |
26 | module.exports = {
27 | semi: false,
28 | singleQuote: false,
29 | printWidth: 120,
30 | plugins: ["prettier-plugin-svelte"]
31 | }
32 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [1.1.1] (2025-05-12)
4 |
5 | ### Bug Fixes
6 | * 修复了一遍过模式下显示剩余文档数量不正确的问题
7 | * 优化了自定义SQL模式下的剩余文档数量计算方式
8 |
9 | ## [1.1.0] (2025-05-08)
10 |
11 | ### Enhancement
12 | * 提高了机遇优先级的轮盘赌推荐算法稳定性
13 | * 增加了计算概率时的提示信息
14 | * 更改了设置页面,右键顶栏插件图标即可进入设置页面
15 | * 设定了所有文档指标默认值为5
16 | * 修改指标信息时增加了为所有文档更新的动作,确保指标值不为0
17 | * 查看文档指标信息时,出现为0或者空值的指标,自动修正为默认值5
18 | * 增加了漫游历史查看功能
19 |
20 | ## [1.0.1] (2025-05-07)
21 |
22 | ### Enhancement
23 | * 美化提示信息,增加诗意表达
24 | * 改进帮助文档链接,指向GitHub仓库中文文档
25 |
26 | ## [1.0.0] (2025-05-06)
27 |
28 | ### Features
29 | * First available version of Incremental Reading
30 | * Added user-defined article parameters and weights
31 | * Added priority calculation based on parameters
32 | * Implemented roulette wheel algorithm for document recommendation
33 | * Added support for notebook and root document filtering
34 | * Added support for completely random "one-pass" mode
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 ebAobS
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [中文](README_zh_CN.md)
2 |
3 | # Roaming Mode Incremental Reading
4 |
5 |
6 |
7 | The core principle of incremental reading is intelligent recommendation for "read later", not combating forgetting. Combating forgetting is the core of reviewing flashcards.
8 |
9 | ## Project Information
10 |
11 | - **Project Repository**: [https://github.com/ebAobS/roaming-mode-incremental-reading](https://github.com/ebAobS/roaming-mode-incremental-reading)
12 | - **Based on**: [siyuan-plugin-random-doc](https://github.com/terwer/siyuan-plugin-random-doc.git) (Author: [terwer](https://github.com/terwer))
13 |
14 | ## Preview
15 |
16 | 
17 |
18 | ## Core Philosophy
19 |
20 | Incremental reading should be distinguished from flashcards:
21 |
22 | - **Flashcards** review algorithm is based on **forgetting curves**, for which SiYuan's flashcard system with FSRS algorithm is already sufficient. Years of experience with Anki and SuperMemo teach us that the ["minimum information principle"](https://www.kancloud.cn/ankigaokao/incremental_learning/2454060#_30) is crucial.
23 | - **Incremental reading** deals with large texts that inherently don't meet the ["minimum information principle"](https://www.kancloud.cn/ankigaokao/incremental_learning/2454060#_30). Most incremental reading solutions instinctively use forgetting curve-based algorithms (like FSRS) to recommend articles for review, which I personally don't find appropriate.
24 |
25 | For a lengthy article, it's difficult to use **memory level alone** as the standard for whether it should be **recommended for review**. The real **standard should be multidimensional**, including difficulty, learning progress, importance of learning content, urgency, level of interest, etc.
26 |
27 | That's why I developed this plugin, allowing users to **customize article parameters** and their weights (e.g., article difficulty, weight 30%), and then calculate these indicators into an article's **priority**. Priority correlates positively with the probability of being recommended for review. Through a roulette wheel algorithm, based on priority, the next article is randomly recommended, thus achieving an informed roaming of articles. I believe this approach allows incremental reading to return to its essence — implementing pressure-free "read later" to efficiently learn large amounts of material.
28 |
29 | **In summary, the core purpose of incremental reading is not "combating forgetting," but implementing pressure-free "read later" to efficiently learn large amounts of material simultaneously.**
30 |
31 | Related reading: [Progressive Learning: SuperMemo's Algorithm Implementation for Progressive Reading](https://zhuanlan.zhihu.com/p/307996163)
32 |
33 | Following the principle of "Don't create entities unnecessarily", the "extracting" and "card-making" functions of incremental reading are not included in this plugin. Clicking the "edit" button allows opening the article you're incrementally learning in a new tab. From there, you can use many other plugins in the SiYuan marketplace to implement subsequent processes like "extracting" and "card-making".
34 |
35 | ## Features
36 |
37 | - User-defined article parameters and weights. Adjustable during incremental reading.
38 | - Priority calculated from parameters. Higher priority means higher recommendation probability. Document recommendation based on roulette wheel algorithm.
39 | - Support for notebook and root document filtering.
40 | - Support for no algorithm. Completely random "one-pass" mode.
41 |
42 | ## Usage Guide
43 |
44 | 1. **Install Plugin**: Search for "Roaming Mode Incremental Reading" in the SiYuan plugin market and install
45 | 2. **Set Parameters**: Right-click the plugin icon in the top bar to access the settings page, customize the indicators and weights you need.
46 | 3. **Start Incremental Reading**: Read and study the recommended articles, click the "edit" button to open in a new tab, and perform extraction, card-making and other operations.
47 | 4. **Adjust Parameters**: Use parameter adjustment instead of traditional incremental reading's "forget", "remember" buttons as feedback.
48 | 5. **Read Later**: When you're tired of this article, bored with reading, encounter difficulties and don't want to continue, whatever the reason, click "Continue Roaming" to intelligently recommend and read the next article.
49 |
50 | ## Improvement Plans
51 |
52 | The roulette wheel algorithm based on priority doesn't seem very intelligent. Is it possible to use methods like deep learning to improve the intelligence of recommendations? Welcome everyone to give suggestions and secondary development.
53 |
54 | ## Update History
55 |
56 | **v1.1.1 Update (2025.05.12)**
57 | - Fixed incorrect display of remaining document count in one-pass mode
58 | - Optimized calculation of remaining document count in custom SQL mode
59 |
60 | **v1.1.0 Update (2025.05.08)**
61 | - Improved stability of the priority-based roulette wheel recommendation algorithm
62 | - Added prompt information when calculating probability
63 | - Modified the settings page, right-click the top bar plugin icon to enter the settings page
64 | - Set the default value of all document indicators to 5
65 | - Added actions to update all documents when modifying indicator information to ensure indicator values are not 0
66 | - When viewing document indicator information, automatically corrects indicators with 0 or empty values to default value 5
67 | - Added roaming history viewing function
68 |
69 | **v1.0.1 Update (2025.05.07)**
70 | - Enhanced prompt messages with poetic expressions
71 | - Improved help documentation links to GitHub repository
72 |
73 | **v1.0.0 Major Update (2025.05.06)**
74 | - First available version
75 |
76 | For more update records, please check [CHANGELOG](https://github.com/ebAobS/roaming-mode-incremental-reading/blob/main/CHANGELOG.md)
77 |
78 | ## Acknowledgements
79 |
80 | - Special thanks to [terwer](https://github.com/terwer) for developing the original [siyuan-plugin-random-doc](https://github.com/terwer/siyuan-plugin-random-doc.git), on which this plugin is based
81 | - Thanks to [frostime](https://github.com/siyuan-note/plugin-sample-vite-svelte) for the project template
82 |
83 | ## Donate
84 |
85 | If this plugin is helpful to you, feel free to buy me a cup of coffee! Your support encourages me to keep updating and creating more useful tools!
86 |
87 |
88 |
89 |
90 |
91 | ## Contact
92 |
93 | Author Email: ebAobS@outlook.com
--------------------------------------------------------------------------------
/README_zh_CN.md:
--------------------------------------------------------------------------------
1 | [English](README.md)
2 |
3 | # 漫游式渐进阅读
4 |
5 | 
6 |
7 | 渐进阅读的核心要点,是智能推荐的稍后阅读,并不是对抗遗忘。制作闪卡后复习的核心才是对抗遗忘。
8 |
9 | ## 项目信息
10 |
11 | - **项目地址**:[https://github.com/ebAobS/roaming-mode-incremental-reading](https://github.com/ebAobS/roaming-mode-incremental-reading)
12 | - **基于项目**:[思源插件:文档漫游](https://github.com/terwer/siyuan-plugin-random-doc.git)(作者:[terwer](https://github.com/terwer))
13 |
14 | ## 核心理念
15 |
16 | 渐进阅读应该与闪卡区分开来:
17 |
18 | - **闪卡**复习算法原理是**遗忘曲线**,这方面思源闪卡系统+FSRS算法已经足够完善。多年使用Anki、SuperMemo的经验告诉我,卡片的["最小信息原则"](https://www.kancloud.cn/ankigaokao/incremental_learning/2454060#_30)极其重要。
19 | - **渐进阅读**面对的是大段文本,先天不符合["最小信息原则"](https://www.kancloud.cn/ankigaokao/incremental_learning/2454060#_30)。目前大多数渐进阅读解决方案惯性地使用遗忘曲线为原理的复习算法(如FSRS)来推荐复习文章,我个人认为这并不合理。
20 |
21 | 对于一篇洋洋洒洒的文章,很难**单纯的使用记忆程度**作为下次是否**被推荐复习的标准**。真正的**标准应该是多元**的,比如难度、学习进度、学习内容的重要程度、紧急程度、感兴趣程度等。
22 |
23 | 所以我开发此插件,让用户**自定义文章的参数指标**,及其权重(如文章难度,权重30%),再将指标综合计算为文章的**优先级**。优先级与被推荐复习的概率正相关。通过轮盘赌算法,根据优先级,随机推荐下一篇文章,从而实现有所依据地漫游文章。我认为这种方式能让渐进阅读回归本质 ---- 实现无压力的"稍后阅读",最终高效地学习大量材料。
24 |
25 | **总之,渐进阅读的核心目的并不是"对抗遗忘",而是实现无压力的"稍后阅读",最终高效地同时学习大量材料。**
26 |
27 | 相关阅读:[《渐进式学习_SuperMemo对渐进阅读的算法实现思路》](https://zhuanlan.zhihu.com/p/307996163)
28 |
29 | 本着"如无必要,勿增实体"原则,渐进阅读的"摘录"、"制卡"功能,本插件并不涉及。点击"编辑"按钮"可在新标签页打开您正在渐进学习的文章。在这里,利用思源集市中其他很多插件可以继续实现"摘录"、"制卡"等后续流程。
30 |
31 | ## 界面预览
32 |
33 | 
34 |
35 | ## 功能特点
36 |
37 | - 用户自定义文章指标参数、该参数权重。在渐进阅读时候可自行调整。
38 | - 由参数计算优先级。优先级越大,被推荐概率越大。基于轮盘赌算法推荐文档。
39 | - 支持笔记本和根文档筛选
40 | - 支持无任何算法。完全随机的"一遍过"模式。
41 |
42 | ## 使用方法
43 |
44 | 1. **安装插件**:在思源笔记插件市场中搜索"漫游式渐进阅读"并安装
45 | 2. **设置参数**:右键顶栏插件图标即可进入设置页面,自定义你所需要的指标及权重。
46 | 3. **开始渐进阅读**:针对推荐的文章进行阅读学习,点击"编辑"按钮可新标签页打开,并进行摘录、制卡等操作
47 | 4. **调整指标**:以调整指标数据来代替传统渐进阅读的"遗忘"、"记得"按钮来作为反馈。
48 | 5. **稍后阅读**:当你这篇文章学累了,读烦了,遇到困难不想继续了,whatever,点击"继续漫游",即可智能推荐并阅读下一篇文章。
49 |
50 | ## 改进计划
51 |
52 | 根据优先级的轮盘赌算法,好像并没有很智能。能否使用深度学习等方法,提高推荐的智能性?欢迎各位大佬提意见、二次开发。也欢迎讨论渐进学习相关经验。
53 |
54 | ## 更新历史
55 |
56 | **v1.1.1 更新(2025.05.12)**
57 | - 修复了一遍过模式下显示剩余文档数量不正确的问题
58 | - 优化了自定义SQL模式下的剩余文档数量计算方式
59 |
60 | **v1.1.0 更新(2025.05.08)**
61 | - 提高了基于优先级的轮盘赌推荐算法稳定性
62 | - 增加了计算概率时的提示信息
63 | - 更改了设置页面,右键顶栏插件图标即可进入设置页面
64 | - 设定了所有文档指标默认值为5
65 | - 修改指标信息时增加了为所有文档更新的动作,确保指标值不为0
66 | - 查看文档指标信息时,出现为0或者空值的指标,自动修正为默认值5
67 | - 增加了漫游历史查看功能
68 |
69 | **v1.0.1 更新(2025.05.07)**
70 | - 美化提示信息,增加诗意表达
71 | - 改进帮助文档链接,指向GitHub仓库中文文档
72 |
73 | **v1.0.0 主要更新(2025.05.06)**
74 | - 第一个可用版本
75 |
76 | 更多更新记录请查看 [CHANGELOG](https://github.com/ebAobS/roaming-mode-incremental-reading/blob/main/CHANGELOG.md)
77 |
78 | ## 感谢
79 |
80 | - 特别感谢 [terwer](https://github.com/terwer) 开发的原版[思源插件:文档漫游](https://github.com/terwer/siyuan-plugin-random-doc.git),本插件是在其基础上改进而来
81 | - 感谢 [frostime](https://github.com/siyuan-note/plugin-sample-vite-svelte) 提供的项目模板
82 |
83 | ## 乞讨
84 |
85 | 如果这个插件对您有所帮助,给我午餐加个鸡腿吧!谢谢老板!祝您吃嘛嘛香,干啥啥顺,钞票多到数不过来!顺风顺水顺财神!
86 |
87 |
88 |
89 |
90 |
91 | ## 联系方式
92 |
93 | 作者邮箱:ebAobS@outlook.com
--------------------------------------------------------------------------------
/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ebAobS/roaming-mode-incremental-reading/1ec49bc572a9ce8c1a599c713532ea86c25df253/icon.png
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Incremental Reading
7 |
8 |
9 | This file is for lib hot-load test only, see /src/index.ts
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/index.styl:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2025, ebAobS . All rights reserved.
3 | * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 | *
5 | * This code is free software; you can redistribute it and/or modify it
6 | * under the terms of the GNU General Public License version 2 only, as
7 | * published by the Free Software Foundation. ebAobS designates this
8 | * particular file as subject to the "Classpath" exception as provided
9 | * by ebAobS in the LICENSE file that accompanied this code.
10 | *
11 | * This code is distributed in the hope that it will be useful, but WITHOUT
12 | * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 | * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14 | * version 2 for more details (a copy is included in the LICENSE file that
15 | * accompanied this code).
16 | *
17 | * You should have received a copy of the GNU General Public License version
18 | * 2 along with this work; if not, write to the Free Software Foundation,
19 | * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20 | *
21 | * Please contact ebAobS, ebAobs@outlook.com
22 | * or visit https://github.com/ebAobS/roaming-mode-incremental-reading if you need additional information or have any
23 | * questions.
24 | */
25 |
26 | // 图标
27 | // 建议使用 iconfont ,可以调色,可以调整大小
28 | // https://fontawesome.com/search?q=yuque&o=r&m=free
29 | // https://www.iconfont.cn/search/index?searchType=icon&q=cnblogs&page=1&tag=&fromCollection=1&fills=
30 | .font-awesome-icon
31 | width 12px
32 | height 12px
33 | margin-right 10px
34 | margin-top 3px
35 |
36 | .iconfont-icon
37 | width 12px
38 | height 12px
39 | margin-right 10px
40 | margin-top 0
41 |
42 | .rnd-doc-custom-tips
43 | padding 10px 0
44 | .p
45 | padding 10px 8px
46 | border-radius 5px
47 | .t
48 | padding 8pxp 6px
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "roaming-mode-incremental-reading",
3 | "version": "1.1.1",
4 | "type": "module",
5 | "description": "Roaming mode incremental reading for SiYuan Note",
6 | "repository": "ebAobS/roaming-mode-incremental-reading",
7 | "homepage": "https://github.com/ebAobS/roaming-mode-incremental-reading",
8 | "author": "ebAobS",
9 | "license": "MIT",
10 | "scripts": {
11 | "serve": "vite",
12 | "makeLink": "python scripts/make_dev_link.py",
13 | "dev": "vite build --watch",
14 | "build": "vite build",
15 | "start": "vite preview",
16 | "test": "vitest --watch",
17 | "syncVersion": "python scripts/version.py",
18 | "parseChangelog": "python scripts/parse_changelog.py",
19 | "prepareRelease": "pnpm syncVersion && pnpm parseChangelog",
20 | "package": "python scripts/package.py"
21 | },
22 | "devDependencies": {
23 | "@sveltejs/vite-plugin-svelte": "^2.5.3",
24 | "@terwer/eslint-config-custom": "^1.3.6",
25 | "@tsconfig/svelte": "^5.0.4",
26 | "@types/minimist": "1.2.4",
27 | "fast-glob": "^3.3.2",
28 | "jsdom": "^22.1.0",
29 | "minimist": "^1.2.8",
30 | "rollup-plugin-livereload": "^2.0.5",
31 | "siyuan": "^0.8.9",
32 | "stylus": "^0.61.0",
33 | "svelte": "^4.2.19",
34 | "typescript": "5.6.2",
35 | "vite": "^5.4.11",
36 | "vite-plugin-dts": "^3.9.1",
37 | "vite-plugin-node-polyfills": "^0.16.0",
38 | "vite-plugin-static-copy": "^0.16.0",
39 | "vite-tsconfig-paths": "^4.3.2",
40 | "vitest": "^0.33.0"
41 | },
42 | "dependencies": {
43 | "zhi-common": "^1.33.0",
44 | "zhi-lib-base": "^0.8.0"
45 | },
46 | "packageManager": "pnpm@9.13.2"
47 | }
48 |
--------------------------------------------------------------------------------
/plugin.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "roaming-mode-incremental-reading",
3 | "author": "ebAobS",
4 | "url": "https://github.com/ebAobS/roaming-mode-incremental-reading",
5 | "version": "1.1.1",
6 | "minAppVersion": "2.9.0",
7 | "backends": [
8 | "windows",
9 | "linux",
10 | "darwin",
11 | "docker"
12 | ],
13 | "frontends": [
14 | "desktop",
15 | "browser-desktop"
16 | ],
17 | "displayName": {
18 | "en_US": "Roaming Mode Incremental Reading",
19 | "zh_CN": "漫游式渐进阅读"
20 | },
21 | "description": {
22 | "en_US": "The core of incremental reading is reading later on, rather than fighting against forgetting.",
23 | "zh_CN": "渐进阅读的核心是稍后阅读,而不是对抗遗忘。"
24 | },
25 | "readme": {
26 | "en_US": "README.md",
27 | "zh_CN": "README_zh_CN.md"
28 | },
29 | "funding": {
30 | "custom": [
31 | "https://cdn.jsdelivr.net/gh/ebAobS/pics@main/donate.png"
32 | ]
33 | }
34 | }
--------------------------------------------------------------------------------
/preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ebAobS/roaming-mode-incremental-reading/1ec49bc572a9ce8c1a599c713532ea86c25df253/preview.png
--------------------------------------------------------------------------------
/scripts/.gitignore:
--------------------------------------------------------------------------------
1 | .venv
2 | build
3 | dist
4 | *.exe
5 | *.spec
6 |
--------------------------------------------------------------------------------
/scripts/README.md:
--------------------------------------------------------------------------------
1 | # scripts
2 |
3 | ## Usage
4 |
5 | ```bash
6 | pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
7 | ```
--------------------------------------------------------------------------------
/scripts/make_dev_link.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2022-2025, ebAobS . All rights reserved.
2 | # @author ebAobS on 2025/05/06
3 |
4 | import json
5 | import os
6 | import sys
7 | import urllib.parse
8 | import urllib.request
9 |
10 | import scriptutils
11 |
12 | # ************************************ 在这里填写你的配置 ************************************
13 | # 请在这里写下你的 "workspace/data/plugins" 目录
14 | # 比如这样 targetDir = 'H:\\SiYuanDevSpace\\data\\plugins'
15 | targetDir = ''
16 | # dev 构建输出目录,相对于项目根路径
17 | devOutDir = 'dist'
18 | # ******************************************************************************************
19 |
20 | POST_HEADER = {
21 | "Authorization": f"Token ",
22 | "Content-Type": "application/json",
23 | }
24 |
25 |
26 | def get_siyuan_dir():
27 | url = 'http://127.0.0.1:6806/api/system/getWorkspaces'
28 | try:
29 | response = _myfetch(url, {
30 | 'method': 'POST',
31 | 'headers': POST_HEADER
32 | })
33 | if response['ok'] and 200 <= response['status'] < 300:
34 | data = response['data']
35 | conf = json.loads(data)
36 | else:
37 | _error(f'\tget_siyuan_dir HTTP-Error: {response["status"]}')
38 | return None
39 | except Exception as e:
40 | _error(f'\tError: {e}')
41 | _error('\tPlease make sure SiYuan is running!!!')
42 | return None
43 | return conf['data']
44 |
45 |
46 | def choose_target(workspaces):
47 | count = len(workspaces)
48 | _log(f'>>> Got {count} SiYuan {"workspaces" if count > 1 else "workspace"}')
49 | for i, workspace in enumerate(workspaces):
50 | _log(f'\t[{i}] {workspace["path"]}')
51 |
52 | if count == 1:
53 | return f'{workspaces[0]["path"]}/data/plugins'
54 | else:
55 | index = input(f'\tPlease select a workspace[0-{count - 1}]: ')
56 | return f'{workspaces[int(index)]["path"]}/data/plugins'
57 |
58 |
59 | def get_plugin_name():
60 | # 检查 plugin.json 是否存在
61 | if not os.path.exists('./plugin.json'):
62 | _error('失败!找不到 plugin.json')
63 | sys.exit(1)
64 | # 获取插件名称
65 | # 加载 plugin.json
66 | with open('./plugin.json', 'r') as file:
67 | plugin = json.load(file)
68 | plugin_name = plugin.get('name')
69 | if not plugin_name or plugin_name == '':
70 | _error('失败!请在 plugin.json 中设置插件名称')
71 | sys.exit(1)
72 | return plugin_name
73 |
74 |
75 | def make_link(target_dir, plugin_name):
76 | # dev 目录
77 | dev_dir = f'{os.getcwd()}/{devOutDir}'
78 | # 如果不存在则创建
79 | if not os.path.exists(dev_dir):
80 | os.makedirs(dev_dir)
81 |
82 | target_path = f'{target_dir}/{plugin_name}'
83 | # 如果已存在,则退出
84 | if os.path.exists(target_path):
85 | is_symbol = os.path.islink(target_path)
86 |
87 | if is_symbol:
88 | src_path = os.readlink(target_path)
89 |
90 | if _cmp_path(src_path, dev_dir):
91 | _log(f'Good! {target_path} 已经链接到 {dev_dir}')
92 | else:
93 | _error(f'Error! 符号链接 {target_path} 已存在\n但它链接到了 {src_path}')
94 | else:
95 | _error(f'失败!{target_path} 已经存在并且不是符号链接')
96 |
97 | else:
98 | # 创建符号链接
99 | os.symlink(dev_dir, target_path, target_is_directory=True)
100 | _log(f'Done! 创建符号链接 {target_path}')
101 |
102 |
103 | # =====================
104 | # private methods
105 | # =====================
106 | def _log(info):
107 | print(f'\033[36m{info}\033[0m')
108 |
109 |
110 | def _error(info):
111 | print(f'\033[31m{info}\033[0m')
112 |
113 |
114 | def _myfetch(url, options):
115 | method = options['method'].upper()
116 | headers = options['headers']
117 |
118 | if method == 'GET':
119 | query_params = options.get('params', {})
120 | query_string = urllib.parse.urlencode(query_params)
121 | full_url = f"{url}?{query_string}"
122 | req = urllib.request.Request(full_url, headers=headers)
123 | elif method == 'POST':
124 | data = options.get('data', {})
125 | encoded_data = urllib.parse.urlencode(data).encode('utf-8')
126 | req = urllib.request.Request(url, data=encoded_data, headers=headers)
127 | else:
128 | raise ValueError(f"Unsupported method: {method}")
129 |
130 | with urllib.request.urlopen(req) as response:
131 | data = response.read().decode('utf-8')
132 | status = response.status
133 |
134 | return {
135 | 'ok': True,
136 | 'status': status,
137 | 'data': data
138 | }
139 |
140 |
141 | def _cmp_path(path1, path2):
142 | path1 = path1.replace('\\', '/')
143 | path2 = path2.replace('\\', '/')
144 | # 尾部添加分隔符
145 | if path1[-1] != '/':
146 | path1 += '/'
147 | if path2[-1] != '/':
148 | path2 += '/'
149 | return path1 == path2
150 |
151 |
152 | if __name__ == "__main__":
153 | # 切换工作空间
154 | scriptutils.switch_workdir()
155 |
156 | # 获取当前路径
157 | cwd = scriptutils.get_workdir()
158 |
159 | # 获取插件目录
160 | _log('>>> 尝试访问 make_dev_link.js 中的常量 "targetDir"...')
161 | if targetDir == '':
162 | _log('>>> 常量 "targetDir" 为空,尝试自动获取 SiYuan 目录...')
163 | res = get_siyuan_dir()
164 | if res is None or len(res) == 0:
165 | _log('>>> 无法自动获取 SiYuan 目录,尝试访问环境变量 "SIYUAN_PLUGIN_DIR"...')
166 |
167 | env = os.getenv('SIYUAN_PLUGIN_DIR')
168 | if env is not None and env != '':
169 | targetDir = env
170 | _log(f'\t从环境变量 "SIYUAN_PLUGIN_DIR" 获取到目标目录: {targetDir}')
171 | else:
172 | _error('\t无法从环境变量 "SIYUAN_PLUGIN_DIR" 获取 SiYuan 目录,失败!')
173 | sys.exit(1)
174 | else:
175 | targetDir = choose_target(res)
176 | _log(f'>>> 成功获取到目标目录: {targetDir}')
177 | # 检查目录是否存在
178 | if not os.path.exists(targetDir):
179 | _error(f'失败!插件目录不存在: "{targetDir}"')
180 | _error('请在 scripts/make_dev_link.py 中设置插件目录')
181 | sys.exit(1)
182 |
183 | # 获取插件名称
184 | name = get_plugin_name()
185 | _log(f'>>> 成功获取到插件名称: {name}')
186 |
187 | # 生成软连接
188 | make_link(targetDir, name)
--------------------------------------------------------------------------------
/scripts/package.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import scriptutils
4 |
5 | if __name__ == "__main__":
6 | # 切换工作空间
7 | scriptutils.switch_workdir()
8 |
9 | # 获取当前工作空间
10 | cwd = scriptutils.get_workdir()
11 |
12 | dist_folder = "./dist"
13 | data = scriptutils.read_json_file(cwd + "package.json")
14 | v = data["version"]
15 |
16 | src_folder = dist_folder
17 | tmp_folder_name = "./roaming-mode-incremental-reading"
18 | build_zip_path = "./build"
19 | build_zip_name = "roaming-mode-incremental-reading-" + v + ".zip"
20 |
21 | try:
22 | # 压缩dist为zip
23 | scriptutils.zip_folder(src_folder, tmp_folder_name, build_zip_path, build_zip_name)
24 | scriptutils.cp_file(os.path.join(build_zip_path, build_zip_name), os.path.join(build_zip_path, "package.zip"))
25 | except Exception as e:
26 | print(f"打包错误,{str(e)}")
27 | print("插件打包完毕.")
28 |
--------------------------------------------------------------------------------
/scripts/parse_changelog.py:
--------------------------------------------------------------------------------
1 | import re
2 | import shutil
3 | from collections import defaultdict
4 |
5 |
6 | def parse_changelog():
7 | """
8 | :robot: A new release will be created
9 | ---
10 |
11 |
12 | ## 1.0.0 (2025-05-06)
13 |
14 |
15 | ### Features
16 |
17 | * First available version of Incremental Reading
18 | * Added user-defined article parameters and weights
19 | * Added priority calculation based on parameters
20 | * Implemented roulette wheel algorithm for document recommendation
21 | * Added support for notebook and root document filtering
22 | * Added support for completely random "one-pass" mode
23 | """
24 |
25 | # will print
26 |
27 | """
28 | :robot: a new release will be created
29 | ---
30 |
31 | ## 1.0.0 (2025-05-06)
32 | ### Features
33 | * First available version of Incremental Reading
34 | * Added user-defined article parameters and weights
35 | * Added priority calculation based on parameters
36 | * Implemented roulette wheel algorithm for document recommendation
37 | * Added support for notebook and root document filtering
38 | * Added support for completely random "one-pass" mode
39 | """
40 |
41 | # make a backup copy of the original file
42 | original_file = 'CHANGELOG.md'
43 | # backup_file = original_file.replace(".md", "_backup.md")
44 | # shutil.copyfile(original_file, backup_file)
45 |
46 | # handle repeat lines
47 | with open(original_file, 'r', encoding='utf-8') as f:
48 | lines = [line.strip() for line in f.readlines()]
49 | unique_commits = remove_same_commit(lines)
50 |
51 | # save new file
52 | save_file = original_file
53 | with open(save_file, 'w', encoding='utf-8') as f:
54 | f.write('\n'.join(unique_commits))
55 | print(f"comment parsed.saved to => {save_file}")
56 |
57 |
58 | def remove_same_commit(commit_list):
59 | commit_map = defaultdict()
60 | for line in commit_list:
61 | if '#' not in line:
62 | line = line.lower()
63 | # 先匹配常规的
64 | match = re.search(r'(?<=\*\s).*?(?=\()', line)
65 | if match:
66 | title = match.group(0).strip()
67 | commit_map[title] = line
68 | else:
69 | # 接下来匹配有模块的
70 | match2 = re.search(r'[*] [**](.*)[**] ([^:]+): (.*) \((.*)\)', line)
71 | if match2:
72 | message_title = match.group(3).strip()
73 | commit_map[message_title] = line
74 | else:
75 | # 最后处理剩下的
76 | commit_map[line] = line
77 |
78 | return commit_map.values()
79 |
80 |
81 | if __name__ == "__main__":
82 | parse_changelog()
--------------------------------------------------------------------------------
/scripts/scriptutils.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2025, ebAobS . All rights reserved.
2 | # DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
3 | #
4 | # This code is free software; you can redistribute it and/or modify it
5 | # under the terms of the GNU General Public License version 2 only, as
6 | # published by the Free Software Foundation. ebAobS designates this
7 | # particular file as subject to the "Classpath" exception as provided
8 | # by ebAobS in the LICENSE file that accompanied this code.
9 | #
10 | # This code is distributed in the hope that it will be useful, but WITHOUT
11 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
13 | # version 2 for more details (a copy is included in the LICENSE file that
14 | # accompanied this code).
15 | #
16 | # You should have received a copy of the GNU General Public License version
17 | # 2 along with this work; if not, write to the Free Software Foundation,
18 | # Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
19 | #
20 | # Please contact ebAobS, ebAobS@outlook.com
21 | # if you need additional information or have any questions.
22 |
23 | import distutils
24 | import glob
25 | import json
26 | import os
27 | import pathlib
28 | import shutil
29 | import sys
30 | import time
31 | import zipfile
32 | from distutils import dir_util
33 | from distutils import file_util
34 |
35 |
36 | def get_workdir():
37 | """
38 | 获取工作空间
39 | """
40 | cwd = "./"
41 | if os.getcwd().endswith("scripts"):
42 | cwd = "../"
43 |
44 | # 打印当前python版本
45 | print("当前python版本:" + sys.version)
46 | # 打印当前路径
47 | print("当前路径:" + os.path.abspath(cwd))
48 |
49 | return cwd
50 |
51 |
52 | def switch_workdir():
53 | """
54 | 切换工作空间
55 | """
56 | # 获取当前工作空间
57 | cwd = get_workdir()
58 |
59 | print("切换路径")
60 | os.chdir(cwd)
61 | print("当前路径:" + os.getcwd())
62 |
63 |
64 | def cp_file(f, t):
65 | """
66 | 拷贝文件
67 | :param f: 源路径
68 | :param t: 目的地
69 | """
70 | distutils.file_util.copy_file(f, t)
71 |
72 |
73 | def rm_file(filename):
74 | """
75 | 删除文件
76 | :param filename:文件名
77 | """
78 | if os.path.exists(filename):
79 | os.remove(filename)
80 |
81 |
82 | def mv_file(src, dst):
83 | """
84 | 移动文件
85 | :param src: 源文件
86 | :param dst: 目标文件
87 | """
88 | if os.path.exists(dst):
89 | rm_file(dst)
90 | if os.path.exists(src):
91 | file_util.move_file(src, dst)
92 |
93 |
94 | def rm_files(regex):
95 | """
96 | 正则删除文件
97 | :param regex: 正则
98 | """
99 | file_list = glob.glob(regex)
100 | for file in file_list:
101 | rm_file(file)
102 |
103 |
104 | def cp_folder(src, dst, remove_folder=False):
105 | """
106 | 拷贝文件夹
107 | :param src: 源文件夹,例如:"/path/to/source/folder"
108 | :param dst: 目的地,例如:"/path/to/destination/folder"
109 | :param remove_folder: 是否删除文件夹
110 | """
111 | if os.path.exists(dst) and remove_folder:
112 | rm_folder(dst)
113 |
114 | if not os.path.exists(dst):
115 | mkdir(dst)
116 |
117 | try:
118 | shutil.copytree(src, dst)
119 | except FileExistsError:
120 | # 如果目标文件夹已经存在,则删除它并重试
121 | shutil.rmtree(dst)
122 | shutil.copytree(src, dst)
123 | except Exception as e:
124 | print(f"无法拷贝文件夹,{e}")
125 | raise e
126 |
127 |
128 | def mkdir(dirname):
129 | """
130 | 创建目录
131 | :param dirname: 目录
132 | """
133 | if not os.path.exists(dirname):
134 | distutils.dir_util.mkpath(dirname)
135 |
136 |
137 | def rm_folder(folder):
138 | """
139 | 删除文件夹,它会递归的删除文件夹中的所有文件和子文件夹
140 | :param folder: 文件夹
141 | """
142 | if os.path.exists(folder):
143 | shutil.rmtree(folder)
144 |
145 |
146 | def read_json_file(filename):
147 | """
148 | 读取 JSON 文件
149 | :param filename: 文件名
150 | """
151 | # 读取 JSON 文件
152 | print("读取文件:" + os.path.abspath(filename))
153 | with open(filename, "r", encoding="utf-8") as f:
154 | data = json.load(f)
155 | return data
156 |
157 |
158 | def write_json_file(filename, data):
159 | """
160 | 写入 JSON 文件
161 | :param filename: 文件名
162 | :param data: JSON 数据
163 | """
164 | # 写入 JSON 文件
165 | with open(filename, "w", encoding="utf-8") as f:
166 | json.dump(data, f, indent=2, ensure_ascii=False)
167 |
168 |
169 | def zip_folder(src_folder, tmp_folder_name, build_zip_path, build_zip_name):
170 | """
171 | 压缩文件夹为zip
172 | :param src_folder: 需要压缩的文件所在的目录
173 | :param tmp_folder_name: 临时目录,也是解压后的默认目录
174 | :param build_zip_path: zip保存目录
175 | :param build_zip_name: zip文件名称
176 | """
177 | mkdir(tmp_folder_name)
178 | cp_folder(src_folder, tmp_folder_name)
179 |
180 | mkdir(build_zip_path)
181 | print("tmp_folder_name:" + tmp_folder_name)
182 | print("build_zip_path:" + build_zip_path)
183 | print("build_zip_name:" + build_zip_name)
184 |
185 | rm_file(build_zip_name)
186 | create_zip(tmp_folder_name, build_zip_name, [], build_zip_path)
187 | rm_folder(tmp_folder_name)
188 |
189 |
190 | def create_zip(root_path, file_name, ignored=[], storage_path=None):
191 | """Create a ZIP
192 |
193 | This function creates a ZIP file of the provided root path.
194 |
195 | Args:
196 | root_path (str): Root path to start from when picking files and directories.
197 | file_name (str): File name to save the created ZIP file as.
198 | ignored (list): A list of files and/or directories that you want to ignore. This
199 | selection is applied in root directory only.
200 | storage_path: If provided, ZIP file will be placed in this location. If None, the
201 | ZIP will be created in root_path
202 | """
203 | if storage_path is not None:
204 | zip_root = os.path.join(storage_path, file_name)
205 | else:
206 | zip_root = os.path.join(root_path, file_name)
207 |
208 | zipf = zipfile.ZipFile(zip_root, 'w', zipfile.ZIP_STORED)
209 |
210 | def iter_subtree(path, layer=0):
211 | # iter the directory
212 | path = pathlib.Path(path)
213 | for p in path.iterdir():
214 | if layer == 0 and p.name in ignored:
215 | continue
216 | zipf.write(p, str(p).replace(root_path, '').lstrip('/'))
217 |
218 | if p.is_dir():
219 | iter_subtree(p, layer=layer + 1)
220 |
221 | iter_subtree(root_path)
222 | zipf.close()
223 |
224 |
225 | def get_filename_from_time():
226 | """
227 | 根据时间命名文件
228 | :return: 根据时间生成的名称
229 | """
230 | # 获取当前的时间
231 | now_time = time.localtime()
232 | # 使用strftime函数把时间转换成想要的格式
233 | filename = time.strftime("%Y%m%d%H%M%S", now_time) # 输出结果为:20210126095555
234 | return filename
235 |
--------------------------------------------------------------------------------
/scripts/version.py:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2025, ebAobS . All rights reserved.
2 | # DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
3 | #
4 | # This code is free software; you can redistribute it and/or modify it
5 | # under the terms of the GNU General Public License version 2 only, as
6 | # published by the Free Software Foundation. ebAobS designates this
7 | # particular file as subject to the "Classpath" exception as provided
8 | # by ebAobS in the LICENSE file that accompanied this code.
9 | #
10 | # This code is distributed in the hope that it will be useful, but WITHOUT
11 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 | # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
13 | # version 2 for more details (a copy is included in the LICENSE file that
14 | # accompanied this code).
15 | #
16 | # You should have received a copy of the GNU General Public License version
17 | # 2 along with this work; if not, write to the Free Software Foundation,
18 | # Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
19 | #
20 | # Please contact ebAobS, ebAobS@outlook.com
21 | # if you need additional information or have any questions.
22 |
23 | import argparse
24 |
25 | import scriptutils
26 |
27 |
28 | def parse_json(filename, version_field, new_version):
29 | """
30 | 解析json文件,并修改版本号未指定的值
31 | :param filename: 文件路径
32 | :param version_field: 版本号字段
33 | :param new_version: 版本号
34 | """
35 |
36 | # 读取 JSON 文件
37 | data = scriptutils.read_json_file(filename)
38 |
39 | pkg = scriptutils.read_json_file(cwd + "package.json")
40 | print(f'new_version=>{new_version}')
41 | print(f'pkgv=>{pkg["version"]}')
42 | if new_version is None:
43 | new_version = pkg["version"]
44 |
45 | # 修改 JSON 文件中的属性
46 | if data[version_field] == new_version:
47 | print("版本号已经是最新,无需修改")
48 | return
49 | data[version_field] = new_version
50 |
51 | # 将修改后的 JSON 写回到文件中
52 | scriptutils.write_json_file(filename, data)
53 | print(f"修改 {filename} 完毕,新版本为:" + new_version)
54 |
55 |
56 | if __name__ == "__main__":
57 | # 获取当前工作空间
58 | cwd = scriptutils.get_workdir()
59 |
60 | # 参数解析
61 | parser = argparse.ArgumentParser()
62 | parser.add_argument("--version", help="the file to be processed")
63 | parser.add_argument("-v", "--verbose", action="store_true", help="enable verbose output")
64 | args = parser.parse_args()
65 |
66 | if args.verbose:
67 | print("Verbose mode enabled")
68 |
69 | # plugin.json
70 | parse_json(cwd + "plugin.json", "version", args.version)
--------------------------------------------------------------------------------
/src/Constants.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2023-2025, ebAobS . All rights reserved.
3 | * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 | *
5 | * This code is free software; you can redistribute it and/or modify it
6 | * under the terms of the GNU General Public License version 2 only, as
7 | * published by the Free Software Foundation. ebAobS designates this
8 | * particular file as subject to the "Classpath" exception as provided
9 | * by ebAobS in the LICENSE file that accompanied this code.
10 | *
11 | * This code is distributed in the hope that it will be useful, but WITHOUT
12 | * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 | * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14 | * version 2 for more details (a copy is included in the LICENSE file that
15 | * accompanied this code).
16 | *
17 | * You should have received a copy of the GNU General Public License version
18 | * 2 along with this work; if not, write to the Free Software Foundation,
19 | * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20 | */
21 |
22 | /**
23 | * ========================================
24 | * 漫游式渐进阅读插件 - 常量定义
25 | * ========================================
26 | *
27 | * 本文件定义了插件中使用的全局常量,包括环境配置、路径和存储键等,
28 | * 这些常量被整个插件共享使用。
29 | *
30 | * ## 文件结构
31 | * 1. 环境常量 - 定义运行环境相关常量
32 | * 2. 路径常量 - 定义文件路径和目录常量
33 | * 3. API相关常量 - 思源笔记API相关配置
34 | * 4. 存储常量 - 插件数据存储相关常量
35 | */
36 |
37 | /** 1. 环境常量 */
38 |
39 | /** 1.1 思源笔记工作空间目录 */
40 | export const workspaceDir = `${(window as any).siyuan.config.system.workspaceDir}`
41 |
42 | /** 1.2 思源笔记数据目录 */
43 | export const dataDir = `${(window as any).siyuan.config.system.dataDir}`
44 |
45 | /** 1.3 是否为开发模式 */
46 | export const isDev = process.env.DEV_MODE === "true"
47 |
48 | /** 2. API相关常量 */
49 |
50 | /** 2.1 思源笔记API地址 */
51 | export const siyuanApiUrl = ""
52 |
53 | /** 2.2 思源笔记API令牌 */
54 | export const siyuanApiToken = ""
55 |
56 | /** 3. 存储常量 */
57 |
58 | /** 3.1 主配置存储键名 */
59 | export const storeName = "roaming-mode-incremental-reading.json"
60 |
61 |
--------------------------------------------------------------------------------
/src/api/base-api.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2023, Terwer . All rights reserved.
3 | * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 | *
5 | * This code is free software; you can redistribute it and/or modify it
6 | * under the terms of the GNU General Public License version 2 only, as
7 | * published by the Free Software Foundation. Terwer designates this
8 | * particular file as subject to the "Classpath" exception as provided
9 | * by Terwer in the LICENSE file that accompanied this code.
10 | *
11 | * This code is distributed in the hope that it will be useful, but WITHOUT
12 | * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 | * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14 | * version 2 for more details (a copy is included in the LICENSE file that
15 | * accompanied this code).
16 | *
17 | * You should have received a copy of the GNU General Public License version
18 | * 2 along with this work; if not, write to the Free Software Foundation,
19 | * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20 | *
21 | * Please contact Terwer, Shenzhen, Guangdong, China, youweics@163.com
22 | * or visit www.terwer.space if you need additional information or have any
23 | * questions.
24 | */
25 |
26 | import { isDev, siyuanApiToken, siyuanApiUrl } from "../Constants"
27 | import { simpleLogger } from "zhi-lib-base"
28 |
29 | /**
30 | * 思源 API 返回类型
31 | */
32 | export interface SiyuanData {
33 | /**
34 | * 非 0 为异常情况
35 | */
36 | code: number
37 |
38 | /**
39 | * 正常情况下是空字符串,异常情况下会返回错误文案
40 | */
41 | msg: string
42 |
43 | /**
44 | * 可能为 \{\}、[] 或者 NULL,根据不同接口而不同
45 | */
46 | data: any[] | object | null | undefined
47 | }
48 |
49 | export class BaseApi {
50 | protected logger
51 |
52 | constructor() {
53 | this.logger = simpleLogger("base-api", "random-doc", isDev)
54 | }
55 |
56 | /**
57 | * 以sql发送请求
58 | * @param sql sql
59 | */
60 | public async sql(sql: string): Promise {
61 | const sqldata = {
62 | stmt: sql,
63 | }
64 | const url = "/api/query/sql"
65 | return await this.siyuanRequest(url, sqldata)
66 | }
67 |
68 | /**
69 | * 向思源请求数据
70 | *
71 | * @param url - url
72 | * @param data - 数据
73 | */
74 | public async siyuanRequest(url: string, data: object): Promise {
75 | const reqUrl = `${siyuanApiUrl}${url}`
76 |
77 | const fetchOps = {
78 | body: JSON.stringify(data),
79 | method: "POST",
80 | }
81 | if (siyuanApiToken !== "") {
82 | Object.assign(fetchOps, {
83 | headers: {
84 | Authorization: `Token ${siyuanApiToken}`,
85 | },
86 | })
87 | }
88 |
89 | if (isDev) {
90 | this.logger.debug("开始向思源请求数据,reqUrl=>", reqUrl)
91 | this.logger.debug("开始向思源请求数据,fetchOps=>", fetchOps)
92 | }
93 |
94 | const response = await fetch(reqUrl, fetchOps)
95 | const resJson = (await response.json()) as SiyuanData
96 | if (isDev) {
97 | this.logger.debug("思源请求数据返回,resJson=>", resJson)
98 | }
99 | return resJson
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/api/kernel-api.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2023, Terwer . All rights reserved.
3 | * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 | *
5 | * This code is free software; you can redistribute it and/or modify it
6 | * under the terms of the GNU General Public License version 2 only, as
7 | * published by the Free Software Foundation. Terwer designates this
8 | * particular file as subject to the "Classpath" exception as provided
9 | * by Terwer in the LICENSE file that accompanied this code.
10 | *
11 | * This code is distributed in the hope that it will be useful, but WITHOUT
12 | * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 | * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14 | * version 2 for more details (a copy is included in the LICENSE file that
15 | * accompanied this code).
16 | *
17 | * You should have received a copy of the GNU General Public License version
18 | * 2 along with this work; if not, write to the Free Software Foundation,
19 | * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20 | *
21 | * Please contact Terwer, Shenzhen, Guangdong, China, youweics@163.com
22 | * or visit www.terwer.space if you need additional information or have any
23 | * questions.
24 | */
25 |
26 | import { BaseApi, SiyuanData } from "./base-api"
27 | import { StrUtil } from "zhi-common"
28 |
29 | /**
30 | * 思源笔记服务端API v2.8.9
31 | *
32 | * @see {@link https://github.com/siyuan-note/siyuan/blob/master/API_zh_CN.md API}
33 | *
34 | * @author terwer
35 | * @version 0.0.1
36 | * @since 0.0.1
37 | */
38 | class KernelApi extends BaseApi {
39 | /**
40 | * 列出笔记本
41 | */
42 | public async lsNotebooks(): Promise {
43 | return await this.siyuanRequest("/api/notebook/lsNotebooks", {})
44 | }
45 |
46 | /**
47 | * 分页获取根文档
48 | *
49 | * @param keyword - 关键字
50 | * @deprecated
51 | */
52 | public async getRootBlocksCount(keyword?: string): Promise {
53 | const stmt = `SELECT COUNT(DISTINCT b.root_id) as count FROM blocks b`
54 | const data = (await this.sql(stmt)).data as any[]
55 | return data[0].count
56 | }
57 |
58 | /**
59 | * 以id获取思源块信息
60 | * @param blockId 块ID
61 | */
62 | public async getBlockByID(blockId: string): Promise {
63 | const stmt = `select *
64 | from blocks
65 | where id = '${blockId}'`
66 | const data = (await this.sql(stmt)).data as any[]
67 | if (!data || data.length === 0) {
68 | throw new Error("通过ID查询块信息失败")
69 | }
70 | return data[0]
71 | }
72 |
73 | /**
74 | * 获取随机文档
75 | *
76 | * @param notebookId
77 | */
78 | public async getRandomRootBlocks(notebookId?: string): Promise {
79 | const condition = StrUtil.isEmptyString(notebookId) ? "" : `and box = '${notebookId}'`
80 | const stmt = `SELECT DISTINCT b.root_id, b.content FROM blocks b
81 | WHERE 1=1 ${condition}
82 | ORDER BY random() LIMIT 1`
83 | this.logger.info("random sql =>", stmt)
84 | return await this.sql(stmt)
85 | }
86 |
87 | /**
88 | * 获取自定义SQL随机文档
89 | *
90 | * @param sql
91 | */
92 | public async getCustomRandomDocId(sql: string): Promise {
93 | this.logger.info("custom random sql =>", sql)
94 | return await this.sql(sql)
95 | }
96 |
97 | /**
98 | * 获取块属性
99 | */
100 | public async getBlockAttrs(blockId: string): Promise {
101 | return await this.siyuanRequest("/api/attr/getBlockAttrs", {
102 | id: blockId,
103 | })
104 | }
105 |
106 | /**
107 | * 设置块属性
108 | */
109 | public async setBlockAttrs(blockId: string, attrs: any): Promise {
110 | return await this.siyuanRequest("/api/attr/setBlockAttrs", {
111 | id: blockId,
112 | attrs: attrs,
113 | })
114 | }
115 |
116 | public async getDoc(docId: string): Promise {
117 | const params = {
118 | id: docId,
119 | isBacklink: false,
120 | mode: 0,
121 | size: 128,
122 | }
123 | const url = "/api/filetree/getDoc"
124 | return await this.siyuanRequest(url, params)
125 | }
126 | }
127 |
128 | export default KernelApi
129 |
--------------------------------------------------------------------------------
/src/i18n/en_US.json:
--------------------------------------------------------------------------------
1 | {
2 | "randomDoc": "Roaming Mode Incremental Reading",
3 | "setting": "Setting",
4 | "docFetchError": "Ahh, the network is out of business, try to refresh again~",
5 | "showLoading": "Show loading",
6 | "showLoadingTip": "Whether to display the loading icon when roaming through documents",
7 | "customSqlEnabled": "Enable Custom SQL",
8 | "customSqlEnabledTip": "Check this to use custom SQL for browsing, instead of predefined conditions",
9 | "cancel": "Cancel",
10 | "save": "Save",
11 | "ok": "Ok",
12 | "settingConfigSaveSuccess": "Configuration saved successfully",
13 | "sqlContent": "Custom SQL",
14 | "sqlContentTip": "Custom SQL, note that it must be a valid JSON array, for example: [{\"name\":\"默认\",\"sql\":\"sql1\"]",
15 | "customSqlEmpty": "Custom SQL is detected to be enabled, please set it. Custom SQL cannot be empty.",
16 | "dataPanel": "Data panel",
17 | "help": "Help"
18 | }
19 |
--------------------------------------------------------------------------------
/src/i18n/zh_CN.json:
--------------------------------------------------------------------------------
1 | {
2 | "randomDoc": "漫游式渐进阅读",
3 | "setting": "设置",
4 | "docFetchError": "啊哦,暂未获取到数据,请按照指引操作或者刷新试试呢~",
5 | "showLoading": "显示加载中",
6 | "showLoadingTip": "漫游文档时是否显示显示加载中图标",
7 | "customSqlEnabled": "启用自定义SQL",
8 | "customSqlEnabledTip": "勾选之后将使用自定义 SQL 进行漫游,不再使用预设条件",
9 | "cancel": "取消",
10 | "save": "保存",
11 | "ok": "确认",
12 | "settingConfigSaveSuccess": "配置保存成功",
13 | "sqlContent": "自定义SQL",
14 | "sqlContentTip": "自定义 sql,注意必须是合法的 JSON 数组,例如:[{\"name\":\"默认\",\"sql\":\"sql1\"],注意:无论怎么写,自定义 sql 必须能返回唯一的一个文档 ID。",
15 | "customSqlEmpty": "检测到开启自定义 SQL,请设置,自定义 SQL 不能为空",
16 | "dataPanel": "数据面板",
17 | "help": "查看帮助"
18 | }
19 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2025, ebAobS . All rights reserved.
3 | * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 | *
5 | * This code is free software; you can redistribute it and/or modify it
6 | * under the terms of the GNU General Public License version 2 only, as
7 | * published by the Free Software Foundation. ebAobS designates this
8 | * particular file as subject to the "Classpath" exception as provided
9 | * by ebAobS in the LICENSE file that accompanied this code.
10 | *
11 | * This code is distributed in the hope that it will be useful, but WITHOUT
12 | * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 | * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14 | * version 2 for more details (a copy is included in the LICENSE file that
15 | * accompanied this code).
16 | *
17 | * You should have received a copy of the GNU General Public License version
18 | * 2 along with this work; if not, write to the Free Software Foundation,
19 | * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20 | *
21 | * Please contact ebAobS, ebAobs@outlook.com
22 | * or visit https://github.com/ebAobS/roaming-mode-incremental-reading if you need additional information or have any
23 | * questions.
24 | */
25 |
26 | /**
27 | * ========================================
28 | * 漫游式渐进阅读插件入口文件
29 | * ========================================
30 | *
31 | * 本文件是漫游式渐进阅读插件的核心入口点,实现了插件的初始化和基础功能。
32 | *
33 | * ## 文件结构
34 | * 1. 插件类定义 - RandomDocPlugin 类是整个插件的主体
35 | * 2. 插件生命周期方法 - 包括 onload 方法用于初始化插件
36 | * 3. 工具方法 - 包括配置加载等辅助功能
37 | */
38 |
39 | import { App, getFrontend, IModel, IObject, Plugin } from "siyuan"
40 | import { simpleLogger } from "zhi-lib-base"
41 |
42 | import "../index.styl"
43 | import { isDev } from "./Constants"
44 | import { initTopbar, registerCommand } from "./topbar"
45 | import KernelApi from "./api/kernel-api"
46 |
47 | /**
48 | * 1. 漫游式渐进阅读插件类
49 | * 继承自思源笔记的 Plugin 基类,提供核心插件功能
50 | */
51 | export default class RandomDocPlugin extends Plugin {
52 | /** 1.1 插件日志记录器 */
53 | public logger
54 | /** 1.2 是否为移动设备标志 */
55 | public isMobile: boolean
56 | /** 1.3 内核API封装,用于与思源内核交互 */
57 | public kernelApi: KernelApi
58 |
59 | /** 1.4 自定义标签页对象 */
60 | public customTabObject: () => IModel
61 | /** 1.5 标签页实例引用 */
62 | public tabInstance
63 | /** 1.6 标签页内容实例引用 */
64 | public tabContentInstance
65 |
66 | /**
67 | * 1.7 插件构造函数
68 | * 初始化插件基础设施
69 | *
70 | * @param options 插件初始化选项
71 | */
72 | constructor(options: { app: App; id: string; name: string; i18n: IObject }) {
73 | super(options)
74 |
75 | // 1.7.1 初始化日志记录器
76 | this.logger = simpleLogger("index", "incremental-reading", isDev)
77 | // 1.7.2 检测前端环境
78 | const frontEnd = getFrontend()
79 | this.isMobile = frontEnd === "mobile" || frontEnd === "browser-mobile"
80 | // 1.7.3 初始化内核API
81 | this.kernelApi = new KernelApi()
82 | }
83 |
84 | /**
85 | * 2. 插件加载方法
86 | * 当插件被思源笔记加载时调用,用于初始化插件功能
87 | */
88 | async onload() {
89 | // 2.1 初始化顶栏按钮
90 | await initTopbar(this)
91 | // 2.2 注册插件命令(快捷键)
92 | await registerCommand(this)
93 | }
94 |
95 | // openSetting() {
96 | // showSettingMenu(this)
97 | // }
98 |
99 | /**
100 | * 3. 工具方法
101 | */
102 |
103 | /**
104 | * 3.1 安全的加载配置
105 | * 确保即使配置加载失败也返回一个有效对象
106 | *
107 | * @param storeName 存储键名
108 | * @returns 配置对象
109 | */
110 | public async safeLoad(storeName: string) {
111 | let storeConfig = await this.loadData(storeName)
112 |
113 | if (typeof storeConfig !== "object") {
114 | storeConfig = {}
115 | }
116 |
117 | return storeConfig
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/src/libs/IncrementalConfigPanel.svelte:
--------------------------------------------------------------------------------
1 |
25 |
26 |
178 |
179 |
180 |
渐进式模式配置
181 |
182 |
183 |
自定义指标
184 |
权重总和将自动调整为100%
185 |
186 |
217 |
218 |
219 |
220 |
添加新指标
221 |
222 |
254 |
255 |
270 |
271 |
272 |
273 | {#if isProcessing}
274 |
275 |
正在修复文档指标
276 |
277 | 正在处理 {processCurrent} / {processTotal} 篇文档 ({processProgress}%)
278 |
279 |
282 |
正在扫描并修复文档指标,分页处理中,请耐心等待...
283 |
大量文档处理可能需要较长时间,请勿关闭窗口
284 |
285 | {/if}
286 |
287 |
288 | 保存配置
289 | 关闭
290 |
291 |
292 |
293 |
--------------------------------------------------------------------------------
/src/libs/Loading.svelte:
--------------------------------------------------------------------------------
1 |
25 |
26 |
29 |
30 | {#if show}
31 |
34 | {/if}
35 |
36 |
68 |
--------------------------------------------------------------------------------
/src/libs/MetricsPanel.svelte:
--------------------------------------------------------------------------------
1 |
25 |
26 |
260 |
261 |
262 |
263 |
文档指标
264 |
265 | 优先级:{totalPriority.toFixed(1)}
266 |
267 |
268 |
269 | {#if isLoading}
270 |
271 | 正在加载指标数据...
272 |
273 | {:else if errorMessage}
274 |
275 | {errorMessage}
276 |
277 | {:else if !metrics || metrics.length === 0}
278 |
279 | 未找到指标配置,请在设置中添加指标
280 |
281 | {:else}
282 |
283 | {#each metrics as metric}
284 |
285 |
286 | {metric.name}({metric.weight.toFixed(0)}%)
287 |
288 |
289 | decreaseMetric(metric.id)}>-
290 | updateMetricValue(metric.id, e.currentTarget.value)}
295 | on:keydown={(e) => e.key === 'Enter' && updateMetricValue(metric.id, e.currentTarget.value)}
296 | style="background: linear-gradient(to right, var(--b3-theme-primary-light) {docMetrics.get(metric.id) * 10}%, transparent 0%);"
297 | />
298 | increaseMetric(metric.id)}>+
299 |
300 |
301 | {/each}
302 |
303 | {/if}
304 |
305 |
306 |
--------------------------------------------------------------------------------
/src/libs/RandomDocContent.svelte:
--------------------------------------------------------------------------------
1 |
25 |
26 |
798 |
799 |
800 |
801 |
802 |
803 |
811 | {title}
812 |
813 |
814 |
821 |
822 | {#if storeConfig?.customSqlEnabled}
823 |
828 | {#if sqlList && sqlList.length > 0}
829 | {#each sqlList as s (s.sql)}
830 | {s.name}
831 | {/each}
832 | {:else}
833 | {pluginInstance.i18n.loading}...
834 | {/if}
835 |
836 | 当前使用自定义 SQL 漫游
837 | {:else}
838 | 筛选:
839 |
844 | 笔记本
845 | 根文档
846 |
847 | {#if filterMode === FilterMode.Notebook}
848 |
853 | 全部笔记本
854 | {#if notebooks && notebooks.length > 0}
855 | {#each notebooks as notebook (notebook.id)}
856 | {notebook.name}
857 | {/each}
858 | {:else}
859 | {pluginInstance.i18n.loading}...
860 | {/if}
861 |
862 | {:else}
863 |
869 | {/if}
870 | 模式:
871 |
876 | 渐进
877 | 一遍过
878 |
879 | {/if}
880 |
881 |
882 | {isLoading ? "漫游中..." : "继续漫游"}
883 |
884 | 编辑
885 |
886 | 重置已访问
887 |
888 |
889 | 漫游历史
890 |
891 |
896 | ?
897 |
898 |
899 |
900 | {#if reviewMode === ReviewMode.Incremental && currentRndId}
901 |
909 | {/if}
910 |
911 |
921 | {@html content}
922 |
923 |
924 |
925 |
926 |
1048 |
--------------------------------------------------------------------------------
/src/libs/RandomDocSetting.svelte:
--------------------------------------------------------------------------------
1 |
225 |
226 |
227 |
228 |
237 |
238 |
244 |
245 | {#if reviewMode === ReviewMode.Incremental}
246 |
255 |
256 |
353 |
354 |
355 | {#if isProcessing}
356 |
367 | {/if}
368 | {/if}
369 |
370 |
379 |
380 | {#if customSqlEnabled}
381 |
393 | {/if}
394 |
395 |
396 |
{pluginInstance.i18n.cancel}
397 |
398 |
{pluginInstance.i18n.save}
399 |
400 |
401 |
402 |
403 |
592 |
--------------------------------------------------------------------------------
/src/models/IncrementalConfig.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2025, ebAobS . All rights reserved.
3 | * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 | *
5 | * This code is free software; you can redistribute it and/or modify it
6 | * under the terms of the GNU General Public License version 2 only, as
7 | * published by the Free Software Foundation. ebAobS designates this
8 | * particular file as subject to the "Classpath" exception as provided
9 | * by ebAobS in the LICENSE file that accompanied this code.
10 | *
11 | * This code is distributed in the hope that it will be useful, but WITHOUT
12 | * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 | * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14 | * version 2 for more details (a copy is included in the LICENSE file that
15 | * accompanied this code).
16 | *
17 | * You should have received a copy of the GNU General Public License version
18 | * 2 along with this work; if not, write to the Free Software Foundation,
19 | * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20 | *
21 | * Please contact ebAobS, ebAobs@outlook.com
22 | * or visit https://github.com/ebAobS/roaming-mode-incremental-reading if you need additional information or have any
23 | * questions.
24 | */
25 |
26 | /**
27 | * ========================================
28 | * 漫游式渐进阅读插件 - 渐进式阅读配置模型
29 | * ========================================
30 | *
31 | * 本文件定义了渐进式阅读的核心配置模型,包括指标定义、文档优先级数据结构和优先级计算逻辑。
32 | * 这些配置用于实现轮盘赌算法中的权重计算和文档选择。
33 | *
34 | * ## 文件结构
35 | * 1. 接口定义 - 指标和文档优先级的数据结构
36 | * 2. 配置类 - 管理渐进阅读的指标和计算方法
37 | */
38 |
39 | /**
40 | * 1. 接口定义
41 | */
42 |
43 | /**
44 | * 1.1 自定义指标
45 | * 定义文档评估的标准和权重
46 | */
47 | export interface Metric {
48 | /** 1.1.1 指标唯一标识符 */
49 | id: string;
50 | /** 1.1.2 指标显示名称 */
51 | name: string;
52 | /** 1.1.3 指标默认值(0-10) */
53 | value: number;
54 | /** 1.1.4 指标在总评分中的权重(0-100%) */
55 | weight: number;
56 | /** 1.1.5 指标描述信息(可选) */
57 | description?: string;
58 | }
59 |
60 | /**
61 | * 1.2 文档优先级数据
62 | * 存储特定文档的指标值和访问记录
63 | */
64 | export interface DocPriorityData {
65 | /** 1.2.1 文档ID */
66 | docId: string;
67 | /** 1.2.2 指标ID到值的映射 */
68 | metrics: { [key: string]: number };
69 | /** 1.2.3 访问次数(可选) */
70 | visitCount?: number;
71 | }
72 |
73 | /**
74 | * 2. 配置类
75 | * 渐进式阅读配置管理
76 | */
77 | export class IncrementalConfig {
78 | /**
79 | * 2.1 可用的指标列表
80 | * 存储当前活跃的所有指标
81 | */
82 | public metrics: Metric[] = [];
83 |
84 | /**
85 | * 2.2 获取默认指标
86 | * 提供初始的预设指标列表
87 | *
88 | * @returns 默认指标列表
89 | */
90 | static getDefaultMetrics(): Metric[] {
91 | return [
92 | {
93 | id: "importance",
94 | name: "重要性",
95 | value: 5.0,
96 | weight: 40,
97 | description: "文档的重要程度"
98 | },
99 | {
100 | id: "urgency",
101 | name: "紧急度",
102 | value: 5.0,
103 | weight: 30,
104 | description: "需要尽快查看的程度"
105 | },
106 | {
107 | id: "difficulty",
108 | name: "难度",
109 | value: 5.0,
110 | weight: 30,
111 | description: "理解和记忆的难度"
112 | }
113 | ];
114 | }
115 |
116 | /**
117 | * 2.3 计算文档优先级
118 | * 根据指标权重计算文档总优先级
119 | *
120 | * @param docData 文档优先级数据
121 | * @returns 包含优先级分数的对象
122 | */
123 | public calculatePriority(docData: DocPriorityData): { priority: number } {
124 | let priority = 0;
125 | let totalWeight = 0;
126 |
127 | // 2.3.1 遍历所有指标
128 | for (const metric of this.metrics) {
129 | // 2.3.2 获取指标值,假设已经在getDocPriorityData中修复过
130 | const metricValue = docData.metrics[metric.id] || 5.0;
131 |
132 | // 2.3.3 将该指标的值乘以权重加到总优先级上
133 | priority += metricValue * metric.weight;
134 | totalWeight += metric.weight;
135 | }
136 |
137 | // 2.3.4 检查总权重
138 | if (totalWeight <= 0) {
139 | throw new Error("总权重非正数,无法计算优先级");
140 | }
141 |
142 | // 2.3.5 归一化结果(0-10)
143 | const normalizedPriority = priority / totalWeight;
144 |
145 | // 2.3.6 返回优先级
146 | return { priority: normalizedPriority };
147 | }
148 |
149 | /**
150 | * 2.4 构造函数
151 | * 初始化配置对象和默认指标
152 | */
153 | constructor() {
154 | // 2.4.1 初始化默认指标
155 | this.metrics = IncrementalConfig.getDefaultMetrics();
156 | }
157 |
158 | /**
159 | * 2.5 添加新指标
160 | *
161 | * @param metric 要添加的指标对象
162 | */
163 | public addMetric(metric: Metric): void {
164 | this.metrics.push(metric);
165 | this.normalizeWeights();
166 | }
167 |
168 | /**
169 | * 2.6 删除指标
170 | *
171 | * @param metricId 要删除的指标ID
172 | */
173 | public removeMetric(metricId: string): void {
174 | this.metrics = this.metrics.filter(m => m.id !== metricId);
175 | this.normalizeWeights();
176 | }
177 |
178 | /**
179 | * 2.7 更新指标
180 | *
181 | * @param metricId 要更新的指标ID
182 | * @param updates 部分更新内容
183 | */
184 | public updateMetric(metricId: string, updates: Partial): void {
185 | const index = this.metrics.findIndex(m => m.id === metricId);
186 | if (index >= 0) {
187 | this.metrics[index] = { ...this.metrics[index], ...updates };
188 | // 2.7.1 如果更新了权重,需要重新归一化
189 | if (updates.weight !== undefined) {
190 | this.normalizeWeights();
191 | }
192 | }
193 | }
194 |
195 | /**
196 | * 2.8 归一化所有指标的权重
197 | * 调整所有权重使总和为100
198 | */
199 | private normalizeWeights(): void {
200 | const totalWeight = this.metrics.reduce((sum, metric) => sum + metric.weight, 0);
201 |
202 | if (totalWeight > 0 && totalWeight !== 100) {
203 | // 2.8.1 调整所有权重使总和为100
204 | this.metrics.forEach(metric => {
205 | metric.weight = (metric.weight / totalWeight) * 100;
206 | });
207 | }
208 | }
209 | }
210 |
211 | export default IncrementalConfig;
--------------------------------------------------------------------------------
/src/models/IncrementalReadingConfig.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2023-2025, ebAobS . All rights reserved.
3 | * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 | *
5 | * This code is free software; you can redistribute it and/or modify it
6 | * under the terms of the GNU General Public License version 2 only, as
7 | * published by the Free Software Foundation. ebAobS designates this
8 | * particular file as subject to the "Classpath" exception as provided
9 | * by ebAobS in the LICENSE file that accompanied this code.
10 | *
11 | * This code is distributed in the hope that it will be useful, but WITHOUT
12 | * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 | * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14 | * version 2 for more details (a copy is included in the LICENSE file that
15 | * accompanied this code).
16 | *
17 | * You should have received a copy of the GNU General Public License version
18 | * 2 along with this work; if not, write to the Free Software Foundation,
19 | * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20 | */
21 |
22 | export enum ReviewMode {
23 | Incremental = "incremental",
24 | Once = "once",
25 | }
26 |
27 | export enum FilterMode {
28 | Notebook = "notebook",
29 | Root = "root",
30 | }
31 |
32 | /**
33 | * 存储对象
34 | */
35 | class IncrementalReadingConfig {
36 | /**
37 | * 笔记本ID
38 | */
39 | public notebookId: string
40 |
41 | /**
42 | * 是否显示加载中
43 | */
44 | public showLoading: boolean
45 |
46 | /**
47 | * 是否启用自定义 SQL
48 | */
49 | public customSqlEnabled: boolean
50 |
51 | /**
52 | * 自定义 SQL
53 | */
54 | public sql: string
55 |
56 | /**
57 | * 当前 SQL
58 | */
59 | public currentSql: string
60 |
61 | /**
62 | * 复习模式
63 | */
64 | reviewMode: ReviewMode = ReviewMode.Incremental
65 |
66 | /**
67 | * 过滤模式
68 | */
69 | filterMode: FilterMode = FilterMode.Notebook
70 |
71 | /**
72 | * 根块ID
73 | */
74 | rootId = ""
75 |
76 | /**
77 | * 渐进模式配置ID
78 | */
79 | incrementalConfigId = "incremental_config"
80 |
81 | /**
82 | * 是否排除今日已访问的文档
83 | */
84 | excludeTodayVisited = true
85 |
86 | constructor() {
87 | this.filterMode = this.filterMode || FilterMode.Notebook
88 | this.rootId = this.rootId || ""
89 | this.excludeTodayVisited = this.excludeTodayVisited !== false
90 | }
91 | }
92 |
93 | export default IncrementalReadingConfig
--------------------------------------------------------------------------------
/src/models/RandomDocConfig.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2025, ebAobS . All rights reserved.
3 | * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 | *
5 | * This code is free software; you can redistribute it and/or modify it
6 | * under the terms of the GNU General Public License version 2 only, as
7 | * published by the Free Software Foundation. ebAobS designates this
8 | * particular file as subject to the "Classpath" exception as provided
9 | * by ebAobS in the LICENSE file that accompanied this code.
10 | *
11 | * This code is distributed in the hope that it will be useful, but WITHOUT
12 | * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 | * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14 | * version 2 for more details (a copy is included in the LICENSE file that
15 | * accompanied this code).
16 | *
17 | * You should have received a copy of the GNU General Public License version
18 | * 2 along with this work; if not, write to the Free Software Foundation,
19 | * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20 | *
21 | * Please contact ebAobS, ebAobs@outlook.com
22 | * or visit https://github.com/ebAobS/roaming-mode-incremental-reading if you need additional information or have any
23 | * questions.
24 | */
25 |
26 | /**
27 | * ========================================
28 | * 漫游式渐进阅读插件 - 配置模型
29 | * ========================================
30 | *
31 | * 本文件定义了插件的主要配置模型,包括漫游模式、过滤器和其他运行时配置。
32 | * 这些配置被存储在思源笔记的数据存储中,用于控制插件的主要行为。
33 | *
34 | * ## 文件结构
35 | * 1. 枚举定义 - 定义配置中使用的枚举类型
36 | * 2. 配置类 - 定义插件的主要配置模型
37 | */
38 |
39 | /**
40 | * 1. 枚举定义
41 | */
42 |
43 | /**
44 | * 1.1 复习模式枚举
45 | * 定义文档复习的两种主要模式
46 | */
47 | export enum ReviewMode {
48 | /** 1.1.1 渐进式复习模式,基于优先级系统 */
49 | Incremental = "incremental",
50 | /** 1.1.2 一次性复习模式,依次查看所有文档 */
51 | Once = "once",
52 | }
53 |
54 | /**
55 | * 1.2 过滤模式枚举
56 | * 定义文档筛选的两种主要方式
57 | */
58 | export enum FilterMode {
59 | /** 1.2.1 按笔记本过滤 */
60 | Notebook = "notebook",
61 | /** 1.2.2 按根文档过滤 */
62 | Root = "root",
63 | }
64 |
65 | /**
66 | * 2. 配置类
67 | * 定义插件的主要配置模型
68 | */
69 | class RandomDocConfig {
70 | /**
71 | * 2.1 笔记本ID
72 | * 当过滤模式为Notebook时使用的笔记本ID
73 | */
74 | public notebookId: string
75 |
76 | /**
77 | * 2.2 是否显示加载动画
78 | * 控制漫游过程中是否显示加载动画
79 | */
80 | public showLoading: boolean
81 |
82 | /**
83 | * 2.3 是否启用自定义SQL
84 | * 控制是否使用自定义SQL查询来筛选文档
85 | */
86 | public customSqlEnabled: boolean
87 |
88 | /**
89 | * 2.4 自定义SQL列表
90 | * 存储用户定义的SQL查询列表(JSON字符串)
91 | */
92 | public sql: string
93 |
94 | /**
95 | * 2.5 当前选中的SQL
96 | * 当前正在使用的SQL查询
97 | */
98 | public currentSql: string
99 |
100 | /**
101 | * 2.6 复习模式
102 | * 控制文档的复习方式,默认为渐进式
103 | */
104 | reviewMode: ReviewMode = ReviewMode.Incremental
105 |
106 | /**
107 | * 2.7 过滤模式
108 | * 控制文档的筛选方式,默认为按笔记本
109 | */
110 | filterMode: FilterMode = FilterMode.Notebook
111 |
112 | /**
113 | * 2.8 根文档ID
114 | * 当过滤模式为Root时使用的根文档ID
115 | */
116 | rootId = ""
117 |
118 | /**
119 | * 2.9 渐进模式配置ID
120 | * 用于存储渐进模式的配置数据
121 | */
122 | incrementalConfigId = "incremental_config"
123 |
124 | /**
125 | * 2.10 是否排除今日已访问文档
126 | * 控制是否在漫游时排除今天已经访问过的文档
127 | */
128 | excludeTodayVisited = true
129 |
130 | /**
131 | * 2.11 构造函数
132 | * 初始化配置对象,设置默认值
133 | */
134 | constructor() {
135 | this.filterMode = this.filterMode || FilterMode.Notebook
136 | this.rootId = this.rootId || ""
137 | this.excludeTodayVisited = this.excludeTodayVisited !== false
138 | }
139 | }
140 |
141 | export default RandomDocConfig
142 |
--------------------------------------------------------------------------------
/src/service/IncrementalReviewer.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2025, ebAobS . All rights reserved.
3 | * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 | *
5 | * This code is free software; you can redistribute it and/or modify it
6 | * under the terms of the GNU General Public License version 2 only, as
7 | * published by the Free Software Foundation. ebAobS designates this
8 | * particular file as subject to the "Classpath" exception as provided
9 | * by ebAobS in the LICENSE file that accompanied this code.
10 | *
11 | * This code is distributed in the hope that it will be useful, but WITHOUT
12 | * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 | * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14 | * version 2 for more details (a copy is included in the LICENSE file that
15 | * accompanied this code).
16 | *
17 | * You should have received a copy of the GNU General Public License version
18 | * 2 along with this work; if not, write to the Free Software Foundation,
19 | * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20 | *
21 | * Please contact ebAobS, ebAobs@outlook.com
22 | * or visit https://github.com/ebAobS/roaming-mode-incremental-reading if you need additional information or have any
23 | * questions.
24 | */
25 |
26 | /**
27 | * ======================================================
28 | * 漫游式渐进阅读插件 - 渐进式阅读审阅器
29 | * ======================================================
30 | *
31 | * 本文件实现了渐进式阅读的核心功能,包括文档优先级计算、轮盘赌选择算法、
32 | * 文档筛选与过滤、指标管理等功能。这是插件最核心的业务逻辑所在。
33 | *
34 | * ## 文件结构
35 | * 1. 审阅器类定义与初始化 - 渐进审阅器的核心配置与状态
36 | * 2. 配置管理 - 处理渐进配置的加载与保存
37 | * 3. 文档获取 - 实现文档查询、过滤与选择
38 | * 4. 优先级与指标 - 处理文档优先级、指标计算与管理
39 | * 5. 轮盘赌算法 - 基于优先级的随机选择算法
40 | * 6. 访问记录 - 管理文档访问次数与历史
41 | * 7. 工具方法 - 提供过滤条件构建等辅助功能
42 | */
43 |
44 | import { showMessage } from "siyuan"
45 | import RandomDocPlugin from "../index"
46 | import RandomDocConfig, { FilterMode, ReviewMode } from "../models/RandomDocConfig"
47 | import IncrementalConfig, { DocPriorityData, Metric } from "../models/IncrementalConfig"
48 |
49 | /**
50 | * 1. 渐进式阅读审阅器
51 | * 实现渐进式阅读的核心算法与功能
52 | */
53 | class IncrementalReviewer {
54 | /** 1.1 插件配置 */
55 | private storeConfig: RandomDocConfig
56 | /** 1.2 插件实例 */
57 | private pluginInstance: RandomDocPlugin
58 | /** 1.3 渐进式阅读配置 */
59 | private incrementalConfig: IncrementalConfig
60 |
61 | /**
62 | * 1.4 构造函数
63 | * 初始化审阅器并关联配置与插件实例
64 | *
65 | * @param storeConfig 存储配置
66 | * @param pluginInstance 插件实例
67 | */
68 | constructor(storeConfig: RandomDocConfig, pluginInstance: RandomDocPlugin) {
69 | this.storeConfig = storeConfig
70 | this.pluginInstance = pluginInstance
71 | this.incrementalConfig = new IncrementalConfig()
72 | }
73 |
74 | /**
75 | * 2. 配置管理
76 | */
77 |
78 | /**
79 | * 2.1 初始化渐进配置
80 | * 从存储中加载配置或使用默认值
81 | */
82 | public async initIncrementalConfig(): Promise {
83 | try {
84 | // 2.1.1 从存储中加载配置
85 | const configId = this.storeConfig.incrementalConfigId
86 | const savedConfig = await this.pluginInstance.safeLoad(configId)
87 |
88 | if (savedConfig && savedConfig.metrics) {
89 | // 2.1.2 使用已保存的配置
90 | this.incrementalConfig.metrics = savedConfig.metrics
91 | } else {
92 | // 2.1.3 使用默认配置
93 | this.incrementalConfig.metrics = IncrementalConfig.getDefaultMetrics()
94 | await this.saveIncrementalConfig()
95 | }
96 | } catch (error) {
97 | this.pluginInstance.logger.error("初始化渐进配置失败:", error)
98 | // 2.1.4 出错时使用默认配置
99 | this.incrementalConfig.metrics = IncrementalConfig.getDefaultMetrics()
100 | }
101 | }
102 |
103 | /**
104 | * 2.2 保存渐进配置
105 | * 将配置存储到思源存储中
106 | */
107 | public async saveIncrementalConfig(): Promise {
108 | try {
109 | const configId = this.storeConfig.incrementalConfigId
110 | await this.pluginInstance.saveData(configId, {
111 | metrics: this.incrementalConfig.metrics
112 | })
113 | } catch (error) {
114 | this.pluginInstance.logger.error("保存渐进配置失败:", error)
115 | showMessage("保存配置失败: " + error.message, 5000, "error")
116 | }
117 | }
118 |
119 | /**
120 | * 3. 文档获取
121 | */
122 |
123 | /**
124 | * 3.1 获取随机文档(基于轮盘赌选择算法)
125 | * 根据优先级从符合条件的文档中随机选择一篇
126 | *
127 | * @returns 选中的文档ID
128 | */
129 | public async getRandomDoc(): Promise {
130 | try {
131 | this.pluginInstance.logger.info("开始获取随机文档...")
132 |
133 | // 3.1.1 获取最新过滤条件
134 | const filterCondition = this.buildFilterCondition()
135 | this.pluginInstance.logger.info(`构建的过滤条件: ${filterCondition}`)
136 |
137 | let excludeVisited = ""
138 |
139 | // 3.1.2 构建排除已访问文档的条件
140 | if (this.storeConfig.excludeTodayVisited) {
141 | this.pluginInstance.logger.info("启用了排除已访问文档选项")
142 | excludeVisited = `
143 | AND (
144 | NOT EXISTS (
145 | SELECT 1 FROM attributes
146 | WHERE block_id = blocks.id
147 | AND name = 'custom-visit-count'
148 | AND value <> ''
149 | )
150 | )
151 | `
152 | }
153 |
154 | // 3.1.3 获取符合条件的文档总数
155 | const countSql = `
156 | SELECT COUNT(id) as total FROM blocks
157 | WHERE type = 'd'
158 | ${filterCondition}
159 | ${excludeVisited}
160 | `
161 |
162 | const countResult = await this.pluginInstance.kernelApi.sql(countSql)
163 | if (countResult.code !== 0) {
164 | this.pluginInstance.logger.error(`获取文档总数失败,错误码: ${countResult.code}, 错误信息: ${countResult.msg}`)
165 | showMessage("获取文档总数失败: " + countResult.msg, 7000, "error")
166 | throw new Error(countResult.msg)
167 | }
168 |
169 | const totalDocCount = countResult.data?.[0]?.total || 0
170 | this.pluginInstance.logger.info(`符合条件的文档总数: ${totalDocCount}`)
171 |
172 | // 3.1.4 检查是否存在符合条件的文档
173 | if (totalDocCount === 0) {
174 | const errorMsg = this.storeConfig.excludeTodayVisited
175 | ? "所有文档都已访问过,可以重置访问记录或关闭排除已访问选项"
176 | : "没有找到符合条件的文档";
177 |
178 | this.pluginInstance.logger.error(errorMsg);
179 | showMessage(errorMsg, 5000, "error");
180 | throw new Error(errorMsg);
181 | }
182 |
183 | // 3.1.5 使用分页查询获取所有文档
184 | const pageSize = 50 // 每页获取50个文档
185 | let allDocs = []
186 |
187 | for (let offset = 0; offset < totalDocCount; offset += pageSize) {
188 | // 3.1.5.1 构建分页查询SQL
189 | const pageSql = `
190 | SELECT id FROM blocks
191 | WHERE type = 'd'
192 | ${filterCondition}
193 | ${excludeVisited}
194 | LIMIT ${pageSize} OFFSET ${offset}
195 | `
196 |
197 | this.pluginInstance.logger.info(`执行分页查询 ${Math.floor(offset/pageSize) + 1}/${Math.ceil(totalDocCount/pageSize)}: ${pageSql.replace(/\s+/g, ' ')}`)
198 |
199 | // 3.1.5.2 执行查询
200 | const pageResult = await this.pluginInstance.kernelApi.sql(pageSql)
201 | if (pageResult.code !== 0) {
202 | this.pluginInstance.logger.error(`分页查询失败,错误码: ${pageResult.code}, 错误信息: ${pageResult.msg}`)
203 | showMessage("获取文档失败: " + pageResult.msg, 7000, "error")
204 | throw new Error(pageResult.msg)
205 | }
206 |
207 | // 3.1.5.3 处理查询结果
208 | const pageDocs = pageResult.data as any[]
209 | if (!pageDocs || pageDocs.length === 0) {
210 | this.pluginInstance.logger.warn(`分页 ${Math.floor(offset/pageSize) + 1} 没有返回文档,提前结束分页查询`)
211 | break
212 | }
213 |
214 | // 3.1.5.4 累计文档结果
215 | allDocs = allDocs.concat(pageDocs)
216 | this.pluginInstance.logger.info(`已获取 ${allDocs.length}/${totalDocCount} 个文档`)
217 | }
218 |
219 | // 3.1.6 验证查询结果
220 | if (allDocs.length === 0) {
221 | const errorMsg = "分页查询未能获取到任何文档"
222 | this.pluginInstance.logger.error(errorMsg)
223 | showMessage(errorMsg, 5000, "error")
224 | throw new Error(errorMsg)
225 | }
226 |
227 | this.pluginInstance.logger.info(`最终获取到 ${allDocs.length}/${totalDocCount} 个文档`)
228 |
229 | // 3.1.7 显示获取文档数量的提示
230 | showMessage(`已获取 ${allDocs.length} 个文档用于计算漫游概率`, 3000, "info")
231 |
232 | // 3.1.8 获取所有文档的优先级数据
233 | this.pluginInstance.logger.info("开始获取所有文档的优先级数据...")
234 |
235 | // 3.1.9 批量处理文档优先级计算
236 | const docPriorityList: { docId: string, priority: number }[] = []
237 | const batchSize = 20
238 |
239 | for (let i = 0; i < allDocs.length; i += batchSize) {
240 | const batchDocs = allDocs.slice(i, i + batchSize)
241 | this.pluginInstance.logger.info(`处理第 ${Math.floor(i/batchSize) + 1}/${Math.ceil(allDocs.length/batchSize)} 批文档,共 ${batchDocs.length} 个`)
242 |
243 | // 3.1.9.1 并行处理一批文档
244 | const batchResults = await Promise.all(
245 | batchDocs.map(async (doc) => {
246 | try {
247 | const docData = await this.getDocPriorityData(doc.id)
248 | const priorityResult = await this.calculatePriority(docData)
249 | return { docId: doc.id, priority: priorityResult.priority }
250 | } catch (err) {
251 | this.pluginInstance.logger.error(`获取文档 ${doc.id} 优先级数据失败`, err);
252 | // 返回默认优先级,避免因单个文档失败而中断整个流程
253 | return { docId: doc.id, priority: 5.0 };
254 | }
255 | })
256 | )
257 |
258 | docPriorityList.push(...batchResults)
259 |
260 | // 3.1.9.2 更新进度提示
261 | if (allDocs.length > 100) {
262 | showMessage(`正在计算文档优先级 ${docPriorityList.length}/${allDocs.length}`, 1000, "info")
263 | }
264 | }
265 |
266 | this.pluginInstance.logger.info(`已计算 ${docPriorityList.length} 个文档的优先级数据`)
267 |
268 | // 3.1.10 记录前几个文档的优先级情况(调试用)
269 | const top5Docs = docPriorityList.slice(0, 5).map(doc => `${doc.docId}: ${doc.priority.toFixed(2)}`);
270 | this.pluginInstance.logger.info(`前5个文档的优先级: ${top5Docs.join(', ')}`)
271 |
272 | // 3.1.11 使用轮盘赌算法选择文档
273 | const selectedDoc = this.rouletteWheelSelection(docPriorityList)
274 | this.pluginInstance.logger.info(`选中的文档ID: ${selectedDoc}`)
275 |
276 | // 3.1.12 计算并记录选中文档的概率
277 | const selectedDocInfo = docPriorityList.find(item => item.docId === selectedDoc)
278 | if (!selectedDocInfo) {
279 | this.pluginInstance.logger.error(`严重错误:无法找到选中文档 ${selectedDoc} 的优先级信息`)
280 | throw new Error(`无法找到选中文档 ${selectedDoc} 的优先级信息`)
281 | }
282 |
283 | // 3.1.13 计算总优先级(高精度)
284 | const totalPriority = docPriorityList.reduce((sum, item) => sum + item.priority, 0)
285 | this.pluginInstance.logger.info(`所有文档总优先级: ${totalPriority.toFixed(6)}`)
286 |
287 | try {
288 | this.pluginInstance.logger.info(`开始计算选中文档的概率...`)
289 | // 3.1.14 精确计算概率值
290 | this._lastSelectionProbability = this.calculateSelectionProbability(
291 | selectedDocInfo.priority,
292 | totalPriority
293 | )
294 |
295 | this.pluginInstance.logger.info(`概率计算完成, 最终结果: ${this._lastSelectionProbability.toFixed(6)}%`)
296 | } catch (error) {
297 | this.pluginInstance.logger.error('计算概率时出错:', error)
298 | throw new Error(`计算选中概率失败: ${error.message}`)
299 | }
300 |
301 | // 3.1.15 更新访问次数
302 | await this.updateVisitCount(selectedDoc)
303 | this.pluginInstance.logger.info("已更新文档的访问次数")
304 |
305 | // 3.1.16 记录漫游历史
306 | try {
307 | const blockResult = await this.pluginInstance.kernelApi.getBlockByID(selectedDoc)
308 | if (blockResult) {
309 | const docTitle = blockResult.content || "无标题文档"
310 | await this.saveRoamingHistory(selectedDoc, docTitle, this._lastSelectionProbability)
311 | }
312 | } catch (error) {
313 | this.pluginInstance.logger.error('获取文档标题失败,不影响漫游过程:', error)
314 | }
315 |
316 | return selectedDoc
317 | } catch (error) {
318 | this.pluginInstance.logger.error("获取随机文档失败", error)
319 | showMessage("获取随机文档失败: " + error.message, 5000, "error")
320 | throw error
321 | }
322 | }
323 |
324 | /**
325 | * 3.2 获取符合条件的文档总数
326 | *
327 | * @param config 可选配置,不提供则使用当前配置
328 | * @returns 文档总数
329 | */
330 | public async getTotalDocCount(config?: RandomDocConfig): Promise {
331 | try {
332 | // 3.2.1 使用传入的配置或当前最新配置
333 | const filterCondition = this.buildFilterCondition(config || this.storeConfig)
334 |
335 | // 3.2.2 构造计数SQL查询
336 | const sql = `
337 | SELECT COUNT(id) as total FROM blocks
338 | WHERE type = 'd'
339 | ${filterCondition}
340 | `
341 |
342 | // 3.2.3 执行查询
343 | const result = await this.pluginInstance.kernelApi.sql(sql)
344 | if (result.code !== 0) {
345 | this.pluginInstance.logger.error(`获取文档总数时出错,错误码: ${result.code}, 错误信息: ${result.msg}`)
346 | throw new Error(`获取文档总数时出错: ${result.msg}`)
347 | }
348 |
349 | // 3.2.4 返回结果
350 | return result.data?.[0]?.total || 0
351 | } catch (error) {
352 | this.pluginInstance.logger.error("获取文档总数时出错:", error)
353 | throw error
354 | }
355 | }
356 |
357 | /**
358 | * 4. 优先级与指标
359 | */
360 |
361 | /**
362 | * 4.1 获取文档的优先级数据
363 | * 读取文档属性中存储的指标值,并自动修复空值或无效值
364 | *
365 | * @param docId 文档ID
366 | * @returns 文档优先级数据对象
367 | */
368 | public async getDocPriorityData(docId: string): Promise {
369 | try {
370 | // 4.1.1 获取文档属性
371 | const attrs = await this.pluginInstance.kernelApi.getBlockAttrs(docId)
372 | const data = attrs.data || attrs
373 |
374 | // 4.1.2 准备文档数据对象
375 | const docData: DocPriorityData = {
376 | docId,
377 | metrics: {}
378 | }
379 |
380 | // 4.1.3 跟踪需要更新的指标
381 | const metricsToUpdate: { [key: string]: string } = {}
382 |
383 | // 4.1.4 获取每个指标的值并检查修复
384 | for (const metric of this.incrementalConfig.metrics) {
385 | const attrKey = `custom-metric-${metric.id}`
386 | const rawValue = data[attrKey]
387 | let metricValue: number
388 |
389 | // 4.1.4.1 检查指标是否为空或0,设置默认值
390 | if (!rawValue || rawValue === '' || parseFloat(rawValue) === 0) {
391 | metricValue = 5.0
392 | metricsToUpdate[attrKey] = metricValue.toFixed(4)
393 | this.pluginInstance.logger.info(`文档 ${docId} 的指标 ${metric.id} 为空或0,将设置为默认值5.0`)
394 | } else {
395 | metricValue = parseFloat(rawValue)
396 | }
397 |
398 | docData.metrics[metric.id] = metricValue
399 | }
400 |
401 | // 4.1.5 找出不属于当前配置的多余指标并准备删除
402 | const currentMetricKeys = this.incrementalConfig.metrics.map(m => `custom-metric-${m.id}`)
403 | const allMetricKeys = Object.keys(data).filter(key => key.startsWith('custom-metric-'))
404 |
405 | for (const key of allMetricKeys) {
406 | if (!currentMetricKeys.includes(key)) {
407 | metricsToUpdate[key] = '' // 将值设为空字符串相当于删除
408 | this.pluginInstance.logger.info(`删除文档 ${docId} 的无效指标 ${key}`)
409 | }
410 | }
411 |
412 | // 4.1.6 如果有需要更新的指标,执行更新
413 | if (Object.keys(metricsToUpdate).length > 0) {
414 | try {
415 | await this.pluginInstance.kernelApi.setBlockAttrs(docId, metricsToUpdate)
416 | this.pluginInstance.logger.info(`已更新文档 ${docId} 的 ${Object.keys(metricsToUpdate).length} 个指标`)
417 | } catch (updateError) {
418 | this.pluginInstance.logger.error(`更新文档 ${docId} 的指标失败`, updateError)
419 | // 即使更新失败,也继续返回读取到的数据
420 | }
421 | }
422 |
423 | return docData
424 | } catch (error) {
425 | this.pluginInstance.logger.error(`获取文档 ${docId} 的优先级数据失败`, error)
426 | // 4.1.7 返回默认数据
427 | return {
428 | docId,
429 | metrics: this.incrementalConfig.metrics.reduce((obj, metric) => {
430 | obj[metric.id] = 5.0
431 | return obj
432 | }, {})
433 | }
434 | }
435 | }
436 |
437 | /**
438 | * 4.2 更新文档的指标值
439 | *
440 | * @param docId 文档ID
441 | * @param metricId 指标ID
442 | * @param value 新的指标值
443 | */
444 | public async updateDocMetric(docId: string, metricId: string, value: number): Promise {
445 | try {
446 | // 4.2.1 确保值在0-10之间
447 | const clampedValue = Math.max(0, Math.min(10, value))
448 |
449 | // 4.2.2 更新文档属性
450 | await this.pluginInstance.kernelApi.setBlockAttrs(docId, {
451 | [`custom-metric-${metricId}`]: clampedValue.toFixed(4)
452 | })
453 |
454 | showMessage(`已更新指标: ${metricId} = ${clampedValue.toFixed(4)}`, 2000)
455 | } catch (error) {
456 | this.pluginInstance.logger.error(`更新文档 ${docId} 的指标 ${metricId} 失败`, error)
457 | showMessage(`更新指标失败: ${error.message}`, 5000, "error")
458 | }
459 | }
460 |
461 | /**
462 | * 4.3 修复所有文档的指标
463 | * 将空值或0值设为默认值5,删除多余指标
464 | *
465 | * @param progressCallback 可选的进度回调函数
466 | * @returns 修复结果统计信息
467 | */
468 | public async repairAllDocumentMetrics(
469 | progressCallback?: (current: number, total: number) => void
470 | ): Promise<{
471 | totalDocs: number,
472 | updatedDocs: number,
473 | updatedMetrics: { id: string, name: string, count: number }[],
474 | deletedMetricsCount: number
475 | }> {
476 | try {
477 | // 4.3.1 使用空过滤条件,处理所有文档
478 | const filterCondition = this.buildEmptyFilterCondition()
479 | this.pluginInstance.logger.info("修复指标: 使用空过滤条件,将处理所有文档")
480 |
481 | // 4.3.2 初始化统计变量
482 | let totalUpdatedDocs = 0
483 | let updatedMetricsMap = new Map()
484 | let totalDeletedMetrics = 0
485 |
486 | // 4.3.3 初始化指标统计计数器
487 | this.incrementalConfig.metrics.forEach(metric => {
488 | updatedMetricsMap.set(metric.id, { id: metric.id, name: metric.name, count: 0 })
489 | })
490 |
491 | // 4.3.4 获取符合条件的文档总数
492 | const countSql = `
493 | SELECT COUNT(id) AS total FROM blocks
494 | WHERE type = 'd'
495 | ${filterCondition}
496 | `
497 |
498 | const countResult = await this.pluginInstance.kernelApi.sql(countSql)
499 | if (countResult.code !== 0) {
500 | throw new Error(countResult.msg)
501 | }
502 |
503 | const totalDocCount = countResult.data?.[0]?.total || 0
504 | this.pluginInstance.logger.info(`符合条件的文档总数: ${totalDocCount}`)
505 |
506 | if (totalDocCount === 0) {
507 | return { totalDocs: 0, updatedDocs: 0, updatedMetrics: [], deletedMetricsCount: 0 }
508 | }
509 |
510 | // 4.3.5 使用分页查询处理所有文档
511 | const pageSize = 100 // 每页处理100个文档
512 | let processedCount = 0
513 | let allDocs = []
514 |
515 | // 4.3.6 显示处理范围提示
516 | showMessage(`将处理所有文档的指标 (共${totalDocCount}篇)`, 3000, "info")
517 |
518 | // 4.3.7 获取所有文档ID
519 | for (let offset = 0; offset < totalDocCount; offset += pageSize) {
520 | // 4.3.7.1 使用分页查询获取文档
521 | const pageSql = `
522 | SELECT id FROM blocks
523 | WHERE type = 'd'
524 | ${filterCondition}
525 | LIMIT ${pageSize} OFFSET ${offset}
526 | `
527 |
528 | const pageResult = await this.pluginInstance.kernelApi.sql(pageSql)
529 | if (pageResult.code !== 0) {
530 | throw new Error(pageResult.msg)
531 | }
532 |
533 | const pageDocs = pageResult.data as any[] || []
534 | allDocs = allDocs.concat(pageDocs)
535 | this.pluginInstance.logger.info(`获取分页 ${Math.floor(offset/pageSize) + 1}/${Math.ceil(totalDocCount/pageSize)},共 ${pageDocs.length} 篇文档`)
536 | }
537 |
538 | this.pluginInstance.logger.info(`总共获取 ${allDocs.length} 篇文档,将检查指标完整性`)
539 |
540 | // 4.3.8 顺序处理每篇文档的指标
541 | for (let i = 0; i < allDocs.length; i++) {
542 | const doc = allDocs[i]
543 |
544 | // 4.3.8.1 更新进度
545 | if (progressCallback) {
546 | progressCallback(i + 1, allDocs.length)
547 | }
548 |
549 | // 4.3.8.2 定期更新进度提示
550 | if (i % 50 === 0 || i === allDocs.length - 1) {
551 | showMessage(`正在处理文档指标: ${i+1}/${allDocs.length}`, 1000, "info")
552 | }
553 |
554 | // 4.3.8.3 获取文档当前的所有属性
555 | const attrs = await this.pluginInstance.kernelApi.getBlockAttrs(doc.id)
556 | const data = attrs.data || attrs
557 |
558 | // 4.3.8.4 统计需要更新的指标
559 | const metricsToUpdate: { [key: string]: string } = {}
560 | let docUpdated = false
561 |
562 | // 4.3.8.5 检查每个当前配置中的指标
563 | for (const metric of this.incrementalConfig.metrics) {
564 | const attrKey = `custom-metric-${metric.id}`
565 | const rawValue = data[attrKey]
566 |
567 | // 4.3.8.6 检查指标是否为空或0
568 | if (!rawValue || rawValue === '' || parseFloat(rawValue) === 0) {
569 | const defaultValue = 5.0
570 | metricsToUpdate[attrKey] = defaultValue.toFixed(4)
571 |
572 | // 4.3.8.7 更新统计信息
573 | if (updatedMetricsMap.has(metric.id)) {
574 | updatedMetricsMap.get(metric.id).count++
575 | docUpdated = true
576 | }
577 | }
578 | }
579 |
580 | // 4.3.8.8 找出不属于当前配置的多余指标
581 | const currentMetricKeys = this.incrementalConfig.metrics.map(m => `custom-metric-${m.id}`)
582 | const allMetricKeys = Object.keys(data).filter(key => key.startsWith('custom-metric-'))
583 | const invalidMetrics = allMetricKeys.filter(key => !currentMetricKeys.includes(key))
584 |
585 | // 4.3.8.9 删除无效指标
586 | for (const key of invalidMetrics) {
587 | metricsToUpdate[key] = '' // 将值设为空字符串相当于删除
588 | totalDeletedMetrics++
589 | docUpdated = true
590 | }
591 |
592 | // 4.3.8.10 如果有需要更新的指标,执行更新
593 | if (Object.keys(metricsToUpdate).length > 0) {
594 | try {
595 | await this.pluginInstance.kernelApi.setBlockAttrs(doc.id, metricsToUpdate)
596 | this.pluginInstance.logger.info(
597 | `已更新文档 ${doc.id} 的指标 [${i+1}/${allDocs.length}]: ` +
598 | `新增/修复 ${Object.keys(metricsToUpdate).filter(k => metricsToUpdate[k] !== '').length}个, ` +
599 | `删除 ${invalidMetrics.length}个`
600 | )
601 | } catch (updateError) {
602 | this.pluginInstance.logger.error(`更新文档 ${doc.id} 的指标失败 [${i+1}/${allDocs.length}]`, updateError)
603 | }
604 | }
605 |
606 | // 4.3.8.11 如果文档有更新,计数加1
607 | if (docUpdated) {
608 | totalUpdatedDocs++
609 | }
610 | }
611 |
612 | // 4.3.9 完成后显示结果
613 | showMessage(`指标修复完成! 处理了 ${allDocs.length} 篇文档,更新了 ${totalUpdatedDocs} 篇`, 5000, "info")
614 |
615 | // 4.3.10 返回统计结果
616 | return {
617 | totalDocs: allDocs.length,
618 | updatedDocs: totalUpdatedDocs,
619 | updatedMetrics: Array.from(updatedMetricsMap.values()).filter(m => m.count > 0),
620 | deletedMetricsCount: totalDeletedMetrics
621 | }
622 | } catch (error) {
623 | this.pluginInstance.logger.error("修复文档指标失败", error)
624 | showMessage(`修复文档指标失败: ${error.message}`, 5000, "error")
625 | throw error
626 | }
627 | }
628 |
629 | /**
630 | * 4.4 获取当前所有指标
631 | *
632 | * @returns 指标列表
633 | */
634 | getMetrics(): Metric[] {
635 | return this.incrementalConfig.metrics
636 | }
637 |
638 | /**
639 | * 4.5 添加指标
640 | *
641 | * @param metric 要添加的指标
642 | */
643 | async addMetric(metric: Metric): Promise {
644 | this.incrementalConfig.addMetric(metric)
645 | await this.saveIncrementalConfig()
646 | }
647 |
648 | /**
649 | * 4.6 删除指标
650 | *
651 | * @param metricId 要删除的指标ID
652 | */
653 | async removeMetric(metricId: string): Promise {
654 | this.incrementalConfig.removeMetric(metricId)
655 | await this.saveIncrementalConfig()
656 | }
657 |
658 | /**
659 | * 4.7 更新指标
660 | *
661 | * @param metricId 要更新的指标ID
662 | * @param updates 指标更新内容
663 | */
664 | async updateMetric(metricId: string, updates: Partial): Promise {
665 | this.incrementalConfig.updateMetric(metricId, updates)
666 | await this.saveIncrementalConfig()
667 | }
668 |
669 | /**
670 | * 4.8 计算文档的优先级
671 | *
672 | * @param docData 文档优先级数据
673 | * @returns 优先级计算结果
674 | */
675 | private async calculatePriority(docData: DocPriorityData): Promise<{ priority: number }> {
676 | // 直接使用incrementalConfig计算优先级,不再进行指标修复
677 | return this.incrementalConfig.calculatePriority(docData);
678 | }
679 |
680 | /**
681 | * 5. 轮盘赌算法
682 | */
683 |
684 | /**
685 | * 5.1 轮盘赌选择算法
686 | * 根据文档优先级权重随机选择一篇文档
687 | *
688 | * @param items 文档列表及其优先级
689 | * @returns 选中的文档ID
690 | */
691 | private rouletteWheelSelection(items: { docId: string, priority: number }[]): string {
692 | // 5.1.1 严格校验输入
693 | if (!items || items.length === 0) {
694 | throw new Error("轮盘赌选择算法需要至少一个项目")
695 | }
696 |
697 | this.pluginInstance.logger.info(`----------------轮盘赌选择过程----------------`)
698 | this.pluginInstance.logger.info(`总文档数: ${items.length}`)
699 |
700 | // 5.1.2 计算总优先级(使用高精度计算)
701 | const totalPriority = items.reduce((sum, item) => {
702 | // 5.1.2.1 确保每个优先级都是有效数字
703 | if (typeof item.priority !== 'number' || isNaN(item.priority)) {
704 | throw new Error(`文档 ${item.docId} 的优先级值无效: ${item.priority}`)
705 | }
706 | if (item.priority < 0) {
707 | throw new Error(`文档 ${item.docId} 的优先级值为负数: ${item.priority}`)
708 | }
709 | return sum + item.priority
710 | }, 0)
711 |
712 | // 5.1.3 检查总优先级
713 | if (totalPriority === 0) {
714 | throw new Error("所有文档的总优先级为0,无法使用轮盘赌算法选择")
715 | }
716 |
717 | this.pluginInstance.logger.info(`文档总优先级值: ${totalPriority.toFixed(6)}`)
718 |
719 | // 5.1.4 生成随机数
720 | const random = Math.random() * totalPriority
721 | this.pluginInstance.logger.info(`生成随机数: ${random.toFixed(6)} (范围: 0 - ${totalPriority.toFixed(6)})`)
722 |
723 | // 5.1.5 记录文档概率分布
724 | this.pluginInstance.logger.info(`文档概率分布:`)
725 | // 5.1.5.1 按概率从高到低排序
726 | const sortedItems = [...items].sort((a, b) => b.priority - a.priority);
727 |
728 | // 5.1.6 显示前5个最高概率的文档
729 | this.pluginInstance.logger.info(`最高概率的5个文档:`)
730 | for (let i = 0; i < Math.min(5, sortedItems.length); i++) {
731 | const item = sortedItems[i];
732 | const ratio = (item.priority / totalPriority) * 100;
733 | this.pluginInstance.logger.info(`[${i+1}] 文档ID: ${item.docId.substring(0, 8)}..., 优先级: ${item.priority.toFixed(4)}, 概率: ${ratio.toFixed(4)}%`);
734 | }
735 |
736 | // 5.1.7 显示总体概率分布
737 | this.pluginInstance.logger.info(`文档概率分布统计:`)
738 | const highProb = items.filter(item => (item.priority / totalPriority) * 100 > 10).length;
739 | const medProb = items.filter(item => {
740 | const p = (item.priority / totalPriority) * 100;
741 | return p <= 10 && p > 1;
742 | }).length;
743 | const lowProb = items.filter(item => {
744 | const p = (item.priority / totalPriority) * 100;
745 | return p <= 1 && p > 0.1;
746 | }).length;
747 | const veryLowProb = items.filter(item => (item.priority / totalPriority) * 100 <= 0.1).length;
748 |
749 | this.pluginInstance.logger.info(`- 高概率(>10%): ${highProb}个文档`);
750 | this.pluginInstance.logger.info(`- 中等概率(1%-10%): ${medProb}个文档`);
751 | this.pluginInstance.logger.info(`- 低概率(0.1%-1%): ${lowProb}个文档`);
752 | this.pluginInstance.logger.info(`- 极低概率(<=0.1%): ${veryLowProb}个文档`);
753 |
754 | // 5.1.8 向用户界面显示概率分布统计
755 | const distributionInfo = `
756 | 文档概率分布统计:
757 | - 高概率(>10%): ${highProb}个文档
758 | - 中等概率(1%-10%): ${medProb}个文档
759 | - 低概率(0.1%-1%): ${lowProb}个文档
760 | - 极低概率(<=0.1%): ${veryLowProb}个文档
761 | 共${items.length}个文档
762 | `.trim();
763 |
764 | showMessage(distributionInfo, 5000, "info");
765 |
766 | // 5.1.9 执行轮盘赌选择
767 | let accumulatedPriority = 0
768 | for (const item of items) {
769 | accumulatedPriority += item.priority
770 |
771 | // 5.1.9.1 精确比较,避免浮点数精度问题
772 | if (random <= accumulatedPriority || Math.abs(random - accumulatedPriority) < 1e-10) {
773 | const ratio = (item.priority / totalPriority) * 100;
774 | this.pluginInstance.logger.info(`选中文档: ${item.docId}`);
775 | this.pluginInstance.logger.info(`选中文档优先级: ${item.priority.toFixed(6)}`);
776 | this.pluginInstance.logger.info(`选中文档概率: ${ratio.toFixed(6)}%`);
777 | this.pluginInstance.logger.info(`累积优先级值: ${accumulatedPriority.toFixed(6)}`);
778 | this.pluginInstance.logger.info(`随机值(${random.toFixed(6)}) <= 累积优先级(${accumulatedPriority.toFixed(6)}), 因此选中当前文档`);
779 | this.pluginInstance.logger.info(`------------------------------------------------`);
780 | return item.docId
781 | }
782 | }
783 |
784 | // 5.1.10 处理浮点数精度边缘情况
785 | const lastItem = items[items.length - 1]
786 |
787 | // 5.1.10.1 验证累积概率误差是否在可接受范围内
788 | const errorMargin = Math.abs(accumulatedPriority - totalPriority)
789 | if (errorMargin < 1e-10) {
790 | this.pluginInstance.logger.warn(`由于浮点数精度问题(误差:${errorMargin.toExponential(6)}),选择最后一个文档: ${lastItem.docId}`)
791 | return lastItem.docId
792 | }
793 |
794 | // 5.1.10.2 处理算法异常
795 | throw new Error(`轮盘赌选择算法未能选择文档,随机值: ${random},总优先级: ${totalPriority},累积优先级: ${accumulatedPriority},误差: ${errorMargin}`)
796 | }
797 |
798 | /**
799 | * 5.2 计算文档在当前优先级配置下被选中的概率
800 | *
801 | * @param docPriority 文档的优先级
802 | * @param totalPriority 所有文档的总优先级
803 | * @returns 选中概率的百分比
804 | */
805 | public calculateSelectionProbability(docPriority: number, totalPriority: number): number {
806 | // 5.2.1 严格校验输入
807 | if (typeof docPriority !== 'number' || isNaN(docPriority)) {
808 | throw new Error(`无效的文档优先级值: ${docPriority}`)
809 | }
810 |
811 | if (docPriority < 0) {
812 | throw new Error(`文档优先级不能为负数: ${docPriority}`)
813 | }
814 |
815 | if (typeof totalPriority !== 'number' || isNaN(totalPriority)) {
816 | throw new Error(`无效的总优先级值: ${totalPriority}`)
817 | }
818 |
819 | if (totalPriority <= 0) {
820 | throw new Error(`总优先级必须大于0: ${totalPriority}`)
821 | }
822 |
823 | // 5.2.2 记录输入值
824 | this.pluginInstance.logger.info(`概率计算详细信息:`)
825 | this.pluginInstance.logger.info(`- 文档优先级(docPriority): ${docPriority.toFixed(6)}`)
826 | this.pluginInstance.logger.info(`- 总优先级(totalPriority): ${totalPriority.toFixed(6)}`)
827 |
828 | // 5.2.3 严格按照数学公式计算概率
829 | const probability = (docPriority / totalPriority) * 100
830 |
831 | // 5.2.4 记录计算结果
832 | this.pluginInstance.logger.info(`- 原始计算结果: ${probability.toFixed(6)}%`)
833 | this.pluginInstance.logger.info(`- 四位小数格式: ${probability.toFixed(4)}%`)
834 |
835 | // 5.2.5 向用户界面显示计算过程提示信息
836 | const probabilityInfo = `
837 | 概率计算过程:
838 | 文档优先级: ${docPriority.toFixed(2)}
839 | 总体优先级: ${totalPriority.toFixed(2)}
840 | 计算结果: (${docPriority.toFixed(2)} / ${totalPriority.toFixed(2)}) × 100 = ${probability.toFixed(4)}%
841 | `.trim();
842 |
843 | showMessage(probabilityInfo, 5000, "info");
844 |
845 | // 5.2.6 返回原始计算结果,不进行任何约束或舍入
846 | return probability
847 | }
848 |
849 | /**
850 | * 5.3 获取最近选中文档的概率
851 | *
852 | * @returns 选中概率的百分比
853 | */
854 | private _lastSelectionProbability: number = null
855 |
856 | public getLastSelectionProbability(): number {
857 | if (this._lastSelectionProbability === null) {
858 | throw new Error("概率值尚未计算")
859 | }
860 | return this._lastSelectionProbability
861 | }
862 |
863 | /**
864 | * 6. 访问记录
865 | */
866 |
867 | /**
868 | * 6.1 更新文档的访问次数
869 | *
870 | * @param docId 文档ID
871 | */
872 | private async updateVisitCount(docId: string): Promise {
873 | try {
874 | // 6.1.1 获取当前访问次数
875 | const attrs = await this.pluginInstance.kernelApi.getBlockAttrs(docId)
876 | const data = attrs.data || attrs
877 | const currentCount = parseInt(data["custom-visit-count"] || "0", 10)
878 |
879 | // 6.1.2 递增并更新访问次数
880 | await this.pluginInstance.kernelApi.setBlockAttrs(docId, {
881 | "custom-visit-count": (currentCount + 1).toString()
882 | })
883 | } catch (error) {
884 | this.pluginInstance.logger.error(`更新文档 ${docId} 的访问次数失败`, error)
885 | }
886 | }
887 |
888 | /**
889 | * 6.2 重置所有文档的访问记录
890 | *
891 | * @param filterCondition 可选的过滤条件
892 | */
893 | public async resetTodayVisits(filterCondition: string = ""): Promise {
894 | try {
895 | // 6.2.1 查找所有已访问的文档
896 | const sql = `
897 | SELECT id FROM blocks
898 | WHERE type = 'd'
899 | ${filterCondition}
900 | AND id IN (
901 | SELECT block_id FROM attributes
902 | WHERE name = 'custom-visit-count'
903 | AND value <> ''
904 | )
905 | `
906 |
907 | const result = await this.pluginInstance.kernelApi.sql(sql)
908 | if (result.code !== 0) {
909 | this.pluginInstance.logger.error(`SQL查询失败,错误码: ${result.code}, 错误信息: ${result.msg}`)
910 | showMessage("获取文档失败: " + result.msg, 7000, "error")
911 | throw new Error(result.msg)
912 | }
913 |
914 | const docs = result.data as any[]
915 | if (!docs || docs.length === 0) {
916 | this.pluginInstance.logger.info("没有访问记录需要重置")
917 | showMessage("没有访问记录需要重置", 3000)
918 | return
919 | }
920 |
921 | this.pluginInstance.logger.info(`找到 ${docs.length} 篇需要重置访问记录的文档`)
922 |
923 | // 6.2.2 重置所有文档的访问记录
924 | let successCount = 0
925 | for (const doc of docs) {
926 | try {
927 | await this.pluginInstance.kernelApi.setBlockAttrs(doc.id, {
928 | "custom-visit-count": ""
929 | })
930 | successCount++
931 | } catch (error) {
932 | this.pluginInstance.logger.error(`重置文档 ${doc.id} 的访问记录失败`, error)
933 | }
934 | }
935 |
936 | // 6.2.3 检查重置结果
937 | if (successCount === 0) {
938 | throw new Error("重置所有文档的访问记录都失败了")
939 | }
940 |
941 | this.pluginInstance.logger.info(`成功重置 ${successCount}/${docs.length} 篇文档的访问记录`)
942 | showMessage(`已重置 ${successCount} 篇文档的访问记录`, 3000)
943 | } catch (error) {
944 | this.pluginInstance.logger.error("重置访问记录失败", error)
945 | showMessage(`重置失败: ${error.message}`, 5000, "error")
946 | throw error
947 | }
948 | }
949 |
950 | /**
951 | * 6.3 获取今天已访问的文档数量
952 | *
953 | * @returns 已访问文档数量
954 | */
955 | public async getTodayVisitedCount(): Promise {
956 | try {
957 | const filterCondition = this.buildFilterCondition()
958 |
959 | // 6.3.1 构建SQL查询已访问文档
960 | const sql = `
961 | SELECT COUNT(id) AS total FROM blocks
962 | WHERE type = 'd'
963 | ${filterCondition}
964 | AND id IN (
965 | SELECT block_id FROM attributes
966 | WHERE name = 'custom-visit-count'
967 | AND value <> ''
968 | )
969 | `
970 |
971 | const result = await this.pluginInstance.kernelApi.sql(sql)
972 | if (result.code !== 0) {
973 | this.pluginInstance.logger.error(`获取已访问文档数量失败,错误码: ${result.code}, 错误信息: ${result.msg}`)
974 | return 0
975 | }
976 |
977 | return result.data?.[0]?.total || 0
978 | } catch (error) {
979 | this.pluginInstance.logger.error("获取已访问文档数量失败", error)
980 | return 0
981 | }
982 | }
983 |
984 | /**
985 | * 6.4 保存漫游历史
986 | *
987 | * @param docId 文档ID
988 | * @param docTitle 文档标题
989 | * @param probability 选中概率
990 | */
991 | public async saveRoamingHistory(docId: string, docTitle: string, probability: number): Promise {
992 | try {
993 | // 6.4.1 获取现有历史记录
994 | const history = await this.getRoamingHistory()
995 |
996 | // 6.4.2 添加新记录到最前面(最新的记录在前)
997 | history.unshift({
998 | id: docId,
999 | title: docTitle,
1000 | probability: probability.toFixed(4),
1001 | timestamp: new Date().toISOString()
1002 | })
1003 |
1004 | // 6.4.3 限制历史记录数量,保留最近100条
1005 | const limitedHistory = history.slice(0, 100)
1006 |
1007 | // 6.4.4 保存到存储
1008 | await this.pluginInstance.saveData('roaming_history', limitedHistory)
1009 | } catch (error) {
1010 | this.pluginInstance.logger.error("保存漫游历史失败:", error)
1011 | }
1012 | }
1013 |
1014 | /**
1015 | * 6.5 获取漫游历史
1016 | *
1017 | * @returns 漫游历史记录数组
1018 | */
1019 | public async getRoamingHistory(): Promise> {
1025 | try {
1026 | const history = await this.pluginInstance.safeLoad('roaming_history')
1027 | return Array.isArray(history) ? history : []
1028 | } catch (error) {
1029 | this.pluginInstance.logger.error("获取漫游历史失败:", error)
1030 | return []
1031 | }
1032 | }
1033 |
1034 | /**
1035 | * 6.6 清除漫游历史
1036 | */
1037 | public async clearRoamingHistory(): Promise {
1038 | try {
1039 | await this.pluginInstance.saveData('roaming_history', [])
1040 | this.pluginInstance.logger.info("漫游历史已清除")
1041 | } catch (error) {
1042 | this.pluginInstance.logger.error("清除漫游历史失败:", error)
1043 | throw error
1044 | }
1045 | }
1046 |
1047 | /**
1048 | * 7. 工具方法
1049 | */
1050 |
1051 | /**
1052 | * 7.1 构建过滤条件
1053 | *
1054 | * @param config 可选的配置对象
1055 | * @returns 构建的SQL过滤条件
1056 | */
1057 | public buildFilterCondition(config?: RandomDocConfig): string {
1058 | // 7.1.1 使用传入的配置或当前实例的最新配置
1059 | const targetConfig = config || this.storeConfig
1060 |
1061 | // 7.1.2 从配置中获取过滤模式和相关ID
1062 | const filterMode = targetConfig.filterMode || FilterMode.Notebook
1063 | const notebookId = targetConfig.notebookId || ""
1064 | const rootId = targetConfig.rootId || ""
1065 |
1066 | let condition = ""
1067 | if (filterMode === FilterMode.Notebook && notebookId) {
1068 | this.pluginInstance.logger.info(`应用笔记本过滤,笔记本ID: ${notebookId}`)
1069 | condition = `AND box = '${notebookId}'`
1070 | } else if (filterMode === FilterMode.Root && rootId) {
1071 | this.pluginInstance.logger.info(`应用根文档过滤,根文档ID: ${rootId}`)
1072 | condition = `AND path LIKE '%${rootId}%'`
1073 | }
1074 |
1075 | return condition
1076 | }
1077 |
1078 | /**
1079 | * 7.2 构建一个空的过滤条件
1080 | * 用于处理所有文档,不使用任何筛选条件
1081 | *
1082 | * @returns 空过滤条件
1083 | */
1084 | private buildEmptyFilterCondition(): string {
1085 | return ""
1086 | }
1087 | }
1088 |
1089 | export { IncrementalReviewer }
1090 | export default IncrementalReviewer
--------------------------------------------------------------------------------
/src/topbar.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * ========================================
3 | * 漫游式渐进阅读插件 - 顶栏组件
4 | * ========================================
5 | *
6 | * 本文件实现了插件在思源笔记顶栏的按钮及其功能,是用户与插件交互的主要入口。
7 | *
8 | * ## 文件结构
9 | * 1. 顶栏按钮初始化 - 创建并配置顶栏按钮
10 | * 2. 上下文菜单与设置 - 处理右键菜单和设置界面
11 | * 3. 文档漫游触发 - 实现文档漫游功能的核心逻辑
12 | * 4. 快捷键注册 - 配置并注册插件快捷键
13 | */
14 |
15 | import RandomDocPlugin from "./index"
16 | import { icons } from "./utils/svg"
17 | import { Dialog, Menu, openTab, showMessage } from "siyuan"
18 | import RandomDocContent from "./libs/RandomDocContent.svelte"
19 | import RandomDocSetting from "./libs/RandomDocSetting.svelte"
20 | import { ReviewMode } from "./models/RandomDocConfig"
21 | import { storeName } from "./Constants"
22 |
23 | /**
24 | * 1. 初始化顶栏按钮
25 | * 创建顶栏图标并添加点击与右键菜单事件监听
26 | *
27 | * @param pluginInstance 插件实例
28 | */
29 | export async function initTopbar(pluginInstance: RandomDocPlugin) {
30 | // 1.1 定义自定义标签页类型标识
31 | const TAB_TYPE = "random_doc_custom_tab"
32 |
33 | // 1.2 注册自定义标签页
34 | pluginInstance.customTabObject = pluginInstance.addTab({
35 | type: TAB_TYPE,
36 | async init() {},
37 | beforeDestroy() {
38 | // 1.2.1 清理标签页实例引用
39 | delete pluginInstance.tabInstance
40 | pluginInstance.logger.info("tabInstance destroyed")
41 | },
42 | })
43 |
44 | // 1.3 创建顶栏按钮
45 | const topBarElement = pluginInstance.addTopBar({
46 | icon: icons.iconTopbar,
47 | title: pluginInstance.i18n.randomDoc,
48 | position: "right",
49 | callback: () => {},
50 | })
51 |
52 | // 1.4 添加左键点击事件监听
53 | topBarElement.addEventListener("click", async () => {
54 | await triggerRandomDoc(pluginInstance)
55 | })
56 |
57 | // 1.5 添加右键菜单事件监听
58 | topBarElement.addEventListener("contextmenu", () => {
59 | let rect = topBarElement.getBoundingClientRect()
60 | // 1.5.1 如果获取不到宽度,则使用更多按钮的宽度
61 | if (rect.width === 0) {
62 | rect = document.querySelector("#barMore").getBoundingClientRect()
63 | }
64 | initContextMenu(pluginInstance, rect)
65 | })
66 | }
67 |
68 | /**
69 | * 2. 初始化上下文菜单
70 | * 创建右键菜单
71 | *
72 | * @param pluginInstance 插件实例
73 | * @param rect 菜单位置矩形
74 | */
75 | const initContextMenu = async (pluginInstance: RandomDocPlugin, rect: DOMRect) => {
76 | // 2.1 直接调用设置菜单,不显示额外的右键菜单
77 | showSettingMenu(pluginInstance)
78 | }
79 |
80 | /**
81 | * 2.2 显示设置菜单
82 | * 创建并显示插件设置对话框
83 | *
84 | * @param pluginInstance 插件实例
85 | */
86 | export const showSettingMenu = (pluginInstance: RandomDocPlugin) => {
87 | // 2.2.1 设置对话框元素ID
88 | const settingId = "siyuan-random-doc-setting"
89 |
90 | // 2.2.2 创建对话框
91 | const d = new Dialog({
92 | title: `${pluginInstance.i18n.setting} - ${pluginInstance.i18n.randomDoc}`,
93 | content: `
`,
94 | width: pluginInstance.isMobile ? "92vw" : "720px",
95 | })
96 |
97 | // 2.2.3 实例化设置组件
98 | new RandomDocSetting({
99 | target: document.getElementById(settingId) as HTMLElement,
100 | props: {
101 | pluginInstance: pluginInstance,
102 | dialog: d,
103 | },
104 | })
105 | }
106 |
107 | /**
108 | * 3. 触发文档漫游
109 | * 打开漫游标签页并启动漫游功能
110 | *
111 | * @param pluginInstance 插件实例
112 | */
113 | const triggerRandomDoc = async (pluginInstance: RandomDocPlugin) => {
114 | // 3.1 检查标签页是否已存在
115 | if (!pluginInstance.tabInstance) {
116 | // 3.1.1 创建新标签页
117 | const tabInstance = openTab({
118 | app: pluginInstance.app,
119 | custom: {
120 | title: pluginInstance.i18n.randomDoc,
121 | icon: "iconRefresh",
122 | fn: pluginInstance.customTabObject,
123 | } as any,
124 | })
125 |
126 | // 3.1.2 处理Promise或直接对象返回
127 | if (tabInstance instanceof Promise) {
128 | pluginInstance.tabInstance = await tabInstance
129 | } else {
130 | pluginInstance.tabInstance = tabInstance
131 | }
132 |
133 | // 3.1.3 在标签页中加载漫游组件
134 | pluginInstance.tabContentInstance = new RandomDocContent({
135 | target: pluginInstance.tabInstance.panelElement as HTMLElement,
136 | props: {
137 | pluginInstance: pluginInstance,
138 | },
139 | })
140 | } else {
141 | // 3.2 标签页已存在,根据模式继续漫游
142 | if (pluginInstance.tabContentInstance.doIncrementalRandomDoc &&
143 | (await pluginInstance.safeLoad(storeName)).reviewMode === ReviewMode.Incremental) {
144 | // 3.2.1 渐进模式漫游
145 | await pluginInstance.tabContentInstance.doIncrementalRandomDoc()
146 | } else {
147 | // 3.2.2 一遍过模式漫游
148 | await pluginInstance.tabContentInstance.doRandomDoc()
149 | }
150 | pluginInstance.logger.info("再次点击或者重复触发")
151 | }
152 | }
153 |
154 | /**
155 | * 4. 注册快捷键
156 | * 为插件功能注册快捷键
157 | *
158 | * @param pluginInstance 插件实例
159 | */
160 | export async function registerCommand(pluginInstance: RandomDocPlugin) {
161 | // 4.1 注册漫游快捷键
162 | pluginInstance.addCommand({
163 | langKey: "startRandomDoc",
164 | hotkey: "⌥⌘M",
165 | callback: async () => {
166 | pluginInstance.logger.info("快捷键已触发 ⌥⌘M")
167 | await triggerRandomDoc(pluginInstance)
168 | },
169 | })
170 | pluginInstance.logger.info("文档漫步快捷键已注册为 ⌥⌘M")
171 |
172 | // 4.2 注册重置今日漫游记录快捷键
173 | pluginInstance.addCommand({
174 | langKey: "resetAllVisits",
175 | hotkey: "⌥⌘V",
176 | callback: async () => {
177 | pluginInstance.logger.info("快捷键已触发 ⌥⌘V")
178 | // 4.2.1 调用重置并刷新方法
179 | if (pluginInstance.tabContentInstance && pluginInstance.tabContentInstance.resetAndRefresh) {
180 | await pluginInstance.tabContentInstance.resetAndRefresh()
181 | } else {
182 | showMessage("请先打开漫游面板", 3000)
183 | }
184 | }
185 | })
186 | pluginInstance.logger.info("重置所有访问记录快捷键已注册为⌥⌘V")
187 | }
188 |
--------------------------------------------------------------------------------
/src/utils/pageUtil.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2025, ebAobS . All rights reserved.
3 | * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 | *
5 | * This code is free software; you can redistribute it and/or modify it
6 | * under the terms of the GNU General Public License version 2 only, as
7 | * published by the Free Software Foundation. ebAobS designates this
8 | * particular file as subject to the "Classpath" exception as provided
9 | * by ebAobS in the LICENSE file that accompanied this code.
10 | *
11 | * This code is distributed in the hope that it will be useful, but WITHOUT
12 | * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 | * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14 | * version 2 for more details (a copy is included in the LICENSE file that
15 | * accompanied this code).
16 | *
17 | * You should have received a copy of the GNU General Public License version
18 | * 2 along with this work; if not, write to the Free Software Foundation,
19 | * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20 | *
21 | * Please contact ebAobS, ebAobs@outlook.com
22 | * or visit https://github.com/ebAobS/roaming-mode-incremental-reading if you need additional information or have any
23 | * questions.
24 | */
25 |
26 | /**
27 | * 文档工具类
28 | */
29 | class PageUtil {
30 | public static getPageId() {
31 | // 查找包含 protyle 类但不包含 fn__none 的 div 元素
32 | const protyleElement = document.querySelector("div.protyle:not(.fn__none)")
33 | // 在该 div 元素下查找包含 protyle-title 类的 div 元素,并查找 data-node-id 属性
34 | const protyleTitleElement = protyleElement?.querySelector("div.protyle-title")
35 | // 如果该元素存在 data-node-id 属性,则获取其值并返回,否则返回空字符串
36 | return protyleTitleElement?.hasAttribute("data-node-id") ? protyleTitleElement.getAttribute("data-node-id") : ""
37 | }
38 | }
39 |
40 | export default PageUtil
41 |
--------------------------------------------------------------------------------
/src/utils/svg.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2025, ebAobS . All rights reserved.
3 | * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 | *
5 | * This code is free software; you can redistribute it and/or modify it
6 | * under the terms of the GNU General Public License version 2 only, as
7 | * published by the Free Software Foundation. ebAobS designates this
8 | * particular file as subject to the "Classpath" exception as provided
9 | * by ebAobS in the LICENSE file that accompanied this code.
10 | *
11 | * This code is distributed in the hope that it will be useful, but WITHOUT
12 | * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 | * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14 | * version 2 for more details (a copy is included in the LICENSE file that
15 | * accompanied this code).
16 | *
17 | * You should have received a copy of the GNU General Public License version
18 | * 2 along with this work; if not, write to the Free Software Foundation,
19 | * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20 | *
21 | * Please contact ebAobS, ebAobs@outlook.com
22 | * or visit https://github.com/ebAobS/roaming-mode-incremental-reading if you need additional information or have any
23 | * questions.
24 | */
25 |
26 | export const icons = {
27 | iconTopbar: ` `,
28 | iconSetting: ` `,
29 | }
30 |
--------------------------------------------------------------------------------
/src/utils/utils.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2025, ebAobS . All rights reserved.
3 | * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 | *
5 | * This code is free software; you can redistribute it and/or modify it
6 | * under the terms of the GNU General Public License version 2 only, as
7 | * published by the Free Software Foundation. ebAobS designates this
8 | * particular file as subject to the "Classpath" exception as provided
9 | * by ebAobS in the LICENSE file that accompanied this code.
10 | *
11 | * This code is distributed in the hope that it will be useful, but WITHOUT
12 | * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 | * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14 | * version 2 for more details (a copy is included in the LICENSE file that
15 | * accompanied this code).
16 | *
17 | * You should have received a copy of the GNU General Public License version
18 | * 2 along with this work; if not, write to the Free Software Foundation,
19 | * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20 | *
21 | * Please contact ebAobS, ebAobs@outlook.com
22 | * or visit https://github.com/ebAobS/roaming-mode-incremental-reading if you need additional information or have any
23 | * questions.
24 | */
25 |
26 | import { HtmlUtil, StrUtil } from "zhi-common"
27 |
28 | /**
29 | * 清理字符串中的零宽字符和其他不可见字符
30 | * @param {string} str 原始字符串
31 | * @returns {string} 清理后的字符串
32 | */
33 | const cleanInvisibleChars = (str) => {
34 | if (typeof str !== "string") return ""
35 | return str
36 | .replace(/[\u200B-\u200D\uFEFF\u00A0]/g, "") // 零宽字符、不换行空格
37 | .trim()
38 | }
39 |
40 | /**
41 | * 严格判断字符串是否为空(清理后)
42 | * @param {string} str 原始字符串
43 | * @returns {boolean}
44 | */
45 | export const isContentEmpty = (str: string): boolean => {
46 | const plainContent = HtmlUtil.filterHtml(str)
47 | const cleanedStr = cleanInvisibleChars(plainContent)
48 | return StrUtil.isEmptyString(cleanedStr)
49 | }
50 |
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"
2 |
3 | export default {
4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
5 | // for more information about preprocessors
6 | preprocess: vitePreprocess(),
7 | }
8 |
--------------------------------------------------------------------------------
/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": false,
23 | "noUnusedParameters": false,
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 | },
40 | "include": [
41 | "tools/**/*.ts",
42 | "src/**/*.ts",
43 | "src/**/*.d.ts",
44 | "src/**/*.tsx",
45 | "src/**/*.vue"
46 | ],
47 | "references": [
48 | {
49 | "path": "./tsconfig.node.json"
50 | }
51 | ],
52 | "root": "."
53 | }
--------------------------------------------------------------------------------
/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 } from "vite"
3 | import minimist from "minimist"
4 | import { viteStaticCopy } from "vite-plugin-static-copy"
5 | import livereload from "rollup-plugin-livereload"
6 | import { svelte } from "@sveltejs/vite-plugin-svelte"
7 | import fg from "fast-glob"
8 |
9 | const args = minimist(process.argv.slice(2))
10 | const isWatch = args.watch || args.w || false
11 | const distDir = "./dist"
12 |
13 | console.log("isWatch=>", isWatch)
14 | console.log("distDir=>", distDir)
15 |
16 | export default defineConfig({
17 | plugins: [
18 | svelte(),
19 |
20 | viteStaticCopy({
21 | targets: [
22 | {
23 | src: "./README*.md",
24 | dest: "./",
25 | },
26 | {
27 | src: "./LICENSE",
28 | dest: "./",
29 | },
30 | {
31 | src: "./icon.png",
32 | dest: "./",
33 | },
34 | {
35 | src: "./preview.png",
36 | dest: "./",
37 | },
38 | {
39 | src: "./plugin.json",
40 | dest: "./",
41 | },
42 | {
43 | src: "./src/i18n/**",
44 | dest: "./i18n/",
45 | },
46 | ],
47 | }),
48 | ],
49 |
50 | // https://github.com/vitejs/vite/issues/1930
51 | // https://vitejs.dev/guide/env-and-mode.html#env-files
52 | // https://github.com/vitejs/vite/discussions/3058#discussioncomment-2115319
53 | // 在这里自定义变量
54 | define: {
55 | "process.env.NODE_ENV": isWatch ? `"development"` : `"production"`,
56 | "process.env.DEV_MODE": `"${isWatch}"`,
57 | },
58 |
59 | build: {
60 | // 输出路径
61 | outDir: distDir,
62 | emptyOutDir: false,
63 |
64 | // 构建后是否生成 source map 文件
65 | sourcemap: false,
66 |
67 | // 设置为 false 可以禁用最小化混淆
68 | // 或是用来指定是应用哪种混淆器
69 | // boolean | 'terser' | 'esbuild'
70 | // 不压缩,用于调试
71 | minify: !isWatch,
72 |
73 | lib: {
74 | // Could also be a dictionary or array of multiple entry points
75 | entry: resolve(__dirname, "src/index.ts"),
76 | // the proper extensions will be added
77 | fileName: "index",
78 | formats: ["cjs"],
79 | },
80 | rollupOptions: {
81 | plugins: [
82 | ...(isWatch
83 | ? [
84 | livereload(distDir),
85 | {
86 | //监听静态资源文件
87 | name: "watch-external",
88 | async buildStart() {
89 | const files = await fg(["src/i18n/*.json", "./README*.md", "./plugin.json"])
90 | for (const file of files) {
91 | this.addWatchFile(file)
92 | }
93 | },
94 | },
95 | ]
96 | : []),
97 | ],
98 |
99 | // make sure to externalize deps that shouldn't be bundled
100 | // into your library
101 | external: ["siyuan"],
102 |
103 | output: {
104 | entryFileNames: "[name].js",
105 | assetFileNames: (assetInfo) => {
106 | if (assetInfo.name === "style.css") {
107 | return "index.css"
108 | }
109 | return assetInfo.name
110 | },
111 | },
112 | },
113 | },
114 | })
115 |
--------------------------------------------------------------------------------