From f4bab10857dd7b695b797ec626f0b047b84c930d Mon Sep 17 00:00:00 2001 From: Jaycelu Date: Fri, 29 May 2026 09:11:15 +0800 Subject: [PATCH] Prepare Obsidian Smart Review plugin release --- .gitignore | 21 + LICENSE | 21 + README.md | 80 ++++ README.zh-CN.md | 141 ++++++ apps/obsidian-smart-review-plugin/README.md | 36 ++ apps/obsidian-smart-review-plugin/ai-cards.ts | 93 ++++ .../esbuild.config.mjs | 42 ++ apps/obsidian-smart-review-plugin/main.ts | 373 ++++++++++++++++ .../manifest.json | 10 + .../markdown-export.ts | 51 +++ .../obsidian-smart-review-plugin/package.json | 21 + .../review-actions.ts | 193 +++++++++ .../review-center-view.ts | 257 +++++++++++ apps/obsidian-smart-review-plugin/scanner.ts | 101 +++++ apps/obsidian-smart-review-plugin/settings.ts | 314 ++++++++++++++ .../status-bar.ts | 27 ++ apps/obsidian-smart-review-plugin/styles.css | 409 ++++++++++++++++++ .../tsconfig.json | 11 + apps/obsidian-smart-review-plugin/utils.ts | 179 ++++++++ .../versions.json | 3 + manifest.json | 10 + package.json | 21 + packages/shared/package.json | 23 + packages/shared/src/date.ts | 98 +++++ packages/shared/src/index.ts | 11 + packages/shared/src/review.ts | 108 +++++ packages/shared/src/types.ts | 47 ++ packages/shared/tests/date.test.mjs | 33 ++ packages/shared/tests/review.test.mjs | 83 ++++ packages/shared/tsconfig.json | 11 + pnpm-lock.yaml | 397 +++++++++++++++++ pnpm-workspace.yaml | 3 + scripts/analyze-review-history.js | 131 ++++++ scripts/audit-review-index.js | 149 +++++++ scripts/generate-ai-review-cards.js | 105 +++++ .../install-obsidian-smart-review-plugin.sh | 72 +++ tsconfig.base.json | 15 + versions.json | 3 + 38 files changed, 3703 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.zh-CN.md create mode 100644 apps/obsidian-smart-review-plugin/README.md create mode 100644 apps/obsidian-smart-review-plugin/ai-cards.ts create mode 100644 apps/obsidian-smart-review-plugin/esbuild.config.mjs create mode 100644 apps/obsidian-smart-review-plugin/main.ts create mode 100644 apps/obsidian-smart-review-plugin/manifest.json create mode 100644 apps/obsidian-smart-review-plugin/markdown-export.ts create mode 100644 apps/obsidian-smart-review-plugin/package.json create mode 100644 apps/obsidian-smart-review-plugin/review-actions.ts create mode 100644 apps/obsidian-smart-review-plugin/review-center-view.ts create mode 100644 apps/obsidian-smart-review-plugin/scanner.ts create mode 100644 apps/obsidian-smart-review-plugin/settings.ts create mode 100644 apps/obsidian-smart-review-plugin/status-bar.ts create mode 100644 apps/obsidian-smart-review-plugin/styles.css create mode 100644 apps/obsidian-smart-review-plugin/tsconfig.json create mode 100644 apps/obsidian-smart-review-plugin/utils.ts create mode 100644 apps/obsidian-smart-review-plugin/versions.json create mode 100644 manifest.json create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/date.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/review.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/tests/date.test.mjs create mode 100644 packages/shared/tests/review.test.mjs create mode 100644 packages/shared/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/analyze-review-history.js create mode 100755 scripts/audit-review-index.js create mode 100644 scripts/generate-ai-review-cards.js create mode 100755 scripts/install-obsidian-smart-review-plugin.sh create mode 100644 tsconfig.base.json create mode 100644 versions.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f783155 --- /dev/null +++ b/.gitignore @@ -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* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8c6392c --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..03b0270 --- /dev/null +++ b/README.md @@ -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 +/.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. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..4c757a9 --- /dev/null +++ b/README.zh-CN.md @@ -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名>" +``` + +脚本会执行构建,并复制以下文件到 `/.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`。 diff --git a/apps/obsidian-smart-review-plugin/README.md b/apps/obsidian-smart-review-plugin/README.md new file mode 100644 index 0000000..069bb95 --- /dev/null +++ b/apps/obsidian-smart-review-plugin/README.md @@ -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 +/.obsidian/plugins/obsidian-smart-review/manifest.json +/.obsidian/plugins/obsidian-smart-review/main.js +/.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 原生今日复习中心。 diff --git a/apps/obsidian-smart-review-plugin/ai-cards.ts b/apps/obsidian-smart-review-plugin/ai-cards.ts new file mode 100644 index 0000000..6021e40 --- /dev/null +++ b/apps/obsidian-smart-review-plugin/ai-cards.ts @@ -0,0 +1,93 @@ +import type { App } from "obsidian"; +import type { ReviewIndex, ReviewItem } from "@obsidian-smart-review/shared"; + +const DUE_STATES = new Set(["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 { + 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 { + 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); +} diff --git a/apps/obsidian-smart-review-plugin/esbuild.config.mjs b/apps/obsidian-smart-review-plugin/esbuild.config.mjs new file mode 100644 index 0000000..893260f --- /dev/null +++ b/apps/obsidian-smart-review-plugin/esbuild.config.mjs @@ -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(); +} diff --git a/apps/obsidian-smart-review-plugin/main.ts b/apps/obsidian-smart-review-plugin/main.ts new file mode 100644 index 0000000..3fc824e --- /dev/null +++ b/apps/obsidian-smart-review-plugin/main.ts @@ -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 | null = null; + private statusBar: SmartReviewStatusBar | null = null; + private startupScanCompleted = false; + + override async onload(): Promise { + 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 { + const loaded = (await this.loadData()) as Partial | null; + this.settings = { + ...DEFAULT_SETTINGS, + ...loaded + }; + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } + + async openReviewCenter(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + return ( + typeof candidate.generated_at === "string" && + typeof candidate.vault_name === "string" && + typeof candidate.summary === "object" && + candidate.summary !== null && + Array.isArray(candidate.items) + ); +} diff --git a/apps/obsidian-smart-review-plugin/manifest.json b/apps/obsidian-smart-review-plugin/manifest.json new file mode 100644 index 0000000..b31d2b0 --- /dev/null +++ b/apps/obsidian-smart-review-plugin/manifest.json @@ -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 +} diff --git a/apps/obsidian-smart-review-plugin/markdown-export.ts b/apps/obsidian-smart-review-plugin/markdown-export.ts new file mode 100644 index 0000000..8227bad --- /dev/null +++ b/apps/obsidian-smart-review-plugin/markdown-export.ts @@ -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}]]`; +} diff --git a/apps/obsidian-smart-review-plugin/package.json b/apps/obsidian-smart-review-plugin/package.json new file mode 100644 index 0000000..813586e --- /dev/null +++ b/apps/obsidian-smart-review-plugin/package.json @@ -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" + } +} diff --git a/apps/obsidian-smart-review-plugin/review-actions.ts b/apps/obsidian-smart-review-plugin/review-actions.ts new file mode 100644 index 0000000..3578006 --- /dev/null +++ b/apps/obsidian-smart-review-plugin/review-actions.ts @@ -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 { + 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(); + + 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 { + 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 { + 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); + } +} diff --git a/apps/obsidian-smart-review-plugin/review-center-view.ts b/apps/obsidian-smart-review-plugin/review-center-view.ts new file mode 100644 index 0000000..172ca53 --- /dev/null +++ b/apps/obsidian-smart-review-plugin/review-center-view.ts @@ -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 { + 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 { + 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 "日期无效"; +} diff --git a/apps/obsidian-smart-review-plugin/scanner.ts b/apps/obsidian-smart-review-plugin/scanner.ts new file mode 100644 index 0000000..5578c47 --- /dev/null +++ b/apps/obsidian-smart-review-plugin/scanner.ts @@ -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; +} diff --git a/apps/obsidian-smart-review-plugin/settings.ts b/apps/obsidian-smart-review-plugin/settings.ts new file mode 100644 index 0000000..bd1c85c --- /dev/null +++ b/apps/obsidian-smart-review-plugin/settings.ts @@ -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); +} diff --git a/apps/obsidian-smart-review-plugin/status-bar.ts b/apps/obsidian-smart-review-plugin/status-bar.ts new file mode 100644 index 0000000..907e501 --- /dev/null +++ b/apps/obsidian-smart-review-plugin/status-bar.ts @@ -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"); + } +} diff --git a/apps/obsidian-smart-review-plugin/styles.css b/apps/obsidian-smart-review-plugin/styles.css new file mode 100644 index 0000000..cb49ad6 --- /dev/null +++ b/apps/obsidian-smart-review-plugin/styles.css @@ -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; + } +} diff --git a/apps/obsidian-smart-review-plugin/tsconfig.json b/apps/obsidian-smart-review-plugin/tsconfig.json new file mode 100644 index 0000000..d7b5527 --- /dev/null +++ b/apps/obsidian-smart-review-plugin/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "inlineSourceMap": true, + "inlineSources": true, + "noEmit": true, + "types": ["obsidian"] + }, + "include": ["main.ts"] +} diff --git a/apps/obsidian-smart-review-plugin/utils.ts b/apps/obsidian-smart-review-plugin/utils.ts new file mode 100644 index 0000000..ac7e9e3 --- /dev/null +++ b/apps/obsidian-smart-review-plugin/utils.ts @@ -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; + +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( + 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(); + + 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 { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +export async function ensureParentFolder(adapter: { exists(path: string): Promise; mkdir(path: string): Promise }, filePath: string): Promise { + 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}`; +} diff --git a/apps/obsidian-smart-review-plugin/versions.json b/apps/obsidian-smart-review-plugin/versions.json new file mode 100644 index 0000000..708016d --- /dev/null +++ b/apps/obsidian-smart-review-plugin/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.5.0" +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..b31d2b0 --- /dev/null +++ b/manifest.json @@ -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 +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..75799bd --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..896b297 --- /dev/null +++ b/packages/shared/package.json @@ -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" + } +} diff --git a/packages/shared/src/date.ts b/packages/shared/src/date.ts new file mode 100644 index 0000000..16174ef --- /dev/null +++ b/packages/shared/src/date.ts @@ -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)); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..e769f01 --- /dev/null +++ b/packages/shared/src/index.ts @@ -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"; diff --git a/packages/shared/src/review.ts b/packages/shared/src/review.ts new file mode 100644 index 0000000..263343a --- /dev/null +++ b/packages/shared/src/review.ts @@ -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; +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000..1e5d0a4 --- /dev/null +++ b/packages/shared/src/types.ts @@ -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; +} diff --git a/packages/shared/tests/date.test.mjs b/packages/shared/tests/date.test.mjs new file mode 100644 index 0000000..3ff2553 --- /dev/null +++ b/packages/shared/tests/date.test.mjs @@ -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); +}); diff --git a/packages/shared/tests/review.test.mjs b/packages/shared/tests/review.test.mjs new file mode 100644 index 0000000..bca4e5a --- /dev/null +++ b/packages/shared/tests/review.test.mjs @@ -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` + }; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..644c54a --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..dc61aed --- /dev/null +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..85b2580 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/obsidian-smart-review-plugin" + - "packages/*" diff --git a/scripts/analyze-review-history.js b/scripts/analyze-review-history.js new file mode 100644 index 0000000..7e35670 --- /dev/null +++ b/scripts/analyze-review-history.js @@ -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 })); +} + diff --git a/scripts/audit-review-index.js b/scripts/audit-review-index.js new file mode 100755 index 0000000..ab3e671 --- /dev/null +++ b/scripts/audit-review-index.js @@ -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 + }; +} diff --git a/scripts/generate-ai-review-cards.js b/scripts/generate-ai-review-cards.js new file mode 100644 index 0000000..948c158 --- /dev/null +++ b/scripts/generate-ai-review-cards.js @@ -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; +} + diff --git a/scripts/install-obsidian-smart-review-plugin.sh b/scripts/install-obsidian-smart-review-plugin.sh new file mode 100755 index 0000000..f0b9719 --- /dev/null +++ b/scripts/install-obsidian-smart-review-plugin.sh @@ -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 <&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" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..a38eb6d --- /dev/null +++ b/tsconfig.base.json @@ -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 + } +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..708016d --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.5.0" +}