├── .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 | icon 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 | ![preview.png](./preview.png) 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 | donate 89 |
90 | 91 | ## Contact 92 | 93 | Author Email: ebAobS@outlook.com -------------------------------------------------------------------------------- /README_zh_CN.md: -------------------------------------------------------------------------------- 1 | [English](README.md) 2 | 3 | # 漫游式渐进阅读 4 | 5 | ![icon.png](./icon.png) 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 | ![preview.png](./preview.png) 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 | donate 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 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | {#each metrics as metric} 198 | 199 | 200 | 201 | 209 | 210 | 213 | 214 | {/each} 215 | 216 |
ID名称权重 (%)描述操作
{metric.id}{metric.name} 202 | updateMetricWeight(metric.id, parseFloat(e.currentTarget.value))} 207 | /> 208 | {metric.description || "-"} 211 | 212 |
217 |
218 | 219 |
220 |

添加新指标

221 | 222 |
223 |
224 | 225 | 231 |
232 | 233 |
234 | 235 | 241 |
242 | 243 |
244 | 245 | 252 |
253 |
254 | 255 |
256 |
257 | 258 | 264 |
265 | 266 |
267 | 268 |
269 |
270 |
271 | 272 | 273 | {#if isProcessing} 274 |
275 |

正在修复文档指标

276 |
277 | 正在处理 {processCurrent} / {processTotal} 篇文档 ({processProgress}%) 278 |
279 |
280 |
281 |
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 |
32 |
33 |
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 | 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 | 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 | 836 | 当前使用自定义 SQL 漫游 837 | {:else} 838 | 筛选: 839 | 847 | {#if filterMode === FilterMode.Notebook} 848 | 862 | {:else} 863 | 869 | {/if} 870 | 模式: 871 | 879 | {/if} 880 | 881 | 884 | 885 | 888 | 891 | 898 |
899 | 900 | {#if reviewMode === ReviewMode.Incremental && currentRndId} 901 | 909 | {/if} 910 | 911 |
912 |
917 |
{tips}
918 |
919 |
920 |
921 | {@html content} 922 |
923 |
924 |
925 | 926 | 1048 | -------------------------------------------------------------------------------- /src/libs/RandomDocSetting.svelte: -------------------------------------------------------------------------------- 1 | 225 | 226 |
227 |
228 |
229 |
230 | 复习模式 231 | 235 |
236 |
237 | 238 |
239 |
240 | 漫游文档时是否显示加载图标 241 | 242 |
243 |
244 | 245 | {#if reviewMode === ReviewMode.Incremental} 246 |
247 |
248 |
249 | 排除今日已漫游的文档 250 |
勾选后,今日已访问过的文档将不会再次出现
251 |
252 | 253 |
254 |
255 | 256 |
257 |

渐进模式指标配置

258 |
自定义文档优先级的评估指标,系统将基于这些指标为文档分配选中概率
259 | 260 | 261 | {#if metrics && metrics.length > 0} 262 |
263 |

已有指标

264 |

权重总和将自动调整为100%

265 | 266 |
267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | {#each metrics as metric} 279 | 280 | 281 | 282 | 290 | 291 | 294 | 295 | {/each} 296 | 297 |
ID名称权重 (%)描述操作
{metric.id}{metric.name} 283 | updateMetricWeight(metric.id, parseFloat(e.currentTarget.value))} 288 | /> 289 | {metric.description || "-"} 292 | 293 |
298 |
299 |
300 | 301 |
302 |

添加新指标

303 | 304 |
305 |
306 | 307 | 313 |
314 | 315 |
316 | 317 | 323 |
324 | 325 |
326 | 327 | 334 |
335 | 336 |
337 | 338 | 344 |
345 | 346 |
347 | 348 |
349 |
350 |
351 | {/if} 352 |
353 | 354 | 355 | {#if isProcessing} 356 |
357 |

正在修复文档指标

358 |
359 | 正在处理 {processCurrent} / {processTotal} 篇文档 ({processProgress}%) 360 |
361 |
362 |
363 |
364 |

正在扫描并修复文档指标,分页处理中,请耐心等待...

365 |

大量文档处理可能需要较长时间,请勿关闭窗口

366 |
367 | {/if} 368 | {/if} 369 | 370 |
371 |
372 |
373 | 是否启用自定义SQL 374 |
{pluginInstance.i18n.customSqlEnabledTip}
375 |
376 | 377 |
378 |
379 | 380 | {#if customSqlEnabled} 381 |
382 | {pluginInstance.i18n.sqlContent} 383 |
{pluginInstance.i18n.sqlContentTip}
384 | 385 |