mirror of
https://github.com/jaycelu/Smart-Review.git
synced 2026-07-22 07:24:13 +00:00
Prepare Obsidian Smart Review plugin release
This commit is contained in:
commit
f4bab10857
38 changed files with 3703 additions and 0 deletions
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Local-only project material, not part of the public Obsidian plugin repo
|
||||
apps/smart-review-companion-mac/
|
||||
docs/
|
||||
examples/
|
||||
|
||||
# Obsidian plugin build output
|
||||
apps/obsidian-smart-review-plugin/main.js
|
||||
apps/obsidian-smart-review-plugin/main.js.map
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
pnpm-debug.log*
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jayce
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
80
README.md
Normal file
80
README.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Obsidian Smart Review
|
||||
|
||||
[中文说明](README.zh-CN.md) | [Plugin source](apps/obsidian-smart-review-plugin)
|
||||
|
||||
Obsidian Smart Review is a review center for Obsidian notes that uses Properties / YAML frontmatter. It builds review queues from `next_review`, lets you complete reviews inside Obsidian, writes spaced-review metadata back to notes, records review history, and exports AI review-card prompt payloads.
|
||||
|
||||
The Obsidian plugin works on its own and does not require any external companion app.
|
||||
|
||||
## Features
|
||||
|
||||
- Read Obsidian Properties / YAML frontmatter directly.
|
||||
- Build review queues from `next_review`.
|
||||
- Show overdue, today, next 7 days, future, and invalid-date notes in Review Center.
|
||||
- Display today and overdue counts in the Obsidian status bar.
|
||||
- Support `again`, `hard`, `good`, and `easy` review feedback.
|
||||
- Write review metadata back to note frontmatter.
|
||||
- Append review events to `review-history.jsonl`.
|
||||
- Generate a native Markdown daily review page.
|
||||
- Generate `review-ai-cards.json` as a prompt payload without calling external AI APIs.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
- `apps/obsidian-smart-review-plugin`: Obsidian plugin source and build output.
|
||||
- `packages/shared`: Shared types, date parsing, and review-state logic.
|
||||
- `manifest.json` and `versions.json`: Root copies for Obsidian community submission.
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Build output for manual Obsidian installation:
|
||||
|
||||
```text
|
||||
apps/obsidian-smart-review-plugin/main.js
|
||||
apps/obsidian-smart-review-plugin/manifest.json
|
||||
apps/obsidian-smart-review-plugin/styles.css
|
||||
```
|
||||
|
||||
Install into a local vault:
|
||||
|
||||
```bash
|
||||
./scripts/install-obsidian-smart-review-plugin.sh "/path/to/your/vault"
|
||||
```
|
||||
|
||||
The script copies the plugin files to:
|
||||
|
||||
```text
|
||||
<Vault>/.obsidian/plugins/obsidian-smart-review/
|
||||
```
|
||||
|
||||
## Obsidian Commands
|
||||
|
||||
- `Open Review Center`
|
||||
- `Generate Smart Review Index`
|
||||
- `Refresh Review Data`
|
||||
- `Mark Current Note Reviewed`
|
||||
- `Generate Daily Review Markdown`
|
||||
- `Generate AI Review Cards Payload`
|
||||
|
||||
## Generated Files
|
||||
|
||||
- `review-index.json`: Current review index snapshot, overwritten on each generation.
|
||||
- `review-history.jsonl`: Review event history, appended per review action.
|
||||
- `review-ai-cards.json`: Current AI review-card prompt payload, overwritten on each generation.
|
||||
- `00-总览/今日复习.md`: Native Markdown review page, overwritten on each generation.
|
||||
|
||||
## GitHub Release Assets
|
||||
|
||||
For an Obsidian community release, create a GitHub release whose tag exactly matches the version in `manifest.json`, for example `0.1.0`.
|
||||
|
||||
Upload these files as release assets:
|
||||
|
||||
- `main.js`
|
||||
- `manifest.json`
|
||||
- `styles.css`
|
||||
|
||||
The release tag should not use a `v` prefix.
|
||||
141
README.zh-CN.md
Normal file
141
README.zh-CN.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
# Obsidian Smart Review
|
||||
|
||||
[English](README.md) | [插件源码](apps/obsidian-smart-review-plugin)
|
||||
|
||||
基于 Obsidian Properties / YAML frontmatter 的智能复习系统,让笔记按照 `next_review` 自动进入复习队列,并支持复习完成、间隔重复、复习历史和 AI 复习卡片。
|
||||
|
||||
当前主线是 Obsidian 插件本体独立可用。用户安装插件后,不需要额外安装 Mac App,也能在 Obsidian 内完成复习闭环。
|
||||
|
||||
## 核心能力
|
||||
|
||||
- 直接读取 Obsidian 笔记 Properties / YAML frontmatter
|
||||
- 根据 `next_review` 自动生成复习队列
|
||||
- 在插件内提供 Review Center 复习中心
|
||||
- 状态栏显示今日复习和逾期数量,点击可打开 Review Center
|
||||
- 支持 `again` / `hard` / `good` / `easy` 复习反馈
|
||||
- 自动写回下一次复习日期、复习评分、间隔、ease 和 lapses
|
||||
- 自动追加复习历史
|
||||
- 生成 Obsidian 原生今日复习 Markdown
|
||||
- 生成 AI 复习卡片 `prompt_payload`,不直接调用外部 AI API
|
||||
|
||||
## 项目结构
|
||||
|
||||
- `apps/obsidian-smart-review-plugin`: Obsidian Smart Review 插件,负责扫描、Review Center、状态栏、复习反馈、Markdown 导出和 AI Payload。
|
||||
- `packages/shared`: 复用类型、日期解析和复习状态计算逻辑。
|
||||
- `manifest.json` / `versions.json`: 放在仓库根目录,供 Obsidian 社区插件提交读取。
|
||||
|
||||
## 文件写入策略
|
||||
|
||||
这些文件不会无限堆积生成副本:
|
||||
|
||||
- `review-index.json` = 当前最新复习索引,覆盖写入。
|
||||
- `review-history.jsonl` = 复习动作历史记录,追加写入。
|
||||
- `review-ai-cards.json` = 当前 AI 复习卡片 Payload,覆盖写入。
|
||||
- `00-总览/今日复习.md` = Obsidian 原生今日复习中心,覆盖写入。
|
||||
|
||||
插件默认用 Obsidian `vault.adapter.write(path, content)` 写入快照类文件,因此同一路径每次都是覆盖更新。用户可以在设置中修改路径,但同一个路径仍保持相同策略:索引、AI Payload、今日复习 Markdown 覆盖写入;历史 JSONL 只追加复习事件。
|
||||
|
||||
## 安装与构建
|
||||
|
||||
```bash
|
||||
cd obsidian-smart-review
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
构建完成后,Obsidian 插件入口会生成在:
|
||||
|
||||
```text
|
||||
apps/obsidian-smart-review-plugin/main.js
|
||||
```
|
||||
|
||||
推荐使用安装脚本:
|
||||
|
||||
```bash
|
||||
./scripts/install-obsidian-smart-review-plugin.sh "/path/to/your/vault"
|
||||
```
|
||||
|
||||
iCloud 中的 Obsidian Vault 通常位于类似路径:
|
||||
|
||||
```bash
|
||||
./scripts/install-obsidian-smart-review-plugin.sh "$HOME/Library/Mobile Documents/iCloud~md~obsidian/Documents/<你的Vault名>"
|
||||
```
|
||||
|
||||
脚本会执行构建,并复制以下文件到 `<Vault>/.obsidian/plugins/obsidian-smart-review/`:
|
||||
|
||||
```text
|
||||
apps/obsidian-smart-review-plugin/manifest.json
|
||||
apps/obsidian-smart-review-plugin/main.js
|
||||
apps/obsidian-smart-review-plugin/styles.css
|
||||
```
|
||||
|
||||
## Obsidian 内使用
|
||||
|
||||
启用插件后:
|
||||
|
||||
1. 点击左侧 Ribbon 图标打开 Review Center。
|
||||
2. 在 Review Center 查看已逾期、今日复习、未来 7 天、更远未来和日期无效任务。
|
||||
3. 点击任务标题打开对应笔记。
|
||||
4. 点击 `Again` / `Hard` / `Good` / `Easy` 完成复习反馈。
|
||||
5. 使用操作区按钮刷新数据、重新生成 `review-index.json`、生成今日复习 Markdown、生成 AI 卡片 Payload 或打开插件设置。
|
||||
|
||||
Command Palette 至少包含:
|
||||
|
||||
```text
|
||||
Open Review Center
|
||||
Generate Smart Review Index
|
||||
Refresh Review Data
|
||||
Mark Current Note Reviewed
|
||||
Generate Daily Review Markdown
|
||||
Generate AI Review Cards Payload
|
||||
```
|
||||
|
||||
`Mark Current Note Reviewed` 默认按设置中的默认评分处理,初始值为 `good`。
|
||||
|
||||
## 写回字段
|
||||
|
||||
复习反馈会写回当前笔记 frontmatter:
|
||||
|
||||
```yaml
|
||||
last_reviewed: 2026-05-28
|
||||
next_review: 2026-06-05
|
||||
review_count: 1
|
||||
review_rating: good
|
||||
review_interval_days: 7
|
||||
review_ease: 2.5
|
||||
review_lapses: 0
|
||||
```
|
||||
|
||||
间隔重复算法保持轻量、可解释:
|
||||
|
||||
- `again`: 短间隔,表示没有掌握
|
||||
- `hard`: 较短间隔
|
||||
- `good`: 正常间隔
|
||||
- `easy`: 更长间隔
|
||||
|
||||
## AI 复习卡片 Payload
|
||||
|
||||
当前阶段只生成 `review-ai-cards.json`,模式为 `prompt_payload`。插件会从今日和逾期任务读取笔记正文并生成 prompt,供用户复制到 ChatGPT / Dify / Ollama / 本地 AI 服务。
|
||||
|
||||
本阶段不会增加 OpenAI API Key、Ollama、Dify、FastAPI AI Service 或任何远程网络请求。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## 发布文件
|
||||
|
||||
GitHub Release 需要上传:
|
||||
|
||||
```text
|
||||
main.js
|
||||
manifest.json
|
||||
styles.css
|
||||
```
|
||||
|
||||
Release tag 必须和 `manifest.json` 中的 `version` 完全一致,例如 `0.1.0`。
|
||||
36
apps/obsidian-smart-review-plugin/README.md
Normal file
36
apps/obsidian-smart-review-plugin/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Obsidian Smart Review Plugin
|
||||
|
||||
Obsidian 插件本体负责完整复习闭环:扫描 Properties / YAML frontmatter、打开 Review Center、显示状态栏计数、写回复习反馈、追加历史、生成 Markdown 复习中心和 AI 复习卡片 Payload。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
cd obsidian-smart-review
|
||||
pnpm install
|
||||
pnpm --filter @obsidian-smart-review/obsidian-plugin build
|
||||
```
|
||||
|
||||
构建产物:
|
||||
|
||||
```text
|
||||
apps/obsidian-smart-review-plugin/main.js
|
||||
```
|
||||
|
||||
## 安装到 Obsidian
|
||||
|
||||
复制以下文件到你的 Vault:
|
||||
|
||||
```text
|
||||
<Vault>/.obsidian/plugins/obsidian-smart-review/manifest.json
|
||||
<Vault>/.obsidian/plugins/obsidian-smart-review/main.js
|
||||
<Vault>/.obsidian/plugins/obsidian-smart-review/styles.css
|
||||
```
|
||||
|
||||
启用插件后,点击 Ribbon 图标打开 Review Center。`Generate Smart Review Index` 命令仍保留,用于手动覆盖更新 `review-index.json`。
|
||||
|
||||
## 文件策略
|
||||
|
||||
- `review-index.json`:覆盖写入的当前索引快照。
|
||||
- `review-history.jsonl`:追加写入的复习动作事件日志。
|
||||
- `review-ai-cards.json`:覆盖写入的当前 AI 复习卡片 Payload。
|
||||
- `00-总览/今日复习.md`:覆盖写入的 Obsidian 原生今日复习中心。
|
||||
93
apps/obsidian-smart-review-plugin/ai-cards.ts
Normal file
93
apps/obsidian-smart-review-plugin/ai-cards.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import type { App } from "obsidian";
|
||||
import type { ReviewIndex, ReviewItem } from "@obsidian-smart-review/shared";
|
||||
|
||||
const DUE_STATES = new Set<ReviewItem["review_state"]>(["overdue", "today"]);
|
||||
|
||||
export interface AiReviewCardsPayload {
|
||||
generated_at: string;
|
||||
vault_name: string;
|
||||
source_review_index: string;
|
||||
mode: "prompt_payload";
|
||||
items: AiReviewCardPrompt[];
|
||||
}
|
||||
|
||||
export interface AiReviewCardPrompt {
|
||||
file: string;
|
||||
title: string;
|
||||
review_state: ReviewItem["review_state"];
|
||||
days_delta: number | null;
|
||||
source_updated: null;
|
||||
prompt: string;
|
||||
questions: [];
|
||||
summary: "";
|
||||
cards: [];
|
||||
}
|
||||
|
||||
export async function buildAiReviewCardsPayload(
|
||||
app: App,
|
||||
index: ReviewIndex,
|
||||
sourceReviewIndexPath: string,
|
||||
limit = 10
|
||||
): Promise<AiReviewCardsPayload> {
|
||||
const selectedItems = index.items.filter((item) => DUE_STATES.has(item.review_state)).slice(0, limit);
|
||||
const items: AiReviewCardPrompt[] = [];
|
||||
|
||||
for (const item of selectedItems) {
|
||||
items.push(await createPromptPayload(app, item));
|
||||
}
|
||||
|
||||
return {
|
||||
generated_at: new Date().toISOString(),
|
||||
vault_name: index.vault_name,
|
||||
source_review_index: sourceReviewIndexPath,
|
||||
mode: "prompt_payload",
|
||||
items
|
||||
};
|
||||
}
|
||||
|
||||
async function createPromptPayload(app: App, item: ReviewItem): Promise<AiReviewCardPrompt> {
|
||||
const file = app.vault.getFileByPath(item.file);
|
||||
const markdown = file === null ? "" : await app.vault.cachedRead(file);
|
||||
const content = stripFrontmatter(markdown).trim().slice(0, 12_000);
|
||||
|
||||
return {
|
||||
file: item.file,
|
||||
title: item.title,
|
||||
review_state: item.review_state,
|
||||
days_delta: item.days_delta,
|
||||
source_updated: null,
|
||||
prompt: buildPrompt(item, content),
|
||||
questions: [],
|
||||
summary: "",
|
||||
cards: []
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrompt(item: ReviewItem, content: string): string {
|
||||
return [
|
||||
"你是一个知识复习助手。请基于下面的 Obsidian 笔记生成复习材料。",
|
||||
"",
|
||||
"要求:",
|
||||
"1. 生成 3-5 个主动回忆问题。",
|
||||
"2. 生成 1 段 120 字以内的摘要。",
|
||||
"3. 生成 3 张知识卡片,每张包含 title、kind、content。",
|
||||
"4. 只基于笔记内容,不要编造事实。",
|
||||
"5. 输出 JSON,字段为 questions、summary、cards。",
|
||||
"",
|
||||
`标题:${item.title}`,
|
||||
`文件:${item.file}`,
|
||||
`复习状态:${item.review_state}`,
|
||||
"",
|
||||
"笔记内容:",
|
||||
content
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function stripFrontmatter(markdown: string): string {
|
||||
if (!markdown.startsWith("---")) {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
const end = markdown.indexOf("\n---", 3);
|
||||
return end === -1 ? markdown : markdown.slice(end + 4);
|
||||
}
|
||||
42
apps/obsidian-smart-review-plugin/esbuild.config.mjs
Normal file
42
apps/obsidian-smart-review-plugin/esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import builtins from "builtin-modules";
|
||||
import esbuild from "esbuild";
|
||||
|
||||
const production = process.argv[2] === "production";
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: "/* Smart Review Index */"
|
||||
},
|
||||
entryPoints: ["main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: production ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: production
|
||||
});
|
||||
|
||||
if (production) {
|
||||
await context.rebuild();
|
||||
await context.dispose();
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
373
apps/obsidian-smart-review-plugin/main.ts
Normal file
373
apps/obsidian-smart-review-plugin/main.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
import { Notice, Plugin, type TFile } from "obsidian";
|
||||
import type { ReviewIndex, ReviewRating } from "@obsidian-smart-review/shared";
|
||||
import { buildAiReviewCardsPayload } from "./ai-cards";
|
||||
import { buildDailyReviewMarkdown } from "./markdown-export";
|
||||
import { markFileReviewed } from "./review-actions";
|
||||
import { ReviewCenterView, REVIEW_CENTER_VIEW_TYPE } from "./review-center-view";
|
||||
import { buildReviewIndex } from "./scanner";
|
||||
import { DEFAULT_SETTINGS, REVIEW_RATINGS, SmartReviewSettingTab, type SmartReviewSettings } from "./settings";
|
||||
import { SmartReviewStatusBar } from "./status-bar";
|
||||
import { ensureParentFolder, isMissingFileError, normalizeOutputPath } from "./utils";
|
||||
|
||||
export default class SmartReviewPlugin extends Plugin {
|
||||
settings: SmartReviewSettings = DEFAULT_SETTINGS;
|
||||
currentIndex: ReviewIndex | null = null;
|
||||
lastError: string | null = null;
|
||||
private refreshTimer: ReturnType<typeof window.setTimeout> | null = null;
|
||||
private statusBar: SmartReviewStatusBar | null = null;
|
||||
private startupScanCompleted = false;
|
||||
|
||||
override async onload(): Promise<void> {
|
||||
await this.loadSettings();
|
||||
|
||||
this.registerView(REVIEW_CENTER_VIEW_TYPE, (leaf) => new ReviewCenterView(leaf, this));
|
||||
this.statusBar = new SmartReviewStatusBar(this.addStatusBarItem(), () => {
|
||||
void this.openReviewCenter();
|
||||
});
|
||||
this.updateStatusBar();
|
||||
|
||||
this.addRibbonIcon("calendar-check", "Open Review Center", () => {
|
||||
void this.openReviewCenter();
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "open-review-center",
|
||||
name: "Open Review Center",
|
||||
callback: () => {
|
||||
void this.openReviewCenter();
|
||||
}
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "generate-obsidian-smart-review",
|
||||
name: "Generate Smart Review Index",
|
||||
callback: () => {
|
||||
void this.generateReviewIndex();
|
||||
}
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "refresh-review-data",
|
||||
name: "Refresh Review Data",
|
||||
callback: () => {
|
||||
void this.refreshReviewData({ writeIndex: false, notice: true });
|
||||
}
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "mark-current-note-reviewed",
|
||||
name: "Mark Current Note Reviewed",
|
||||
callback: () => {
|
||||
void this.markCurrentNoteReviewed(this.settings.defaultReviewRating);
|
||||
}
|
||||
});
|
||||
|
||||
for (const rating of REVIEW_RATINGS) {
|
||||
this.addCommand({
|
||||
id: `mark-current-note-reviewed-${rating}`,
|
||||
name: `Mark Current Note Reviewed: ${rating}`,
|
||||
callback: () => {
|
||||
void this.markCurrentNoteReviewed(rating);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.addCommand({
|
||||
id: "generate-daily-review-markdown",
|
||||
name: "Generate Daily Review Markdown",
|
||||
callback: () => {
|
||||
void this.generateDailyReviewMarkdown();
|
||||
}
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "generate-ai-review-cards-payload",
|
||||
name: "Generate AI Review Cards Payload",
|
||||
callback: () => {
|
||||
void this.generateAiReviewCardsPayload();
|
||||
}
|
||||
});
|
||||
|
||||
this.addSettingTab(new SmartReviewSettingTab(this.app, this));
|
||||
this.registerAutoRefreshEvents();
|
||||
void this.loadReviewIndexSnapshot();
|
||||
this.registerStartupScan();
|
||||
}
|
||||
|
||||
override onunload(): void {
|
||||
if (this.refreshTimer !== null) {
|
||||
window.clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
this.app.workspace.detachLeavesOfType(REVIEW_CENTER_VIEW_TYPE);
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
const loaded = (await this.loadData()) as Partial<SmartReviewSettings> | null;
|
||||
this.settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...loaded
|
||||
};
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
async openReviewCenter(): Promise<void> {
|
||||
const existing = this.app.workspace.getLeavesOfType(REVIEW_CENTER_VIEW_TYPE)[0];
|
||||
const leaf = existing ?? this.app.workspace.getRightLeaf(false);
|
||||
if (leaf === null) {
|
||||
new Notice("Unable to open Review Center.");
|
||||
return;
|
||||
}
|
||||
|
||||
await leaf.setViewState({ type: REVIEW_CENTER_VIEW_TYPE, active: true });
|
||||
this.app.workspace.revealLeaf(leaf);
|
||||
if (this.currentIndex === null) {
|
||||
await this.loadReviewIndexSnapshot();
|
||||
}
|
||||
this.renderReviewCenter();
|
||||
}
|
||||
|
||||
async ensureReviewDataForView(): Promise<void> {
|
||||
if (this.currentIndex !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadReviewIndexSnapshot();
|
||||
if (this.currentIndex === null && this.isMetadataLikelyReady()) {
|
||||
await this.refreshReviewData({ writeIndex: false, notice: false });
|
||||
}
|
||||
}
|
||||
|
||||
async refreshReviewData(options: { writeIndex: boolean; notice: boolean }): Promise<ReviewIndex | null> {
|
||||
try {
|
||||
const index = buildReviewIndex(this.app, this.settings);
|
||||
this.currentIndex = index;
|
||||
this.lastError = null;
|
||||
|
||||
if (options.writeIndex) {
|
||||
await this.writeReviewIndex(index);
|
||||
}
|
||||
|
||||
this.updateStatusBar();
|
||||
this.renderReviewCenter();
|
||||
|
||||
if (options.notice) {
|
||||
new Notice(`Review data refreshed (${index.items.length} notes).`);
|
||||
}
|
||||
|
||||
return index;
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh review data", error);
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.updateStatusBar();
|
||||
this.renderReviewCenter();
|
||||
if (options.notice) {
|
||||
new Notice("Failed to refresh review data. Check console for details.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async generateReviewIndex(): Promise<void> {
|
||||
const index = await this.refreshReviewData({ writeIndex: true, notice: false });
|
||||
if (index === null) {
|
||||
new Notice("Failed to generate review-index.json. Check console for details.");
|
||||
return;
|
||||
}
|
||||
|
||||
const outputPath = normalizeOutputPath(this.settings.outputPath);
|
||||
new Notice(`Review index generated: ${outputPath} (${index.items.length} notes)`);
|
||||
}
|
||||
|
||||
async markCurrentNoteReviewed(rating: ReviewRating = "good"): Promise<void> {
|
||||
const file = this.app.workspace.getActiveFile();
|
||||
if (file === null || file.extension !== "md") {
|
||||
new Notice("Open a Markdown note before marking it reviewed.");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.reviewFile(file, rating);
|
||||
}
|
||||
|
||||
async reviewFileByPath(filePath: string, rating: ReviewRating): Promise<void> {
|
||||
const file = this.app.vault.getFileByPath(filePath);
|
||||
if (file === null) {
|
||||
new Notice(`Note not found: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.reviewFile(file, rating);
|
||||
}
|
||||
|
||||
async generateDailyReviewMarkdown(): Promise<void> {
|
||||
const index = this.currentIndex ?? (await this.refreshReviewData({ writeIndex: false, notice: false }));
|
||||
if (index === null) {
|
||||
new Notice("Unable to generate daily review Markdown without review data.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const outputPath = normalizeOutputPath(this.settings.dailyMarkdownPath, DEFAULT_SETTINGS.dailyMarkdownPath);
|
||||
await ensureParentFolder(this.app.vault.adapter, outputPath);
|
||||
await this.app.vault.adapter.write(outputPath, buildDailyReviewMarkdown(index));
|
||||
new Notice(`Daily review Markdown generated: ${outputPath}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate daily review Markdown", error);
|
||||
new Notice("Failed to generate daily review Markdown. Check console for details.");
|
||||
}
|
||||
}
|
||||
|
||||
async generateAiReviewCardsPayload(): Promise<void> {
|
||||
const index = this.currentIndex ?? (await this.refreshReviewData({ writeIndex: false, notice: false }));
|
||||
if (index === null) {
|
||||
new Notice("Unable to generate AI review cards payload without review data.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const indexPath = normalizeOutputPath(this.settings.outputPath);
|
||||
const outputPath = normalizeOutputPath(this.settings.aiCardsPath, DEFAULT_SETTINGS.aiCardsPath);
|
||||
const payload = await buildAiReviewCardsPayload(this.app, index, indexPath);
|
||||
await ensureParentFolder(this.app.vault.adapter, outputPath);
|
||||
await this.app.vault.adapter.write(outputPath, `${JSON.stringify(payload, null, 2)}\n`);
|
||||
new Notice(`AI review cards payload generated: ${outputPath} (${payload.items.length} items)`);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate AI review cards payload", error);
|
||||
new Notice("Failed to generate AI review cards payload. Check console for details.");
|
||||
}
|
||||
}
|
||||
|
||||
updateStatusBar(): void {
|
||||
this.statusBar?.update(this.currentIndex, this.settings.showStatusBarCount);
|
||||
}
|
||||
|
||||
scheduleGenerateReviewIndex(): void {
|
||||
if (!this.settings.autoRefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.refreshTimer !== null) {
|
||||
window.clearTimeout(this.refreshTimer);
|
||||
}
|
||||
|
||||
this.refreshTimer = window.setTimeout(() => {
|
||||
this.refreshTimer = null;
|
||||
void this.refreshReviewData({ writeIndex: true, notice: false });
|
||||
}, 1_000);
|
||||
}
|
||||
|
||||
private async reviewFile(file: TFile, rating: ReviewRating): Promise<void> {
|
||||
try {
|
||||
const result = await markFileReviewed(this.app, this.settings, file, rating);
|
||||
await this.refreshReviewData({ writeIndex: true, notice: false });
|
||||
new Notice(`Marked reviewed (${rating}): ${file.basename} -> ${result.nextReview}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to mark note reviewed", error);
|
||||
new Notice("Failed to mark note reviewed. Check console for details.");
|
||||
}
|
||||
}
|
||||
|
||||
private async writeReviewIndex(index: ReviewIndex): Promise<void> {
|
||||
const outputPath = normalizeOutputPath(this.settings.outputPath);
|
||||
await ensureParentFolder(this.app.vault.adapter, outputPath);
|
||||
await this.app.vault.adapter.write(outputPath, JSON.stringify(index, null, 2));
|
||||
}
|
||||
|
||||
private async loadReviewIndexSnapshot(): Promise<void> {
|
||||
try {
|
||||
const outputPath = normalizeOutputPath(this.settings.outputPath);
|
||||
const raw = await this.app.vault.adapter.read(outputPath);
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!isReviewIndex(parsed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentIndex = parsed;
|
||||
this.lastError = null;
|
||||
this.updateStatusBar();
|
||||
this.renderReviewCenter();
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) {
|
||||
console.warn("Failed to load existing review-index.json", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private registerStartupScan(): void {
|
||||
if (!this.settings.scanOnStartup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runStartupScan = async () => {
|
||||
if (this.startupScanCompleted || !this.isMetadataLikelyReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.startupScanCompleted = true;
|
||||
await this.refreshReviewData({ writeIndex: true, notice: false });
|
||||
};
|
||||
|
||||
this.registerEvent(
|
||||
this.app.metadataCache.on("resolved", () => {
|
||||
void runStartupScan();
|
||||
})
|
||||
);
|
||||
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
window.setTimeout(() => {
|
||||
void runStartupScan();
|
||||
}, 1_500);
|
||||
});
|
||||
}
|
||||
|
||||
private isMetadataLikelyReady(): boolean {
|
||||
return this.app.vault.getMarkdownFiles().length > 0;
|
||||
}
|
||||
|
||||
private renderReviewCenter(): void {
|
||||
for (const leaf of this.app.workspace.getLeavesOfType(REVIEW_CENTER_VIEW_TYPE)) {
|
||||
const view = leaf.view;
|
||||
if (view instanceof ReviewCenterView) {
|
||||
view.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private registerAutoRefreshEvents(): void {
|
||||
this.registerEvent(
|
||||
this.app.metadataCache.on("changed", () => {
|
||||
this.scheduleGenerateReviewIndex();
|
||||
})
|
||||
);
|
||||
|
||||
this.registerEvent(
|
||||
this.app.vault.on("rename", () => {
|
||||
this.scheduleGenerateReviewIndex();
|
||||
})
|
||||
);
|
||||
|
||||
this.registerEvent(
|
||||
this.app.vault.on("delete", () => {
|
||||
this.scheduleGenerateReviewIndex();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isReviewIndex(value: unknown): value is ReviewIndex {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<ReviewIndex>;
|
||||
return (
|
||||
typeof candidate.generated_at === "string" &&
|
||||
typeof candidate.vault_name === "string" &&
|
||||
typeof candidate.summary === "object" &&
|
||||
candidate.summary !== null &&
|
||||
Array.isArray(candidate.items)
|
||||
);
|
||||
}
|
||||
10
apps/obsidian-smart-review-plugin/manifest.json
Normal file
10
apps/obsidian-smart-review-plugin/manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "obsidian-smart-review",
|
||||
"name": "Obsidian Smart Review",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Smart review system based on Obsidian Properties / YAML frontmatter with Review Center, spaced review feedback, history, and AI payload export.",
|
||||
"author": "Jayce",
|
||||
"authorUrl": "",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
51
apps/obsidian-smart-review-plugin/markdown-export.ts
Normal file
51
apps/obsidian-smart-review-plugin/markdown-export.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { ReviewIndex, ReviewItem } from "@obsidian-smart-review/shared";
|
||||
import { formatLocalDateTime } from "./utils";
|
||||
|
||||
export function buildDailyReviewMarkdown(index: ReviewIndex): string {
|
||||
const overdue = filterItems(index.items, "overdue");
|
||||
const today = filterItems(index.items, "today");
|
||||
const next7 = filterItems(index.items, "next_7_days");
|
||||
|
||||
return [
|
||||
"# 今日复习",
|
||||
"",
|
||||
`生成时间:${formatLocalDateTime()}`,
|
||||
"",
|
||||
"## 已逾期",
|
||||
"",
|
||||
renderList(overdue, (item) => {
|
||||
const days = item.days_delta === null ? "" : `:逾期 ${Math.abs(item.days_delta)} 天`;
|
||||
return `- ${toWikiLink(item)}${days}`;
|
||||
}),
|
||||
"",
|
||||
"## 今日",
|
||||
"",
|
||||
renderList(today, (item) => `- ${toWikiLink(item)}`),
|
||||
"",
|
||||
"## 未来 7 天",
|
||||
"",
|
||||
renderList(next7, (item) => {
|
||||
const days = item.days_delta === null ? "" : `:${item.days_delta} 天后`;
|
||||
return `- ${toWikiLink(item)}${days}`;
|
||||
}),
|
||||
""
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function filterItems(items: ReviewItem[], state: ReviewItem["review_state"]): ReviewItem[] {
|
||||
return items.filter((item) => item.review_state === state);
|
||||
}
|
||||
|
||||
function renderList(items: ReviewItem[], render: (item: ReviewItem) => string): string {
|
||||
if (items.length === 0) {
|
||||
return "- 暂无";
|
||||
}
|
||||
|
||||
return items.map(render).join("\n");
|
||||
}
|
||||
|
||||
function toWikiLink(item: ReviewItem): string {
|
||||
const pathWithoutExt = item.file.replace(/\.md$/i, "");
|
||||
const display = item.title.trim();
|
||||
return display.length > 0 && display !== pathWithoutExt ? `[[${pathWithoutExt}|${display}]]` : `[[${pathWithoutExt}]]`;
|
||||
}
|
||||
21
apps/obsidian-smart-review-plugin/package.json
Normal file
21
apps/obsidian-smart-review-plugin/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "@obsidian-smart-review/obsidian-plugin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node esbuild.config.mjs production",
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"lint": "tsc -p tsconfig.json --noEmit",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@obsidian-smart-review/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"builtin-modules": "^4.0.0",
|
||||
"esbuild": "^0.25.4",
|
||||
"obsidian": "^1.8.7",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
193
apps/obsidian-smart-review-plugin/review-actions.ts
Normal file
193
apps/obsidian-smart-review-plugin/review-actions.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import type { App, TFile } from "obsidian";
|
||||
import {
|
||||
calculateNextReviewDate,
|
||||
calculateSpacedReview,
|
||||
getLocalDateString,
|
||||
normalizeDate,
|
||||
type ReviewRating
|
||||
} from "@obsidian-smart-review/shared";
|
||||
import type { SmartReviewSettings } from "./settings";
|
||||
import {
|
||||
ensureParentFolder,
|
||||
getNextReviewCount,
|
||||
getNextReviewLapses,
|
||||
getOptionalNumber,
|
||||
getOptionalString,
|
||||
isMissingFileError,
|
||||
normalizeOutputPath,
|
||||
sleep,
|
||||
type Frontmatter
|
||||
} from "./utils";
|
||||
|
||||
export interface ReviewHistoryEntry {
|
||||
reviewed_at: string;
|
||||
file: string;
|
||||
title: string;
|
||||
rating: ReviewRating;
|
||||
previous_next_review: string | null;
|
||||
next_review: string;
|
||||
interval_days: number;
|
||||
ease: number;
|
||||
review_count: number;
|
||||
review_lapses: number;
|
||||
}
|
||||
|
||||
export interface ReviewActionResult {
|
||||
nextReview: string;
|
||||
historyEntry: ReviewHistoryEntry;
|
||||
}
|
||||
|
||||
export async function markFileReviewed(
|
||||
app: App,
|
||||
settings: SmartReviewSettings,
|
||||
file: TFile,
|
||||
rating: ReviewRating
|
||||
): Promise<ReviewActionResult> {
|
||||
const reviewedAt = getLocalDateString();
|
||||
let historyEntry: ReviewHistoryEntry | null = null;
|
||||
let nextReview: string | null = null;
|
||||
|
||||
await app.fileManager.processFrontMatter(file, (frontmatter: Frontmatter) => {
|
||||
const reviewResult = calculateSpacedReview({
|
||||
rating,
|
||||
currentIntervalDays: getOptionalNumber(frontmatter.review_interval_days),
|
||||
currentEase: getOptionalNumber(frontmatter.review_ease),
|
||||
defaultIntervalDays: settings.reviewIntervalDays
|
||||
});
|
||||
nextReview = calculateNextReviewDate(reviewedAt, reviewResult.intervalDays);
|
||||
if (nextReview === null) {
|
||||
throw new Error("Invalid next review date.");
|
||||
}
|
||||
|
||||
const reviewCount = getNextReviewCount(frontmatter.review_count);
|
||||
const reviewLapses = getNextReviewLapses(frontmatter.review_lapses, reviewResult.lapseDelta);
|
||||
const previousNextReview = normalizeDate(frontmatter.next_review);
|
||||
|
||||
frontmatter.last_reviewed = reviewedAt;
|
||||
frontmatter.next_review = nextReview;
|
||||
frontmatter.review_count = reviewCount;
|
||||
frontmatter.review_rating = rating;
|
||||
frontmatter.review_interval_days = reviewResult.intervalDays;
|
||||
frontmatter.review_ease = reviewResult.ease;
|
||||
frontmatter.review_lapses = reviewLapses;
|
||||
reorderReviewFrontmatter(frontmatter);
|
||||
|
||||
historyEntry = {
|
||||
reviewed_at: reviewedAt,
|
||||
file: file.path,
|
||||
title: getOptionalString(frontmatter.title) ?? file.basename,
|
||||
rating,
|
||||
previous_next_review: previousNextReview,
|
||||
next_review: nextReview,
|
||||
interval_days: reviewResult.intervalDays,
|
||||
ease: reviewResult.ease,
|
||||
review_count: reviewCount,
|
||||
review_lapses: reviewLapses
|
||||
};
|
||||
});
|
||||
|
||||
if (historyEntry === null || nextReview === null) {
|
||||
throw new Error("Review action did not produce a result.");
|
||||
}
|
||||
|
||||
if (settings.enableReviewHistory) {
|
||||
await appendReviewHistory(app, settings.reviewHistoryPath, historyEntry);
|
||||
}
|
||||
|
||||
await waitForFrontmatterValue(app, file, "next_review", nextReview);
|
||||
|
||||
return { nextReview, historyEntry };
|
||||
}
|
||||
|
||||
function reorderReviewFrontmatter(frontmatter: Frontmatter): void {
|
||||
const originalEntries = Object.entries(frontmatter);
|
||||
const valueByKey = new Map(originalEntries);
|
||||
const tagsValue = valueByKey.get("tags");
|
||||
const hasTags = valueByKey.has("tags");
|
||||
const reviewKeys = [
|
||||
"last_reviewed",
|
||||
"review_rating",
|
||||
"review_interval_days",
|
||||
"review_count",
|
||||
"review_ease",
|
||||
"review_lapses"
|
||||
];
|
||||
const orderedKeys: string[] = [];
|
||||
const pushed = new Set<string>();
|
||||
|
||||
for (const [key] of originalEntries) {
|
||||
if (key === "tags" || reviewKeys.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
orderedKeys.push(key);
|
||||
pushed.add(key);
|
||||
|
||||
if (key === "next_review") {
|
||||
for (const reviewKey of reviewKeys) {
|
||||
if (valueByKey.has(reviewKey)) {
|
||||
orderedKeys.push(reviewKey);
|
||||
pushed.add(reviewKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pushed.has("next_review")) {
|
||||
for (const reviewKey of reviewKeys) {
|
||||
if (valueByKey.has(reviewKey) && !pushed.has(reviewKey)) {
|
||||
orderedKeys.push(reviewKey);
|
||||
pushed.add(reviewKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key] of originalEntries) {
|
||||
if (key !== "tags" && !pushed.has(key)) {
|
||||
orderedKeys.push(key);
|
||||
pushed.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasTags) {
|
||||
orderedKeys.push("tags");
|
||||
}
|
||||
|
||||
for (const key of Object.keys(frontmatter)) {
|
||||
delete frontmatter[key];
|
||||
}
|
||||
|
||||
for (const key of orderedKeys) {
|
||||
frontmatter[key] = key === "tags" ? tagsValue : valueByKey.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function appendReviewHistory(app: App, reviewHistoryPath: string, entry: ReviewHistoryEntry): Promise<void> {
|
||||
const outputPath = normalizeOutputPath(reviewHistoryPath, "review-history.jsonl");
|
||||
const line = `${JSON.stringify(entry)}\n`;
|
||||
await ensureParentFolder(app.vault.adapter, outputPath);
|
||||
|
||||
try {
|
||||
const current = await app.vault.adapter.read(outputPath);
|
||||
await app.vault.adapter.write(outputPath, `${current.replace(/\s*$/, "\n")}${line}`);
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await app.vault.adapter.write(outputPath, line);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForFrontmatterValue(app: App, file: TFile, key: string, expectedValue: string): Promise<void> {
|
||||
const deadline = Date.now() + 2_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const frontmatter = app.metadataCache.getFileCache(file)?.frontmatter as Frontmatter | undefined;
|
||||
if (getOptionalString(frontmatter?.[key]) === expectedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
257
apps/obsidian-smart-review-plugin/review-center-view.ts
Normal file
257
apps/obsidian-smart-review-plugin/review-center-view.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { ItemView, Notice, type WorkspaceLeaf } from "obsidian";
|
||||
import type { ReviewItem, ReviewRating, ReviewState } from "@obsidian-smart-review/shared";
|
||||
import type SmartReviewPlugin from "./main";
|
||||
import { REVIEW_RATINGS } from "./settings";
|
||||
import { formatLocalDateTime } from "./utils";
|
||||
|
||||
export const REVIEW_CENTER_VIEW_TYPE = "smart-review-center";
|
||||
|
||||
const GROUPS: Array<{ state: ReviewState; title: string; empty: string }> = [
|
||||
{ state: "overdue", title: "已逾期", empty: "没有逾期任务" },
|
||||
{ state: "today", title: "今日复习", empty: "今日没有待复习任务" },
|
||||
{ state: "next_7_days", title: "未来 7 天", empty: "未来 7 天没有复习任务" },
|
||||
{ state: "future", title: "更远未来", empty: "没有更远未来任务" },
|
||||
{ state: "invalid", title: "日期无效", empty: "没有日期无效任务" }
|
||||
];
|
||||
|
||||
export class ReviewCenterView extends ItemView {
|
||||
constructor(leaf: WorkspaceLeaf, private readonly plugin: SmartReviewPlugin) {
|
||||
super(leaf);
|
||||
}
|
||||
|
||||
getViewType(): string {
|
||||
return REVIEW_CENTER_VIEW_TYPE;
|
||||
}
|
||||
|
||||
getDisplayText(): string {
|
||||
return "Review Center";
|
||||
}
|
||||
|
||||
override getIcon(): string {
|
||||
return "calendar-check";
|
||||
}
|
||||
|
||||
override async onOpen(): Promise<void> {
|
||||
this.render();
|
||||
if (this.plugin.currentIndex === null) {
|
||||
await this.plugin.ensureReviewDataForView();
|
||||
}
|
||||
}
|
||||
|
||||
render(): void {
|
||||
const container = this.containerEl.children[1] as HTMLElement;
|
||||
container.empty();
|
||||
container.addClass("smart-review-center");
|
||||
|
||||
const index = this.plugin.currentIndex;
|
||||
this.renderHeader(container, index);
|
||||
this.renderActions(container);
|
||||
|
||||
if (this.plugin.lastError !== null) {
|
||||
container.createDiv({ cls: "smart-review-error", text: this.plugin.lastError });
|
||||
}
|
||||
|
||||
if (index === null) {
|
||||
container.createDiv({
|
||||
cls: "smart-review-empty",
|
||||
text: "还没有扫描数据。点击“刷新数据”开始扫描当前 Vault。"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const group of GROUPS) {
|
||||
this.renderGroup(container, group.title, group.empty, index.items.filter((item) => item.review_state === group.state), group.state);
|
||||
}
|
||||
}
|
||||
|
||||
private renderHeader(container: HTMLElement, index: SmartReviewPlugin["currentIndex"]): void {
|
||||
const header = container.createDiv({ cls: "smart-review-header" });
|
||||
header.createEl("h1", { text: "Obsidian Smart Review" });
|
||||
header.createEl("p", {
|
||||
text: "基于 Properties / YAML frontmatter 的插件内复习中心。",
|
||||
cls: "smart-review-subtitle"
|
||||
});
|
||||
|
||||
const stats = header.createDiv({ cls: "smart-review-stats" });
|
||||
this.renderStat(stats, "今日", index?.summary.today ?? 0, "today");
|
||||
this.renderStat(stats, "逾期", index?.summary.overdue ?? 0, "overdue");
|
||||
this.renderStat(stats, "未来 7 天", index?.summary.next_7_days ?? 0, "next");
|
||||
this.renderStat(stats, "全部", index?.summary.total ?? 0, "total");
|
||||
|
||||
header.createDiv({
|
||||
cls: "smart-review-generated-at",
|
||||
text: index === null ? "最近生成时间:尚未生成" : `最近生成时间:${formatLocalDateTime(new Date(index.generated_at))}`
|
||||
});
|
||||
}
|
||||
|
||||
private renderStat(container: HTMLElement, label: string, value: number, tone: string): void {
|
||||
const stat = container.createDiv({ cls: `smart-review-stat smart-review-stat-${tone}` });
|
||||
stat.createDiv({ cls: "smart-review-stat-value", text: String(value) });
|
||||
stat.createDiv({ cls: "smart-review-stat-label", text: label });
|
||||
}
|
||||
|
||||
private renderActions(container: HTMLElement): void {
|
||||
const actions = container.createDiv({ cls: "smart-review-actions" });
|
||||
this.renderActionButton(actions, "刷新数据", async () => {
|
||||
await this.plugin.refreshReviewData({ writeIndex: false, notice: true });
|
||||
});
|
||||
this.renderActionButton(actions, "重新生成 review-index.json", () => this.plugin.generateReviewIndex());
|
||||
this.renderActionButton(actions, "生成今日复习 Markdown", () => this.plugin.generateDailyReviewMarkdown());
|
||||
this.renderActionButton(actions, "生成 AI 卡片 Payload", () => this.plugin.generateAiReviewCardsPayload());
|
||||
this.renderActionButton(actions, "打开插件设置", () => {
|
||||
const appWithSettings = this.app as typeof this.app & {
|
||||
setting?: { open(): void; openTabById(id: string): void };
|
||||
};
|
||||
appWithSettings.setting?.open();
|
||||
appWithSettings.setting?.openTabById(this.plugin.manifest.id);
|
||||
});
|
||||
|
||||
container.createDiv({
|
||||
cls: "smart-review-feedback-help",
|
||||
text: "复习反馈:点击重来 / 困难 / 掌握 / 简单后,会立即更新该笔记的 next_review 和复习字段,并追加 review-history.jsonl。"
|
||||
});
|
||||
}
|
||||
|
||||
private renderActionButton(container: HTMLElement, text: string, callback: () => void | Promise<void>): void {
|
||||
const button = container.createEl("button", { text, cls: "smart-review-action-button" });
|
||||
button.onclick = () => {
|
||||
void callback();
|
||||
};
|
||||
}
|
||||
|
||||
private renderGroup(container: HTMLElement, title: string, empty: string, items: ReviewItem[], state: ReviewState): void {
|
||||
const section = container.createDiv({ cls: `smart-review-group smart-review-group-${state}` });
|
||||
const heading = section.createDiv({ cls: "smart-review-group-heading" });
|
||||
heading.createEl("h2", { text: title });
|
||||
heading.createSpan({ cls: "smart-review-group-count", text: String(items.length) });
|
||||
|
||||
if (items.length === 0) {
|
||||
section.createDiv({ cls: "smart-review-empty smart-review-empty-inline", text: empty });
|
||||
return;
|
||||
}
|
||||
|
||||
const list = section.createDiv({ cls: "smart-review-task-list" });
|
||||
for (const item of items) {
|
||||
this.renderTask(list, item);
|
||||
}
|
||||
}
|
||||
|
||||
private renderTask(container: HTMLElement, item: ReviewItem): void {
|
||||
const task = container.createDiv({ cls: `smart-review-task smart-review-task-${item.review_state}` });
|
||||
const main = task.createDiv({ cls: "smart-review-task-main" });
|
||||
const titleRow = main.createDiv({ cls: "smart-review-task-title-row" });
|
||||
const title = titleRow.createEl("button", { text: item.title, cls: "smart-review-task-title" });
|
||||
title.onclick = async () => {
|
||||
const file = this.app.vault.getFileByPath(item.file);
|
||||
if (file === null) {
|
||||
new Notice(`Note not found: ${item.file}`);
|
||||
return;
|
||||
}
|
||||
await this.app.workspace.getLeaf(false).openFile(file);
|
||||
};
|
||||
titleRow.createSpan({ cls: `smart-review-state smart-review-state-${item.review_state}`, text: stateLabel(item) });
|
||||
|
||||
main.createDiv({ cls: "smart-review-task-path", text: item.file });
|
||||
const meta = main.createDiv({ cls: "smart-review-task-meta" });
|
||||
renderMeta(meta, "下次", item.next_review ?? "无效");
|
||||
renderMeta(meta, "差值", item.days_delta === null ? "invalid" : String(item.days_delta));
|
||||
renderMeta(meta, "领域", item.domain);
|
||||
renderMeta(meta, "类型", item.type);
|
||||
renderMeta(meta, "系列", item.series);
|
||||
|
||||
if (item.tags.length > 0) {
|
||||
const tags = main.createDiv({ cls: "smart-review-tags" });
|
||||
const visibleTags = item.tags.slice(0, 4);
|
||||
for (const tag of visibleTags) {
|
||||
tags.createSpan({ cls: "smart-review-tag", text: tag });
|
||||
}
|
||||
if (item.tags.length > visibleTags.length) {
|
||||
tags.createSpan({ cls: "smart-review-tag smart-review-tag-more", text: `+${item.tags.length - visibleTags.length}` });
|
||||
}
|
||||
}
|
||||
|
||||
const feedback = task.createDiv({ cls: "smart-review-feedback" });
|
||||
feedback.createDiv({ cls: "smart-review-feedback-label", text: "本次复习结果" });
|
||||
const buttons = feedback.createDiv({ cls: "smart-review-feedback-buttons" });
|
||||
for (const rating of REVIEW_RATINGS) {
|
||||
const button = buttons.createEl("button", {
|
||||
cls: `smart-review-rating smart-review-rating-${rating}`
|
||||
});
|
||||
button.setAttr("title", ratingTooltip(rating));
|
||||
button.createSpan({ cls: "smart-review-rating-label", text: ratingLabel(rating) });
|
||||
button.createSpan({ cls: "smart-review-rating-subtitle", text: ratingSubtitle(rating) });
|
||||
button.onclick = async () => {
|
||||
await this.plugin.reviewFileByPath(item.file, rating);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderMeta(container: HTMLElement, label: string, value: string | undefined): void {
|
||||
if (value === undefined || value.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = container.createSpan({ cls: "smart-review-meta-item" });
|
||||
item.createSpan({ cls: "smart-review-meta-label", text: label });
|
||||
item.createSpan({ cls: "smart-review-meta-value", text: value });
|
||||
}
|
||||
|
||||
function ratingLabel(rating: ReviewRating): string {
|
||||
if (rating === "again") {
|
||||
return "重来";
|
||||
}
|
||||
if (rating === "hard") {
|
||||
return "困难";
|
||||
}
|
||||
if (rating === "easy") {
|
||||
return "简单";
|
||||
}
|
||||
return "掌握";
|
||||
}
|
||||
|
||||
function ratingSubtitle(rating: ReviewRating): string {
|
||||
if (rating === "again") {
|
||||
return "Again";
|
||||
}
|
||||
if (rating === "hard") {
|
||||
return "Hard";
|
||||
}
|
||||
if (rating === "easy") {
|
||||
return "Easy";
|
||||
}
|
||||
return "Good";
|
||||
}
|
||||
|
||||
function ratingTooltip(rating: ReviewRating): string {
|
||||
if (rating === "again") {
|
||||
return "没有掌握,安排短间隔复习,并增加 lapse 计数。";
|
||||
}
|
||||
if (rating === "hard") {
|
||||
return "比较吃力,安排较短间隔复习。";
|
||||
}
|
||||
if (rating === "easy") {
|
||||
return "很轻松,安排更长间隔复习。";
|
||||
}
|
||||
return "正常掌握,按默认间隔重复算法安排下一次复习。";
|
||||
}
|
||||
|
||||
function stateLabel(item: ReviewItem): string {
|
||||
if (item.review_state === "overdue" && item.days_delta !== null) {
|
||||
return `逾期 ${Math.abs(item.days_delta)} 天`;
|
||||
}
|
||||
|
||||
if (item.review_state === "today") {
|
||||
return "今日";
|
||||
}
|
||||
|
||||
if (item.review_state === "next_7_days" && item.days_delta !== null) {
|
||||
return `${item.days_delta} 天后`;
|
||||
}
|
||||
|
||||
if (item.review_state === "future" && item.days_delta !== null) {
|
||||
return `${item.days_delta} 天后`;
|
||||
}
|
||||
|
||||
return "日期无效";
|
||||
}
|
||||
101
apps/obsidian-smart-review-plugin/scanner.ts
Normal file
101
apps/obsidian-smart-review-plugin/scanner.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import type { App, TFile } from "obsidian";
|
||||
import {
|
||||
calculateDaysDelta,
|
||||
getReviewState,
|
||||
normalizeDate,
|
||||
summarizeReviewItems,
|
||||
type ReviewIndex,
|
||||
type ReviewItem
|
||||
} from "@obsidian-smart-review/shared";
|
||||
import type { SmartReviewSettings } from "./settings";
|
||||
import {
|
||||
assignOptionalOrder,
|
||||
assignOptionalString,
|
||||
collectTags,
|
||||
createObsidianUri,
|
||||
getOptionalString,
|
||||
isAllowedStatus,
|
||||
parseFilterList,
|
||||
parseFolderPrefixList,
|
||||
type Frontmatter
|
||||
} from "./utils";
|
||||
|
||||
export function buildReviewIndex(app: App, settings: SmartReviewSettings): ReviewIndex {
|
||||
const vaultName = resolveVaultName(app, settings);
|
||||
const files = app.vault.getMarkdownFiles();
|
||||
const items = files.flatMap((file) => createReviewItem(app, settings, file, vaultName));
|
||||
|
||||
return {
|
||||
generated_at: new Date().toISOString(),
|
||||
vault_name: vaultName,
|
||||
summary: summarizeReviewItems(items),
|
||||
items
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveVaultName(app: App, settings: SmartReviewSettings): string {
|
||||
const configured = settings.vaultName.trim();
|
||||
return configured.length > 0 ? configured : app.vault.getName();
|
||||
}
|
||||
|
||||
function createReviewItem(app: App, settings: SmartReviewSettings, file: TFile, vaultName: string): ReviewItem[] {
|
||||
if (settings.ignoreTemplateFolder && shouldIgnoreFolder(file.path, settings.ignoredFolderPrefixes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cache = app.metadataCache.getFileCache(file);
|
||||
const frontmatter = (cache?.frontmatter ?? {}) as Frontmatter;
|
||||
const status = getOptionalString(frontmatter.status);
|
||||
|
||||
if (settings.ignoreInactive && !isAllowedStatus(status, parseFilterList(settings.allowedStatuses))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nextReview = normalizeDate(frontmatter.next_review);
|
||||
const daysDelta = nextReview === null ? null : calculateDaysDelta(nextReview);
|
||||
const item: ReviewItem = {
|
||||
title: getOptionalString(frontmatter.title) ?? file.basename,
|
||||
file: file.path,
|
||||
next_review: nextReview,
|
||||
review_state: getReviewState(daysDelta),
|
||||
days_delta: daysDelta,
|
||||
tags: collectTags(frontmatter.tags, cache),
|
||||
obsidian_uri: createObsidianUri(vaultName, file.path)
|
||||
};
|
||||
|
||||
assignOptionalString(item, "domain", frontmatter.domain);
|
||||
assignOptionalString(item, "type", frontmatter.type);
|
||||
assignOptionalString(item, "series", frontmatter.series);
|
||||
assignOptionalOrder(item, frontmatter.order);
|
||||
assignOptionalString(item, "status", frontmatter.status);
|
||||
|
||||
return shouldIncludeItem(settings, item) ? [item] : [];
|
||||
}
|
||||
|
||||
function shouldIgnoreFolder(filePath: string, ignoredFolderPrefixes: string): boolean {
|
||||
const prefixes = parseFolderPrefixList(ignoredFolderPrefixes);
|
||||
return prefixes.length > 0 && prefixes.some((prefix) => filePath.startsWith(prefix));
|
||||
}
|
||||
|
||||
function shouldIncludeItem(settings: SmartReviewSettings, item: ReviewItem): boolean {
|
||||
if (settings.exportScope === "due_only" && !["overdue", "today", "next_7_days"].includes(item.review_state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const includedTypes = parseFilterList(settings.includedTypes);
|
||||
if (includedTypes.length > 0 && !includedTypes.includes(item.type ?? "")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const domainFilter = parseFilterList(settings.domainFilter);
|
||||
if (domainFilter.length > 0 && !domainFilter.includes(item.domain ?? "")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tagFilter = parseFilterList(settings.tagFilter);
|
||||
if (tagFilter.length > 0 && !item.tags.some((tag) => tagFilter.includes(tag))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
314
apps/obsidian-smart-review-plugin/settings.ts
Normal file
314
apps/obsidian-smart-review-plugin/settings.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { App, PluginSettingTab, Setting } from "obsidian";
|
||||
import type ReviewSmartReviewPlugin from "./main";
|
||||
import type { ReviewRating } from "@obsidian-smart-review/shared";
|
||||
|
||||
export interface SmartReviewSettings {
|
||||
vaultName: string;
|
||||
outputPath: string;
|
||||
autoRefresh: boolean;
|
||||
ignoreInactive: boolean;
|
||||
allowedStatuses: string;
|
||||
ignoreTemplateFolder: boolean;
|
||||
ignoredFolderPrefixes: string;
|
||||
exportScope: "all" | "due_only";
|
||||
includedTypes: string;
|
||||
domainFilter: string;
|
||||
tagFilter: string;
|
||||
reviewIntervalDays: number;
|
||||
reviewHistoryPath: string;
|
||||
enableReviewHistory: boolean;
|
||||
dailyMarkdownPath: string;
|
||||
aiCardsPath: string;
|
||||
defaultReviewRating: ReviewRating;
|
||||
scanOnStartup: boolean;
|
||||
showStatusBarCount: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: SmartReviewSettings = {
|
||||
vaultName: "",
|
||||
outputPath: "review-index.json",
|
||||
autoRefresh: false,
|
||||
ignoreInactive: true,
|
||||
allowedStatuses: "active,published,draft",
|
||||
ignoreTemplateFolder: true,
|
||||
ignoredFolderPrefixes: "99-模板/",
|
||||
exportScope: "all",
|
||||
includedTypes: "article",
|
||||
domainFilter: "",
|
||||
tagFilter: "",
|
||||
reviewIntervalDays: 30,
|
||||
reviewHistoryPath: "review-history.jsonl",
|
||||
enableReviewHistory: true,
|
||||
dailyMarkdownPath: "00-总览/今日复习.md",
|
||||
aiCardsPath: "review-ai-cards.json",
|
||||
defaultReviewRating: "good",
|
||||
scanOnStartup: true,
|
||||
showStatusBarCount: true
|
||||
};
|
||||
|
||||
export const REVIEW_RATINGS: ReviewRating[] = ["again", "hard", "good", "easy"];
|
||||
|
||||
export class SmartReviewSettingTab extends PluginSettingTab {
|
||||
constructor(app: App, private readonly plugin: ReviewSmartReviewPlugin) {
|
||||
super(app, plugin);
|
||||
}
|
||||
|
||||
override display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl("h2", { text: "Obsidian Smart Review" });
|
||||
containerEl.createEl("p", {
|
||||
text: "插件本体可独立完成复习队列、Review Center、复习反馈、历史记录和 AI Payload 生成。",
|
||||
cls: "smart-review-setting-note"
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Vault 名称")
|
||||
.setDesc("用于 review-index.json 和 Obsidian URI。留空时使用当前 Vault 名称。")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(this.app.vault.getName())
|
||||
.setValue(this.plugin.settings.vaultName)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.vaultName = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("review-index.json 输出路径")
|
||||
.setDesc("覆盖写入的当前复习索引快照。默认:review-index.json")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(DEFAULT_SETTINGS.outputPath)
|
||||
.setValue(this.plugin.settings.outputPath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.outputPath = value.trim() || DEFAULT_SETTINGS.outputPath;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("自动刷新")
|
||||
.setDesc("元数据变化、文件重命名或删除后自动重新扫描并覆盖更新 review-index.json。")
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.autoRefresh).onChange(async (value) => {
|
||||
this.plugin.settings.autoRefresh = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("忽略 inactive / archived / deleted 笔记")
|
||||
.setDesc("开启后只包含空 status 和下方允许列表中的 status。关闭后不按 status 过滤。")
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.ignoreInactive).onChange(async (value) => {
|
||||
this.plugin.settings.ignoreInactive = value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.refreshReviewData({ writeIndex: true, notice: false });
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("允许纳入复习的 status")
|
||||
.setDesc("英文逗号分隔。空 status 始终允许。默认:active,published,draft。")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(DEFAULT_SETTINGS.allowedStatuses)
|
||||
.setValue(this.plugin.settings.allowedStatuses)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.allowedStatuses = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.refreshReviewData({ writeIndex: true, notice: false });
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("忽略指定目录")
|
||||
.setDesc("开启后跳过下方目录列表中的笔记。关闭后不按目录过滤。")
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.ignoreTemplateFolder).onChange(async (value) => {
|
||||
this.plugin.settings.ignoreTemplateFolder = value;
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.refreshReviewData({ writeIndex: true, notice: false });
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("忽略目录列表")
|
||||
.setDesc("英文逗号分隔,路径相对 Vault 根目录。默认:99-模板/。例如:Templates/, 00-模板/")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(DEFAULT_SETTINGS.ignoredFolderPrefixes)
|
||||
.setValue(this.plugin.settings.ignoredFolderPrefixes)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.ignoredFolderPrefixes = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.refreshReviewData({ writeIndex: true, notice: false });
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("导出范围")
|
||||
.setDesc("导出全部纳入扫描的笔记,或只导出逾期/今日/未来 7 天。")
|
||||
.addDropdown((dropdown) =>
|
||||
dropdown
|
||||
.addOption("all", "全部任务")
|
||||
.addOption("due_only", "只导出待复习任务")
|
||||
.setValue(this.plugin.settings.exportScope)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.exportScope = value === "due_only" ? "due_only" : "all";
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.refreshReviewData({ writeIndex: true, notice: false });
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("纳入复习的笔记类型")
|
||||
.setDesc("按 frontmatter 的 type 字段过滤,英文逗号分隔。默认只纳入 article,避免 series / dashboard 导航页进入复习计划。留空表示不过滤。")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(DEFAULT_SETTINGS.includedTypes)
|
||||
.setValue(this.plugin.settings.includedTypes)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.includedTypes = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
await this.plugin.refreshReviewData({ writeIndex: true, notice: false });
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Domain 过滤")
|
||||
.setDesc("可选,英文逗号分隔。留空表示不过滤。")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("基础设施与运维, 知识管理与工作流")
|
||||
.setValue(this.plugin.settings.domainFilter)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.domainFilter = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Tag 过滤")
|
||||
.setDesc("可选,英文逗号分隔。留空表示不过滤。")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("主题/Obsidian, 模块/云计算")
|
||||
.setValue(this.plugin.settings.tagFilter)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.tagFilter = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("review-history.jsonl 输出路径")
|
||||
.setDesc("追加写入的复习动作事件日志。默认:review-history.jsonl")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(DEFAULT_SETTINGS.reviewHistoryPath)
|
||||
.setValue(this.plugin.settings.reviewHistoryPath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.reviewHistoryPath = value.trim() || DEFAULT_SETTINGS.reviewHistoryPath;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("启用复习历史记录")
|
||||
.setDesc("开启后每次复习反馈都会追加一行 JSONL。")
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.enableReviewHistory).onChange(async (value) => {
|
||||
this.plugin.settings.enableReviewHistory = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("今日复习 Markdown 输出路径")
|
||||
.setDesc("覆盖写入的 Obsidian 原生复习中心。默认:00-总览/今日复习.md")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(DEFAULT_SETTINGS.dailyMarkdownPath)
|
||||
.setValue(this.plugin.settings.dailyMarkdownPath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.dailyMarkdownPath = value.trim() || DEFAULT_SETTINGS.dailyMarkdownPath;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("AI 复习卡片 Payload 输出路径")
|
||||
.setDesc("覆盖写入的 prompt_payload 文件,不调用远程 AI API。默认:review-ai-cards.json")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(DEFAULT_SETTINGS.aiCardsPath)
|
||||
.setValue(this.plugin.settings.aiCardsPath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.aiCardsPath = value.trim() || DEFAULT_SETTINGS.aiCardsPath;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("默认复习评分")
|
||||
.setDesc("Mark Current Note Reviewed 使用的默认评分。")
|
||||
.addDropdown((dropdown) => {
|
||||
for (const rating of REVIEW_RATINGS) {
|
||||
dropdown.addOption(rating, rating);
|
||||
}
|
||||
dropdown.setValue(this.plugin.settings.defaultReviewRating).onChange(async (value) => {
|
||||
this.plugin.settings.defaultReviewRating = REVIEW_RATINGS.includes(value as ReviewRating)
|
||||
? (value as ReviewRating)
|
||||
: DEFAULT_SETTINGS.defaultReviewRating;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("初始复习间隔天数")
|
||||
.setDesc("新笔记没有复习历史时使用的基础间隔。")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder(String(DEFAULT_SETTINGS.reviewIntervalDays))
|
||||
.setValue(String(this.plugin.settings.reviewIntervalDays))
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.reviewIntervalDays = parseReviewIntervalDays(value);
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("插件启动时自动扫描")
|
||||
.setDesc("启动 Obsidian 后自动扫描当前 Vault,并刷新状态栏。")
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.scanOnStartup).onChange(async (value) => {
|
||||
this.plugin.settings.scanOnStartup = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("状态栏显示复习计数")
|
||||
.setDesc("在 Obsidian 底部状态栏显示今日和逾期数量,点击可打开 Review Center。")
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.showStatusBarCount).onChange(async (value) => {
|
||||
this.plugin.settings.showStatusBarCount = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.updateStatusBar();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseReviewIntervalDays(value: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
return DEFAULT_SETTINGS.reviewIntervalDays;
|
||||
}
|
||||
|
||||
return Math.min(parsed, 3650);
|
||||
}
|
||||
27
apps/obsidian-smart-review-plugin/status-bar.ts
Normal file
27
apps/obsidian-smart-review-plugin/status-bar.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { ReviewIndex } from "@obsidian-smart-review/shared";
|
||||
|
||||
export class SmartReviewStatusBar {
|
||||
constructor(
|
||||
private readonly statusEl: HTMLElement,
|
||||
private readonly openReviewCenter: () => void
|
||||
) {
|
||||
this.statusEl.addClass("smart-review-status-bar");
|
||||
this.statusEl.onclick = () => {
|
||||
this.openReviewCenter();
|
||||
};
|
||||
}
|
||||
|
||||
update(index: ReviewIndex | null, visible: boolean): void {
|
||||
this.statusEl.toggleClass("smart-review-status-bar-hidden", !visible);
|
||||
if (!visible) {
|
||||
this.statusEl.setText("");
|
||||
return;
|
||||
}
|
||||
|
||||
const today = index?.summary.today ?? 0;
|
||||
const overdue = index?.summary.overdue ?? 0;
|
||||
this.statusEl.setText(overdue > 0 ? `📚 今日 ${today}|逾期 ${overdue}` : `📚 今日 ${today}`);
|
||||
this.statusEl.setAttr("aria-label", "Open Review Center");
|
||||
this.statusEl.setAttr("title", "Open Review Center");
|
||||
}
|
||||
}
|
||||
409
apps/obsidian-smart-review-plugin/styles.css
Normal file
409
apps/obsidian-smart-review-plugin/styles.css
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
.smart-review-setting-note,
|
||||
.smart-review-subtitle,
|
||||
.smart-review-generated-at,
|
||||
.smart-review-task-path,
|
||||
.smart-review-meta-label,
|
||||
.smart-review-empty,
|
||||
.smart-review-feedback-help,
|
||||
.smart-review-feedback-label,
|
||||
.smart-review-rating-subtitle {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.smart-review-status-bar {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.smart-review-status-bar:hover {
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.smart-review-status-bar-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.smart-review-center {
|
||||
width: 100%;
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 22px;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.smart-review-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.smart-review-header h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 28px;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.smart-review-subtitle {
|
||||
margin: 0 0 16px;
|
||||
font-size: var(--font-ui-medium);
|
||||
}
|
||||
|
||||
.smart-review-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 18px 0 12px;
|
||||
}
|
||||
|
||||
.smart-review-stat {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
background: var(--background-secondary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.smart-review-stat-value {
|
||||
font-size: 30px;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.smart-review-stat-label {
|
||||
margin-top: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
}
|
||||
|
||||
.smart-review-stat-overdue .smart-review-stat-value {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.smart-review-stat-today .smart-review-stat-value {
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.smart-review-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 18px 0 10px;
|
||||
}
|
||||
|
||||
.smart-review-action-button,
|
||||
.smart-review-rating,
|
||||
.smart-review-task-title {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.smart-review-action-button {
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.smart-review-action-button:hover,
|
||||
.smart-review-rating:hover,
|
||||
.smart-review-task-title:hover {
|
||||
background: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.smart-review-feedback-help {
|
||||
margin: 0 0 20px;
|
||||
font-size: var(--font-ui-small);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.smart-review-group {
|
||||
margin: 18px 0;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
background: var(--background-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.smart-review-group-overdue {
|
||||
border-color: var(--text-error);
|
||||
}
|
||||
|
||||
.smart-review-group-today {
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.smart-review-group-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 13px 16px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.smart-review-group-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.smart-review-group-count {
|
||||
min-width: 24px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
font-size: var(--font-ui-small);
|
||||
}
|
||||
|
||||
.smart-review-task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.smart-review-task {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-left-width: 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--background-secondary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.smart-review-task-overdue {
|
||||
border-left-color: var(--text-error);
|
||||
}
|
||||
|
||||
.smart-review-task-today {
|
||||
border-left-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.smart-review-task-next_7_days,
|
||||
.smart-review-task-future,
|
||||
.smart-review-task-invalid {
|
||||
border-left-color: var(--background-modifier-border-hover);
|
||||
}
|
||||
|
||||
.smart-review-task-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.smart-review-task-title-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.smart-review-task-title {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
appearance: none;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
white-space: normal !important;
|
||||
overflow: visible;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.smart-review-state {
|
||||
max-width: 180px;
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
font-size: var(--font-ui-smaller);
|
||||
line-height: 1.35;
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-muted);
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.smart-review-state-overdue {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.smart-review-state-today {
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.smart-review-task-path {
|
||||
margin-top: 6px;
|
||||
font-size: var(--font-ui-smaller);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.smart-review-task-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.smart-review-meta-item,
|
||||
.smart-review-tag {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 999px;
|
||||
padding: 4px 9px;
|
||||
font-size: var(--font-ui-smaller);
|
||||
line-height: 1.35;
|
||||
background: var(--background-primary);
|
||||
}
|
||||
|
||||
.smart-review-meta-value {
|
||||
color: var(--text-normal);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.smart-review-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.smart-review-tag {
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.smart-review-tag-more {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.smart-review-feedback {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.smart-review-feedback-label {
|
||||
margin-bottom: 8px;
|
||||
font-size: var(--font-ui-small);
|
||||
}
|
||||
|
||||
.smart-review-feedback-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(92px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.smart-review-rating {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
text-align: left;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.smart-review-rating-label {
|
||||
font-size: var(--font-ui-small);
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.smart-review-rating-subtitle {
|
||||
margin-top: 2px;
|
||||
font-size: var(--font-ui-smaller);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.smart-review-rating-again .smart-review-rating-label {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.smart-review-rating-good .smart-review-rating-label,
|
||||
.smart-review-rating-easy .smart-review-rating-label {
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.smart-review-empty,
|
||||
.smart-review-error {
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: var(--background-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.smart-review-empty-inline {
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.smart-review-error {
|
||||
margin: 12px 0;
|
||||
border-color: var(--text-error);
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.smart-review-center {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.smart-review-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.smart-review-stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.smart-review-feedback-buttons {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.smart-review-stats,
|
||||
.smart-review-feedback-buttons {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.smart-review-task-title-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.smart-review-state {
|
||||
max-width: 100%;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
11
apps/obsidian-smart-review-plugin/tsconfig.json
Normal file
11
apps/obsidian-smart-review-plugin/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"noEmit": true,
|
||||
"types": ["obsidian"]
|
||||
},
|
||||
"include": ["main.ts"]
|
||||
}
|
||||
179
apps/obsidian-smart-review-plugin/utils.ts
Normal file
179
apps/obsidian-smart-review-plugin/utils.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import type { CachedMetadata } from "obsidian";
|
||||
import { normalizePath } from "obsidian";
|
||||
import type { ReviewItem } from "@obsidian-smart-review/shared";
|
||||
import { DEFAULT_SETTINGS } from "./settings";
|
||||
|
||||
export type Frontmatter = Record<string, unknown>;
|
||||
|
||||
export function getOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getOptionalNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function assignOptionalString<T extends "domain" | "type" | "series" | "status">(
|
||||
item: ReviewItem,
|
||||
key: T,
|
||||
value: unknown
|
||||
): void {
|
||||
const stringValue = getOptionalString(value);
|
||||
if (stringValue !== undefined) {
|
||||
item[key] = stringValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function assignOptionalOrder(item: ReviewItem, value: unknown): void {
|
||||
if (typeof value === "number" || typeof value === "string") {
|
||||
item.order = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAllowedStatus(status: string | undefined, allowedStatuses: string[]): boolean {
|
||||
const normalizedStatus = (status ?? "").trim().toLowerCase();
|
||||
if (normalizedStatus.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return allowedStatuses.map((value) => value.toLowerCase()).includes(normalizedStatus);
|
||||
}
|
||||
|
||||
export function collectTags(frontmatterTags: unknown, cache: CachedMetadata | null): string[] {
|
||||
const tags = new Set<string>();
|
||||
|
||||
for (const tag of normalizeFrontmatterTags(frontmatterTags)) {
|
||||
const normalized = normalizeTag(tag);
|
||||
if (normalized !== null) {
|
||||
tags.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tagCache of cache?.tags ?? []) {
|
||||
const normalized = normalizeTag(tagCache.tag);
|
||||
if (normalized !== null) {
|
||||
tags.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return [...tags].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function normalizeFrontmatterTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((tag) => getOptionalString(tag)).filter((tag): tag is string => tag !== undefined);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(/[,\s]+/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normalizeTag(tag: string): string | null {
|
||||
const normalized = tag.trim().replace(/^#/, "");
|
||||
if (normalized.length === 0 || normalized.endsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createObsidianUri(vaultName: string, filePath: string): string {
|
||||
return `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(filePath)}`;
|
||||
}
|
||||
|
||||
export function normalizeOutputPath(outputPath: string, fallback = DEFAULT_SETTINGS.outputPath): string {
|
||||
const trimmed = normalizePath(outputPath.trim().replace(/^\/+/, ""));
|
||||
return trimmed.length > 0 ? trimmed : fallback;
|
||||
}
|
||||
|
||||
export function parseFilterList(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
export function parseFolderPrefixList(value: string): string[] {
|
||||
return parseFilterList(value).map((item) => {
|
||||
const normalized = normalizePath(item.replace(/^\/+/, ""));
|
||||
return normalized.endsWith("/") ? normalized : `${normalized}/`;
|
||||
});
|
||||
}
|
||||
|
||||
export function isMissingFileError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
return /not found|no such file|does not exist/i.test(error.message);
|
||||
}
|
||||
|
||||
return /not found|no such file|does not exist/i.test(String(error));
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function ensureParentFolder(adapter: { exists(path: string): Promise<boolean>; mkdir(path: string): Promise<void> }, filePath: string): Promise<void> {
|
||||
const normalized = normalizePath(filePath);
|
||||
const slashIndex = normalized.lastIndexOf("/");
|
||||
if (slashIndex <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const folderPath = normalized.slice(0, slashIndex);
|
||||
if (await adapter.exists(folderPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = folderPath.split("/");
|
||||
let current = "";
|
||||
for (const part of parts) {
|
||||
current = current.length === 0 ? part : `${current}/${part}`;
|
||||
if (!(await adapter.exists(current))) {
|
||||
await adapter.mkdir(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getNextReviewCount(value: unknown): number {
|
||||
const current = typeof value === "number" ? value : Number(value);
|
||||
return Number.isInteger(current) && current >= 0 ? current + 1 : 1;
|
||||
}
|
||||
|
||||
export function getNextReviewLapses(value: unknown, lapseDelta: number): number {
|
||||
const current = typeof value === "number" ? value : Number(value);
|
||||
const existing = Number.isInteger(current) && current >= 0 ? current : 0;
|
||||
return existing + lapseDelta;
|
||||
}
|
||||
|
||||
export function formatLocalDateTime(date = new Date()): string {
|
||||
const year = date.getFullYear().toString().padStart(4, "0");
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
const hour = date.getHours().toString().padStart(2, "0");
|
||||
const minute = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||
}
|
||||
3
apps/obsidian-smart-review-plugin/versions.json
Normal file
3
apps/obsidian-smart-review-plugin/versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.1.0": "1.5.0"
|
||||
}
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "obsidian-smart-review",
|
||||
"name": "Obsidian Smart Review",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Smart review system based on Obsidian Properties / YAML frontmatter with Review Center, spaced review feedback, history, and AI payload export.",
|
||||
"author": "Jayce",
|
||||
"authorUrl": "",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
21
package.json
Normal file
21
package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "obsidian-smart-review",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Obsidian Smart Review plugin and optional companion app workspace.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"dev": "pnpm --filter @obsidian-smart-review/obsidian-plugin dev",
|
||||
"lint": "pnpm -r lint",
|
||||
"audit:review": "node scripts/audit-review-index.js",
|
||||
"analyze:history": "node scripts/analyze-review-history.js",
|
||||
"generate:ai-cards": "node scripts/generate-ai-review-cards.js",
|
||||
"test": "pnpm -r test",
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
23
packages/shared/package.json
Normal file
23
packages/shared/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "@obsidian-smart-review/shared",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsc -p tsconfig.json --watch",
|
||||
"lint": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "pnpm build && node --test tests/*.test.mjs",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
98
packages/shared/src/date.ts
Normal file
98
packages/shared/src/date.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
const DATE_ONLY_PATTERN = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/;
|
||||
|
||||
export function normalizeDate(input: unknown): string | null {
|
||||
if (input instanceof Date) {
|
||||
return Number.isNaN(input.getTime()) ? null : getLocalDateString(input);
|
||||
}
|
||||
|
||||
if (typeof input !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateOnlyMatch = DATE_ONLY_PATTERN.exec(trimmed);
|
||||
if (dateOnlyMatch) {
|
||||
const [, year, month, day] = dateOnlyMatch;
|
||||
return buildDateString(Number(year), Number(month), Number(day));
|
||||
}
|
||||
|
||||
const parsed = new Date(trimmed);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getLocalDateString(parsed);
|
||||
}
|
||||
|
||||
export function getLocalDateString(date: Date = new Date()): string {
|
||||
return buildDateString(date.getFullYear(), date.getMonth() + 1, date.getDate()) ?? "";
|
||||
}
|
||||
|
||||
export function calculateDaysDelta(nextReview: string, today: string = getLocalDateString()): number | null {
|
||||
const normalizedNextReview = normalizeDate(nextReview);
|
||||
const normalizedToday = normalizeDate(today);
|
||||
|
||||
if (normalizedNextReview === null || normalizedToday === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextTime = toUtcDateOnlyTime(normalizedNextReview);
|
||||
const todayTime = toUtcDateOnlyTime(normalizedToday);
|
||||
|
||||
if (nextTime === null || todayTime === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round((nextTime - todayTime) / 86_400_000);
|
||||
}
|
||||
|
||||
export function calculateNextReviewDate(
|
||||
reviewedAt: string = getLocalDateString(),
|
||||
intervalDays: number = 30
|
||||
): string | null {
|
||||
const normalizedReviewedAt = normalizeDate(reviewedAt);
|
||||
if (normalizedReviewedAt === null || !Number.isInteger(intervalDays) || intervalDays < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reviewedAtTime = toUtcDateOnlyTime(normalizedReviewedAt);
|
||||
if (reviewedAtTime === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextReview = new Date(reviewedAtTime + intervalDays * 86_400_000);
|
||||
return buildDateString(nextReview.getUTCFullYear(), nextReview.getUTCMonth() + 1, nextReview.getUTCDate());
|
||||
}
|
||||
|
||||
function buildDateString(year: number, month: number, day: number): string | null {
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = new Date(year, month - 1, day);
|
||||
if (
|
||||
candidate.getFullYear() !== year ||
|
||||
candidate.getMonth() !== month - 1 ||
|
||||
candidate.getDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${year.toString().padStart(4, "0")}-${month.toString().padStart(2, "0")}-${day
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toUtcDateOnlyTime(dateString: string): number | null {
|
||||
const match = DATE_ONLY_PATTERN.exec(dateString);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, year, month, day] = match;
|
||||
return Date.UTC(Number(year), Number(month) - 1, Number(day));
|
||||
}
|
||||
11
packages/shared/src/index.ts
Normal file
11
packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export type {
|
||||
ReviewIndex,
|
||||
ReviewItem,
|
||||
ReviewRating,
|
||||
ReviewState,
|
||||
ReviewSummary,
|
||||
SpacedReviewInput,
|
||||
SpacedReviewResult
|
||||
} from "./types";
|
||||
export { calculateDaysDelta, calculateNextReviewDate, getLocalDateString, normalizeDate } from "./date";
|
||||
export { calculateSpacedReview, createEmptySummary, getReviewState, summarizeReviewItems } from "./review";
|
||||
108
packages/shared/src/review.ts
Normal file
108
packages/shared/src/review.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import type { ReviewItem, ReviewRating, ReviewState, ReviewSummary, SpacedReviewInput, SpacedReviewResult } from "./types";
|
||||
|
||||
const DEFAULT_EASE = 2.5;
|
||||
const MIN_EASE = 1.3;
|
||||
const MAX_EASE = 3;
|
||||
const DEFAULT_INTERVAL_DAYS = 30;
|
||||
|
||||
export function getReviewState(daysDelta: number | null): ReviewState {
|
||||
if (daysDelta === null) {
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
if (daysDelta < 0) {
|
||||
return "overdue";
|
||||
}
|
||||
|
||||
if (daysDelta === 0) {
|
||||
return "today";
|
||||
}
|
||||
|
||||
if (daysDelta <= 7) {
|
||||
return "next_7_days";
|
||||
}
|
||||
|
||||
return "future";
|
||||
}
|
||||
|
||||
export function createEmptySummary(): ReviewSummary {
|
||||
return {
|
||||
overdue: 0,
|
||||
today: 0,
|
||||
next_7_days: 0,
|
||||
future: 0,
|
||||
invalid: 0,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeReviewItems(items: ReviewItem[]): ReviewSummary {
|
||||
const summary = createEmptySummary();
|
||||
|
||||
for (const item of items) {
|
||||
summary[item.review_state] += 1;
|
||||
summary.total += 1;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function calculateSpacedReview(input: SpacedReviewInput): SpacedReviewResult {
|
||||
const currentIntervalDays = normalizePositiveInteger(input.currentIntervalDays, 0);
|
||||
const defaultIntervalDays = normalizePositiveInteger(input.defaultIntervalDays, DEFAULT_INTERVAL_DAYS);
|
||||
const currentEase = clampEase(input.currentEase);
|
||||
|
||||
if (input.rating === "again") {
|
||||
return {
|
||||
rating: input.rating,
|
||||
intervalDays: 1,
|
||||
ease: roundEase(currentEase - 0.2),
|
||||
lapseDelta: 1
|
||||
};
|
||||
}
|
||||
|
||||
if (input.rating === "hard") {
|
||||
return {
|
||||
rating: input.rating,
|
||||
intervalDays: Math.max(2, Math.round(getBaseInterval(currentIntervalDays, defaultIntervalDays) * 1.2)),
|
||||
ease: roundEase(currentEase - 0.05),
|
||||
lapseDelta: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (input.rating === "easy") {
|
||||
return {
|
||||
rating: input.rating,
|
||||
intervalDays: Math.max(defaultIntervalDays, Math.round(getBaseInterval(currentIntervalDays, defaultIntervalDays) * currentEase * 1.3)),
|
||||
ease: roundEase(currentEase + 0.15),
|
||||
lapseDelta: 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rating: input.rating,
|
||||
intervalDays: Math.max(defaultIntervalDays, Math.round(getBaseInterval(currentIntervalDays, defaultIntervalDays) * currentEase)),
|
||||
ease: roundEase(currentEase),
|
||||
lapseDelta: 0
|
||||
};
|
||||
}
|
||||
|
||||
function getBaseInterval(currentIntervalDays: number, defaultIntervalDays: number): number {
|
||||
return currentIntervalDays > 0 ? currentIntervalDays : defaultIntervalDays;
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: number | null | undefined, fallback: number): number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function clampEase(value: number | null | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return DEFAULT_EASE;
|
||||
}
|
||||
|
||||
return Math.min(MAX_EASE, Math.max(MIN_EASE, value));
|
||||
}
|
||||
|
||||
function roundEase(value: number): number {
|
||||
return Math.round(Math.min(MAX_EASE, Math.max(MIN_EASE, value)) * 100) / 100;
|
||||
}
|
||||
47
packages/shared/src/types.ts
Normal file
47
packages/shared/src/types.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
export type ReviewState = "overdue" | "today" | "next_7_days" | "future" | "invalid";
|
||||
export type ReviewRating = "again" | "hard" | "good" | "easy";
|
||||
|
||||
export interface ReviewItem {
|
||||
title: string;
|
||||
file: string;
|
||||
next_review: string | null;
|
||||
review_state: ReviewState;
|
||||
days_delta: number | null;
|
||||
domain?: string;
|
||||
type?: string;
|
||||
series?: string;
|
||||
order?: number | string;
|
||||
status?: string;
|
||||
tags: string[];
|
||||
obsidian_uri: string;
|
||||
}
|
||||
|
||||
export interface ReviewSummary {
|
||||
overdue: number;
|
||||
today: number;
|
||||
next_7_days: number;
|
||||
future: number;
|
||||
invalid: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ReviewIndex {
|
||||
generated_at: string;
|
||||
vault_name: string;
|
||||
summary: ReviewSummary;
|
||||
items: ReviewItem[];
|
||||
}
|
||||
|
||||
export interface SpacedReviewInput {
|
||||
rating: ReviewRating;
|
||||
currentIntervalDays?: number | null;
|
||||
currentEase?: number | null;
|
||||
defaultIntervalDays?: number;
|
||||
}
|
||||
|
||||
export interface SpacedReviewResult {
|
||||
rating: ReviewRating;
|
||||
intervalDays: number;
|
||||
ease: number;
|
||||
lapseDelta: number;
|
||||
}
|
||||
33
packages/shared/tests/date.test.mjs
Normal file
33
packages/shared/tests/date.test.mjs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { calculateDaysDelta, calculateNextReviewDate, getLocalDateString, normalizeDate } from "../dist/date.js";
|
||||
|
||||
test("normalizeDate normalizes supported date inputs", () => {
|
||||
assert.equal(normalizeDate("2026-05-28"), "2026-05-28");
|
||||
assert.equal(normalizeDate("2026/05/28"), "2026-05-28");
|
||||
assert.equal(normalizeDate(new Date(2026, 4, 28)), "2026-05-28");
|
||||
});
|
||||
|
||||
test("normalizeDate rejects invalid or empty values", () => {
|
||||
assert.equal(normalizeDate(""), null);
|
||||
assert.equal(normalizeDate("YYYY-MM-DD"), null);
|
||||
assert.equal(normalizeDate(null), null);
|
||||
});
|
||||
|
||||
test("getLocalDateString formats local date parts", () => {
|
||||
assert.equal(getLocalDateString(new Date(2026, 0, 9)), "2026-01-09");
|
||||
});
|
||||
|
||||
test("calculateDaysDelta calculates review deltas", () => {
|
||||
assert.equal(calculateDaysDelta("2026-06-04", "2026-05-28"), 7);
|
||||
assert.equal(calculateDaysDelta("2026-05-28", "2026-05-28"), 0);
|
||||
assert.equal(calculateDaysDelta("2026-05-20", "2026-05-28"), -8);
|
||||
assert.equal(calculateDaysDelta("invalid", "2026-05-28"), null);
|
||||
});
|
||||
|
||||
test("calculateNextReviewDate adds a positive review interval", () => {
|
||||
assert.equal(calculateNextReviewDate("2026-05-28", 30), "2026-06-27");
|
||||
assert.equal(calculateNextReviewDate("2026/05/28", 7), "2026-06-04");
|
||||
assert.equal(calculateNextReviewDate("invalid", 7), null);
|
||||
assert.equal(calculateNextReviewDate("2026-05-28", 0), null);
|
||||
});
|
||||
83
packages/shared/tests/review.test.mjs
Normal file
83
packages/shared/tests/review.test.mjs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { calculateSpacedReview, createEmptySummary, getReviewState, summarizeReviewItems } from "../dist/review.js";
|
||||
|
||||
test("getReviewState maps day deltas to states", () => {
|
||||
assert.equal(getReviewState(null), "invalid");
|
||||
assert.equal(getReviewState(-1), "overdue");
|
||||
assert.equal(getReviewState(0), "today");
|
||||
assert.equal(getReviewState(7), "next_7_days");
|
||||
assert.equal(getReviewState(8), "future");
|
||||
});
|
||||
|
||||
test("summarizeReviewItems counts review states and total", () => {
|
||||
const items = [
|
||||
createItem("overdue"),
|
||||
createItem("today"),
|
||||
createItem("next_7_days"),
|
||||
createItem("future"),
|
||||
createItem("invalid"),
|
||||
createItem("overdue")
|
||||
];
|
||||
|
||||
assert.deepEqual(summarizeReviewItems(items), {
|
||||
overdue: 2,
|
||||
today: 1,
|
||||
next_7_days: 1,
|
||||
future: 1,
|
||||
invalid: 1,
|
||||
total: 6
|
||||
});
|
||||
});
|
||||
|
||||
test("createEmptySummary returns zero counts", () => {
|
||||
assert.deepEqual(createEmptySummary(), {
|
||||
overdue: 0,
|
||||
today: 0,
|
||||
next_7_days: 0,
|
||||
future: 0,
|
||||
invalid: 0,
|
||||
total: 0
|
||||
});
|
||||
});
|
||||
|
||||
test("calculateSpacedReview maps ratings to intervals and ease", () => {
|
||||
assert.deepEqual(
|
||||
calculateSpacedReview({ rating: "again", currentIntervalDays: 30, currentEase: 2.5 }),
|
||||
{ rating: "again", intervalDays: 1, ease: 2.3, lapseDelta: 1 }
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
calculateSpacedReview({ rating: "hard", currentIntervalDays: 30, currentEase: 2.5 }),
|
||||
{ rating: "hard", intervalDays: 36, ease: 2.45, lapseDelta: 0 }
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
calculateSpacedReview({ rating: "good", currentIntervalDays: 30, currentEase: 2.5 }),
|
||||
{ rating: "good", intervalDays: 75, ease: 2.5, lapseDelta: 0 }
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
calculateSpacedReview({ rating: "easy", currentIntervalDays: 30, currentEase: 2.5 }),
|
||||
{ rating: "easy", intervalDays: 98, ease: 2.65, lapseDelta: 0 }
|
||||
);
|
||||
});
|
||||
|
||||
test("calculateSpacedReview uses defaults for new notes", () => {
|
||||
assert.deepEqual(
|
||||
calculateSpacedReview({ rating: "good", defaultIntervalDays: 7 }),
|
||||
{ rating: "good", intervalDays: 18, ease: 2.5, lapseDelta: 0 }
|
||||
);
|
||||
});
|
||||
|
||||
function createItem(reviewState) {
|
||||
return {
|
||||
title: reviewState,
|
||||
file: `${reviewState}.md`,
|
||||
next_review: null,
|
||||
review_state: reviewState,
|
||||
days_delta: null,
|
||||
tags: [],
|
||||
obsidian_uri: `obsidian://open?vault=test&file=${reviewState}.md`
|
||||
};
|
||||
}
|
||||
11
packages/shared/tsconfig.json
Normal file
11
packages/shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
397
pnpm-lock.yaml
Normal file
397
pnpm-lock.yaml
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
|
||||
apps/obsidian-smart-review-plugin:
|
||||
dependencies:
|
||||
'@obsidian-smart-review/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
devDependencies:
|
||||
builtin-modules:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
esbuild:
|
||||
specifier: ^0.25.4
|
||||
version: 0.25.12
|
||||
obsidian:
|
||||
specifier: ^1.8.7
|
||||
version: 1.12.3(@codemirror/state@6.5.0)(@codemirror/view@6.38.6)
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/shared:
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@codemirror/state@6.5.0':
|
||||
resolution: {integrity: sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==}
|
||||
|
||||
'@codemirror/view@6.38.6':
|
||||
resolution: {integrity: sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.25.12':
|
||||
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.25.12':
|
||||
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.25.12':
|
||||
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.12':
|
||||
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.25.12':
|
||||
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.25.12':
|
||||
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.25.12':
|
||||
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.12':
|
||||
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.12':
|
||||
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.12':
|
||||
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.25.12':
|
||||
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.25.12':
|
||||
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.12':
|
||||
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.12':
|
||||
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.25.12':
|
||||
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.25.12':
|
||||
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.25.12':
|
||||
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.25.12':
|
||||
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2':
|
||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||
|
||||
'@types/codemirror@5.60.8':
|
||||
resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/tern@0.23.9':
|
||||
resolution: {integrity: sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==}
|
||||
|
||||
builtin-modules@4.0.0:
|
||||
resolution: {integrity: sha512-p1n8zyCkt1BVrKNFymOHjcDSAl7oq/gUvfgULv2EblgpPVQlQr9yHnWjg9IJ2MhfwPqiYqMMrr01OY7yQoK2yA==}
|
||||
engines: {node: '>=18.20'}
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
esbuild@0.25.12:
|
||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
moment@2.29.4:
|
||||
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
|
||||
|
||||
obsidian@1.12.3:
|
||||
resolution: {integrity: sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==}
|
||||
peerDependencies:
|
||||
'@codemirror/state': 6.5.0
|
||||
'@codemirror/view': 6.38.6
|
||||
|
||||
style-mod@4.1.3:
|
||||
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@codemirror/state@6.5.0':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.2
|
||||
|
||||
'@codemirror/view@6.38.6':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.0
|
||||
crelt: 1.0.6
|
||||
style-mod: 4.1.3
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.12':
|
||||
optional: true
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2': {}
|
||||
|
||||
'@types/codemirror@5.60.8':
|
||||
dependencies:
|
||||
'@types/tern': 0.23.9
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/tern@0.23.9':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
|
||||
builtin-modules@4.0.0: {}
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
esbuild@0.25.12:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.12
|
||||
'@esbuild/android-arm': 0.25.12
|
||||
'@esbuild/android-arm64': 0.25.12
|
||||
'@esbuild/android-x64': 0.25.12
|
||||
'@esbuild/darwin-arm64': 0.25.12
|
||||
'@esbuild/darwin-x64': 0.25.12
|
||||
'@esbuild/freebsd-arm64': 0.25.12
|
||||
'@esbuild/freebsd-x64': 0.25.12
|
||||
'@esbuild/linux-arm': 0.25.12
|
||||
'@esbuild/linux-arm64': 0.25.12
|
||||
'@esbuild/linux-ia32': 0.25.12
|
||||
'@esbuild/linux-loong64': 0.25.12
|
||||
'@esbuild/linux-mips64el': 0.25.12
|
||||
'@esbuild/linux-ppc64': 0.25.12
|
||||
'@esbuild/linux-riscv64': 0.25.12
|
||||
'@esbuild/linux-s390x': 0.25.12
|
||||
'@esbuild/linux-x64': 0.25.12
|
||||
'@esbuild/netbsd-arm64': 0.25.12
|
||||
'@esbuild/netbsd-x64': 0.25.12
|
||||
'@esbuild/openbsd-arm64': 0.25.12
|
||||
'@esbuild/openbsd-x64': 0.25.12
|
||||
'@esbuild/openharmony-arm64': 0.25.12
|
||||
'@esbuild/sunos-x64': 0.25.12
|
||||
'@esbuild/win32-arm64': 0.25.12
|
||||
'@esbuild/win32-ia32': 0.25.12
|
||||
'@esbuild/win32-x64': 0.25.12
|
||||
|
||||
moment@2.29.4: {}
|
||||
|
||||
obsidian@1.12.3(@codemirror/state@6.5.0)(@codemirror/view@6.38.6):
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.0
|
||||
'@codemirror/view': 6.38.6
|
||||
'@types/codemirror': 5.60.8
|
||||
moment: 2.29.4
|
||||
|
||||
style-mod@4.1.3: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
packages:
|
||||
- "apps/obsidian-smart-review-plugin"
|
||||
- "packages/*"
|
||||
131
scripts/analyze-review-history.js
Normal file
131
scripts/analyze-review-history.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const VALID_RATINGS = new Set(["again", "hard", "good", "easy"]);
|
||||
|
||||
function usage() {
|
||||
console.log('Usage: node scripts/analyze-review-history.js "/path/to/review-history.jsonl"');
|
||||
}
|
||||
|
||||
const inputPath = process.argv[2];
|
||||
|
||||
if (!inputPath) {
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const historyPath = path.resolve(inputPath);
|
||||
|
||||
if (!fs.existsSync(historyPath)) {
|
||||
console.log(JSON.stringify({
|
||||
file: historyPath,
|
||||
total_reviews: 0,
|
||||
rating_counts: createRatingCounts(),
|
||||
daily_counts: {},
|
||||
top_reviewed_notes: [],
|
||||
latest_reviewed_at: null,
|
||||
warnings: [`Review history file not found: ${historyPath}`],
|
||||
errors: []
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const lines = fs.readFileSync(historyPath, "utf8")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
const entries = [];
|
||||
const warnings = [];
|
||||
const errors = [];
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
const normalized = normalizeEntry(entry);
|
||||
if (normalized === null) {
|
||||
warnings.push(`Skipped malformed history entry at line ${index + 1}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push(normalized);
|
||||
} catch (error) {
|
||||
errors.push(`Invalid JSON at line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const ratingCounts = createRatingCounts();
|
||||
const dailyCounts = new Map();
|
||||
const noteCounts = new Map();
|
||||
let latestReviewedAt = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
ratingCounts[entry.rating] += 1;
|
||||
increment(dailyCounts, entry.reviewed_at);
|
||||
increment(noteCounts, entry.file);
|
||||
|
||||
if (latestReviewedAt === null || entry.reviewed_at > latestReviewedAt) {
|
||||
latestReviewedAt = entry.reviewed_at;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
file: historyPath,
|
||||
total_reviews: entries.length,
|
||||
rating_counts: ratingCounts,
|
||||
daily_counts: toSortedObject(dailyCounts),
|
||||
top_reviewed_notes: topEntries(noteCounts, 10),
|
||||
latest_reviewed_at: latestReviewedAt,
|
||||
warnings,
|
||||
errors
|
||||
}, null, 2));
|
||||
|
||||
if (errors.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function normalizeEntry(value) {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reviewedAt = typeof value.reviewed_at === "string" ? value.reviewed_at : null;
|
||||
const file = typeof value.file === "string" ? value.file : null;
|
||||
const rating = typeof value.rating === "string" && VALID_RATINGS.has(value.rating) ? value.rating : null;
|
||||
|
||||
if (reviewedAt === null || file === null || rating === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
reviewed_at: reviewedAt,
|
||||
file,
|
||||
rating
|
||||
};
|
||||
}
|
||||
|
||||
function createRatingCounts() {
|
||||
return {
|
||||
again: 0,
|
||||
hard: 0,
|
||||
good: 0,
|
||||
easy: 0
|
||||
};
|
||||
}
|
||||
|
||||
function increment(map, key) {
|
||||
map.set(key, (map.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
function toSortedObject(map) {
|
||||
return Object.fromEntries([...map.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
|
||||
}
|
||||
|
||||
function topEntries(map, limit) {
|
||||
return [...map.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, limit)
|
||||
.map(([file, count]) => ({ file, count }));
|
||||
}
|
||||
|
||||
149
scripts/audit-review-index.js
Executable file
149
scripts/audit-review-index.js
Executable file
|
|
@ -0,0 +1,149 @@
|
|||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const VALID_STATES = new Set(["overdue", "today", "next_7_days", "future", "invalid"]);
|
||||
|
||||
function usage() {
|
||||
console.log('Usage: node scripts/audit-review-index.js "/path/to/review-index.json"');
|
||||
}
|
||||
|
||||
const inputPath = process.argv[2];
|
||||
|
||||
if (!inputPath) {
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const reviewIndexPath = path.resolve(inputPath);
|
||||
|
||||
if (!fs.existsSync(reviewIndexPath)) {
|
||||
console.error(`Error: review-index.json not found: ${reviewIndexPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(reviewIndexPath, "utf8"));
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
const summary = data.summary ?? {};
|
||||
const warnings = [];
|
||||
const errors = [];
|
||||
|
||||
const stateCounts = createStateCounts();
|
||||
const statusCounts = new Map();
|
||||
const domainCounts = new Map();
|
||||
const typeCounts = new Map();
|
||||
const emptyTailTags = [];
|
||||
const templateItems = [];
|
||||
const invalidItems = [];
|
||||
const malformedUris = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (!VALID_STATES.has(item.review_state)) {
|
||||
errors.push(`Invalid review_state for ${item.file ?? item.title ?? "(unknown item)"}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
stateCounts[item.review_state] += 1;
|
||||
increment(statusCounts, item.status ?? "(empty)");
|
||||
increment(domainCounts, item.domain ?? "(empty)");
|
||||
increment(typeCounts, item.type ?? "(empty)");
|
||||
|
||||
if (item.review_state === "invalid") {
|
||||
invalidItems.push(item);
|
||||
}
|
||||
|
||||
if (typeof item.file === "string" && item.file.startsWith("99-模板/")) {
|
||||
templateItems.push(item);
|
||||
}
|
||||
|
||||
if (Array.isArray(item.tags)) {
|
||||
for (const tag of item.tags) {
|
||||
if (typeof tag === "string" && tag.endsWith("/")) {
|
||||
emptyTailTags.push({ file: item.file, title: item.title, tag });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof item.obsidian_uri !== "string" || !item.obsidian_uri.startsWith("obsidian://open?")) {
|
||||
malformedUris.push({ file: item.file, title: item.title, obsidian_uri: item.obsidian_uri });
|
||||
}
|
||||
}
|
||||
|
||||
stateCounts.total = items.length;
|
||||
|
||||
for (const key of ["overdue", "today", "next_7_days", "future", "invalid", "total"]) {
|
||||
if (summary[key] !== stateCounts[key]) {
|
||||
errors.push(`Summary mismatch: ${key} expected ${stateCounts[key]}, got ${summary[key]}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (emptyTailTags.length > 0) {
|
||||
warnings.push(`Found ${emptyTailTags.length} empty tail tags, for example ${emptyTailTags[0].tag}`);
|
||||
}
|
||||
|
||||
if (templateItems.length > 0) {
|
||||
warnings.push(`Found ${templateItems.length} template items under 99-模板/`);
|
||||
}
|
||||
|
||||
if (malformedUris.length > 0) {
|
||||
errors.push(`Found ${malformedUris.length} malformed obsidian_uri values`);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
file: reviewIndexPath,
|
||||
generated_at: data.generated_at,
|
||||
vault_name: data.vault_name,
|
||||
summary,
|
||||
recomputed_summary: stateCounts,
|
||||
status_counts: toObject(statusCounts),
|
||||
top_domains: topEntries(domainCounts, 10),
|
||||
type_counts: toObject(typeCounts),
|
||||
invalid_count: invalidItems.length,
|
||||
template_item_count: templateItems.length,
|
||||
empty_tail_tag_count: emptyTailTags.length,
|
||||
malformed_uri_count: malformedUris.length,
|
||||
sample_invalid: invalidItems.slice(0, 5).map(toSample),
|
||||
sample_empty_tail_tags: emptyTailTags.slice(0, 5),
|
||||
warnings,
|
||||
errors
|
||||
}, null, 2));
|
||||
|
||||
if (errors.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function createStateCounts() {
|
||||
return {
|
||||
overdue: 0,
|
||||
today: 0,
|
||||
next_7_days: 0,
|
||||
future: 0,
|
||||
invalid: 0,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
function increment(map, key) {
|
||||
map.set(key, (map.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
function toObject(map) {
|
||||
return Object.fromEntries([...map.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
|
||||
}
|
||||
|
||||
function topEntries(map, limit) {
|
||||
return [...map.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, limit)
|
||||
.map(([key, count]) => ({ key, count }));
|
||||
}
|
||||
|
||||
function toSample(item) {
|
||||
return {
|
||||
title: item.title,
|
||||
file: item.file,
|
||||
next_review: item.next_review,
|
||||
status: item.status,
|
||||
tags: item.tags
|
||||
};
|
||||
}
|
||||
105
scripts/generate-ai-review-cards.js
Normal file
105
scripts/generate-ai-review-cards.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const DUE_STATES = new Set(["overdue", "today", "next_7_days"]);
|
||||
|
||||
function usage() {
|
||||
console.log('Usage: node scripts/generate-ai-review-cards.js "/path/to/vault" "/path/to/review-index.json" [output.json] [limit]');
|
||||
}
|
||||
|
||||
const [vaultArg, indexArg, outputArg, limitArg] = process.argv.slice(2);
|
||||
|
||||
if (!vaultArg || !indexArg) {
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const vaultPath = path.resolve(vaultArg);
|
||||
const indexPath = path.resolve(indexArg);
|
||||
const outputPath = path.resolve(outputArg ?? path.join(vaultPath, "review-ai-cards.json"));
|
||||
const limit = parseLimit(limitArg);
|
||||
|
||||
if (!fs.existsSync(vaultPath) || !fs.statSync(vaultPath).isDirectory()) {
|
||||
console.error(`Error: vault path not found or not a directory: ${vaultPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
console.error(`Error: review-index.json not found: ${indexPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const reviewIndex = JSON.parse(fs.readFileSync(indexPath, "utf8"));
|
||||
const items = Array.isArray(reviewIndex.items) ? reviewIndex.items : [];
|
||||
const selectedItems = items
|
||||
.filter((item) => DUE_STATES.has(item.review_state))
|
||||
.slice(0, limit);
|
||||
|
||||
const output = {
|
||||
generated_at: new Date().toISOString(),
|
||||
vault_name: reviewIndex.vault_name ?? path.basename(vaultPath),
|
||||
source_review_index: indexPath,
|
||||
mode: "prompt_payload",
|
||||
items: selectedItems.map((item) => createPromptPayload(vaultPath, item))
|
||||
};
|
||||
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`);
|
||||
console.log(JSON.stringify({
|
||||
output: outputPath,
|
||||
item_count: output.items.length,
|
||||
mode: output.mode
|
||||
}, null, 2));
|
||||
|
||||
function createPromptPayload(vaultPath, item) {
|
||||
const notePath = path.join(vaultPath, item.file);
|
||||
const markdown = fs.existsSync(notePath) ? fs.readFileSync(notePath, "utf8") : "";
|
||||
const content = stripFrontmatter(markdown).trim().slice(0, 12_000);
|
||||
|
||||
return {
|
||||
file: item.file,
|
||||
title: item.title,
|
||||
review_state: item.review_state,
|
||||
days_delta: item.days_delta,
|
||||
source_updated: item.updated ?? null,
|
||||
prompt: buildPrompt(item, content),
|
||||
questions: [],
|
||||
summary: "",
|
||||
cards: []
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrompt(item, content) {
|
||||
return [
|
||||
"你是一个知识复习助手。请基于下面的 Obsidian 笔记生成复习材料。",
|
||||
"",
|
||||
"要求:",
|
||||
"1. 生成 3-5 个主动回忆问题。",
|
||||
"2. 生成 1 段 120 字以内的摘要。",
|
||||
"3. 生成 3 张知识卡片,每张包含 title、kind、content。",
|
||||
"4. 只基于笔记内容,不要编造事实。",
|
||||
"5. 输出 JSON,字段为 questions、summary、cards。",
|
||||
"",
|
||||
`标题:${item.title}`,
|
||||
`文件:${item.file}`,
|
||||
`复习状态:${item.review_state}`,
|
||||
"",
|
||||
"笔记内容:",
|
||||
content
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function stripFrontmatter(markdown) {
|
||||
if (!markdown.startsWith("---")) {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
const end = markdown.indexOf("\n---", 3);
|
||||
return end === -1 ? markdown : markdown.slice(end + 4);
|
||||
}
|
||||
|
||||
function parseLimit(value) {
|
||||
const parsed = Number(value ?? 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : 10;
|
||||
}
|
||||
|
||||
72
scripts/install-obsidian-smart-review-plugin.sh
Executable file
72
scripts/install-obsidian-smart-review-plugin.sh
Executable file
|
|
@ -0,0 +1,72 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PLUGIN_ID="obsidian-smart-review"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage:
|
||||
./scripts/install-obsidian-smart-review-plugin.sh "/path/to/your/vault"
|
||||
|
||||
Example for iCloud Obsidian vaults:
|
||||
./scripts/install-obsidian-smart-review-plugin.sh "\$HOME/Library/Mobile Documents/iCloud~md~obsidian/Documents/YourVaultName"
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VAULT_PATH="$1"
|
||||
|
||||
if [[ ! -d "$VAULT_PATH" ]]; then
|
||||
echo "Error: Vault path does not exist: $VAULT_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v corepack >/dev/null 2>&1; then
|
||||
echo "Error: corepack is required but was not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PLUGIN_SOURCE_DIR="$REPO_ROOT/apps/obsidian-smart-review-plugin"
|
||||
PLUGIN_TARGET_DIR="$VAULT_PATH/.obsidian/plugins/$PLUGIN_ID"
|
||||
|
||||
echo "Repository: $REPO_ROOT"
|
||||
echo "Vault: $VAULT_PATH"
|
||||
echo "Plugin: $PLUGIN_TARGET_DIR"
|
||||
echo
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo "Building plugin..."
|
||||
corepack pnpm build
|
||||
|
||||
for file in main.js manifest.json styles.css; do
|
||||
if [[ ! -f "$PLUGIN_SOURCE_DIR/$file" ]]; then
|
||||
echo "Error: Missing build artifact: $PLUGIN_SOURCE_DIR/$file" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "Creating plugin directory..."
|
||||
mkdir -p "$PLUGIN_TARGET_DIR"
|
||||
|
||||
echo "Copying plugin files..."
|
||||
cp "$PLUGIN_SOURCE_DIR/main.js" "$PLUGIN_TARGET_DIR/main.js"
|
||||
cp "$PLUGIN_SOURCE_DIR/manifest.json" "$PLUGIN_TARGET_DIR/manifest.json"
|
||||
cp "$PLUGIN_SOURCE_DIR/styles.css" "$PLUGIN_TARGET_DIR/styles.css"
|
||||
|
||||
echo
|
||||
echo "Installed Obsidian Smart Review plugin successfully."
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo " 1. Open Obsidian."
|
||||
echo " 2. Enable the community plugin: Obsidian Smart Review."
|
||||
echo " 3. Click the Ribbon icon to open Review Center."
|
||||
echo " 4. Run Command Palette: Generate Smart Review Index if you want to overwrite review-index.json manually."
|
||||
echo " 5. Check that this file exists: $VAULT_PATH/review-index.json"
|
||||
15
tsconfig.base.json
Normal file
15
tsconfig.base.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.1.0": "1.5.0"
|
||||
}
|
||||
Loading…
Reference in a new issue