Prepare Obsidian Smart Review plugin release

This commit is contained in:
Jaycelu 2026-05-29 09:11:15 +08:00
commit f4bab10857
38 changed files with 3703 additions and 0 deletions

21
.gitignore vendored Normal file
View 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
View 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
View 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
View 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`

View 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 原生今日复习中心。

View 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);
}

View 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();
}

View 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)
);
}

View 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
}

View 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}]]`;
}

View 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"
}
}

View 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);
}
}

View 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 "日期无效";
}

View 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;
}

View 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);
}

View 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");
}
}

View 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;
}
}

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"inlineSourceMap": true,
"inlineSources": true,
"noEmit": true,
"types": ["obsidian"]
},
"include": ["main.ts"]
}

View 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}`;
}

View file

@ -0,0 +1,3 @@
{
"0.1.0": "1.5.0"
}

10
manifest.json Normal file
View 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
View 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"
}
}

View 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"
}
}

View 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));
}

View 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";

View 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;
}

View 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;
}

View 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);
});

View 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`
};
}

View 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
View 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
View file

@ -0,0 +1,3 @@
packages:
- "apps/obsidian-smart-review-plugin"
- "packages/*"

View 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
View 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
};
}

View 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;
}

View 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
View 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
View file

@ -0,0 +1,3 @@
{
"0.1.0": "1.5.0"
}