# Jasper
轻量级 Joplin 兼容客户端:**本地 Rust(axum) 服务 + 浏览器 SPA**,不依赖 Electron/Tauri/WebView。
目标:启动快、内存小(后端常驻 ~10MB)、跨平台(macOS/Windows/Linux)、可读可写。
> 注意:仓库根目录下的 `joplin/` 是**上游 Joplin v3.6.15 源码(仅作格式参考,自带独立 git,已 gitignore)**;
> `JopinData/` 是**用户真实笔记测试数据(隐私,已 gitignore,勿提交/勿误删/勿在其上做破坏性测试)**。
> 本项目代码在 `core/`(纯逻辑)、`server/`(原生后端)、`web/`(前端)、`wasm/`(浏览器 demo)。
> 三个 Rust crate 用 path 依赖串联,**无 workspace**(`cd server && cargo run` 照常)。
## 目录结构
```
core/ 纯逻辑 crate (jasper-core):无 IO,可编译到 WASM
src/
model.rs Note/Folder/Resource/Tag/NoteTag + ItemType/MarkupLanguage 枚举
parser.rs 解析 Joplin .md 条目(含真实数据单测)
serialize.rs 写回:update_note_md / new_note_md / new_resource_md / new_id / ISO 时间
library.rs 内存索引(笔记本树/笔记/资源/标签/搜索 + 增删改);Library::from_contents
server/ Rust 后端 (axum),依赖 jasper-core
src/
main.rs 启动:加载配置→建索引→挂 API + 托管前端;pub use jasper_core::{...} 重导出
config.rs SQLite 配置存储(ConfigStore) + build_storage 工厂 + source_key
cache.rs SQLite 增量缓存(CacheStore):按数据源缓存条目原始内容+mtime
indexer.rs 从存储拉取(rayon 并行)+增量缓存协调 → 调 Library::from_contents(IO 在此,不进 core)
storage/ StorageBackend trait
mod.rs trait 定义 + is_item_filename + DEFAULT_INFO_JSON
local.rs 本地文件夹后端
webdav.rs WebDAV 后端 (ureq + roxmltree, PROPFIND/GET/PUT/DELETE/MKCOL)
plugins/ 插件宿主(feature = "plugins";mod.rs 含 feature-off 零成本桩)
mod.rs PluginHost 入口/桩、before_save 钩子入口、api_router
manifest.rs manifest.toml 解析与校验(含 [[contributes.storage]] / [[contributes.sidebar]] / config_schema)
runtime.rs wasmi 沙箱:fuel 切片 + resumable 检查点(CPU-only 墙钟)、StoreLimits、NotesCtx
host_api.rs host_call 方法表(log/settings/http.request/notes.*,能力门控 + 提案回传)
ai.rs ai.complete 宿主实现(genai:anthropic/openai 兼容 + 自定义 base_url)
install.rs zip 安装(zip-slip 防护/体量上限/版本比较)、扫描、卸载
routes.rs /api/plugins* + /api/ai/config 路由(列表/安装/启停/设置/资产/命令/ui/editor-transform/auto-approve)
storage.rs PluginStorage(storage.* dispatch → StorageBackend 适配)+ 配置校验
hooks.rs before-save 串联(插件失败不丢数据)
api.rs axum 路由与 handler,AppState
mcp.rs MCP server(feature = "mcp";含 feature-off 零成本桩):rmcp 的 Streamable HTTP
挂在 /mcp,13 个工具全是对 api.rs handler 的薄包装;门控在工具层(见 docs/mcp-server.md)
plugin-sdk/ 插件作者 SDK (jasper-plugin-sdk):ABI 胶水(rt.rs)/宿主封装(host.rs)/
Storage trait(storage.rs)/register! 宏;共享 jasper-core(serde) 类型
plugins-examples/ 宿主测试夹具/参考实现(cdylib → wasm32-unknown-unknown;build-wasm.sh 一键构建;path 依赖仓内 SDK,随宿主共演进;**不对外分发**——用户装的插件见「插件生态仓库」)
trim-trailing/ before-save 去行尾空白(spec 附录 B 参考实现)
testbed/ 测试夹具(echo/spin/alloc_bomb/bad_json/call_http + command 槽 echo-args/relay/
read-note/write-note/make-note/ai-echo/chat + ui 方法(main/notes 两 view),
喂宿主限额/能力门控/命令链路/notes 提案/ai/ui 测试;手写 ABI 不用宏;测试自写 manifest 只复用其 wasm)
webdav-storage/ 存储 provider 参考实现:WebDAV over host:http(对照内置 webdav.rs 的等价测试 = PluginStorage 适配器覆盖)
wasm/ 浏览器 demo crate (jasper-wasm):jasper-core + 内置演示库 → wasm-bindgen
src/lib.rs / demo.rs 暴露 folders/notes/note/search(只读),内置纯文本演示库
web/ Svelte 5 (runes) + Vite + TS 前端
src/
App.svelte 三栏布局(+右侧插件 dock 第四栏)+ 状态/配置流程
lib/
api.ts 后端 API 客户端 + 类型
render.ts markdown 渲染(markdown-it + 插件 + highlight.js + KaTeX + DOMPurify)
FolderTree.svelte / NoteList.svelte / NoteView.svelte
CodeMirrorEditor.svelte CodeMirror6 编辑器(懒加载;源码/实时预览两模式、同一 EditorView,切模式只重配隔间)
editor/ 编辑器内核:build(建 view)/setup(基础扩展+mode/readOnly 隔间)/livePreview(实时预览装饰)
/widgets(图片·音视频·PDF·附件·表格·公式内联 widget)/handle/commands/theme/types/markdown
Settings.svelte 首次配置向导 / 设置页(数据源段含插件 provider 动态表单)
messages.ts 中/英文案字典(zh 基准;`const en: typeof zh` 强制不漏键)
i18n.svelte.ts rune 存当前语言 + t() 取词/插值(localStorage 持久化 + 浏览器自动选)
plugins.svelte.ts 插件列表 rune store + 主题 注入 + sidebar/命令贡献过滤 + 管理动作
schema.ts / SchemaForm.svelte 字段词汇(spec §10)的校验与表单渲染(向导/插件设置/form widget 共用)
PluginPanel.svelte / PluginConsent.svelte 插件管理面板(顶栏 plug)+ 能力授权弹窗
PluginSidebar.svelte / UiWidget.svelte / ChatWidget.svelte 插件侧栏 dock + server-driven UI 渲染器 + 聊天(多会话持久化)
selection.svelte.ts / chatSessions.ts 笔记内容区选区捕获(并入命令 args)+ 聊天会话模型(localStorage 多会话)
pendingWrites.svelte.ts / PendingWriteDialog.svelte / diff.ts 写提案队列 + diff 确认框(spec 0.3)
shims.d.ts 无类型 markdown-it 插件的最小声明
docs/joplin-data-format.md 逆向出的 Joplin 数据格式规范(解析层依据)
docs/plugin-spec.md 插件规范 v0.3(契约);docs/plugin-design.md 架构决策
docs/mcp-server.md MCP server(工具清单 / 门控为何在工具层 / 为何复用 handler)
Dockerfile / docker-compose.yml / .dockerignore
```
## 开发与运行
```bash
# 后端(默认读已保存配置;无配置则进入“未配置态”,由前端向导配置)
cd server && cargo run # 监听 127.0.0.1:27583
cd server && cargo run -- <路径或URL> # 首次引导用数据源(会存进配置)
cd server && cargo test # 运行单元测试(parser/serialize/webdav)
# 前端
cd web && pnpm dev # 开发热更新(5173),/api 代理到 27583
cd web && pnpm build # 产出 web/dist,由后端在 27583 直接托管
# 生产:构建前端后访问 http://127.0.0.1:27583/
# 单文件打包(前端内嵌进二进制,运行时不依赖磁盘上的 web/dist)
cd web && pnpm build # 必须先有 web/dist
cd server && cargo build --release --features embed # 产物 server/target/release/jasper
# MCP server(把笔记库暴露给 Claude Code 等客户端;默认构建不含,见 docs/mcp-server.md)
cd server && cargo build --features mcp
claude mcp add --transport http jasper http://127.0.0.1:27583/mcp # 设了访问密码再加 Bearer 头;
# 从局域网 IP / 反代域名访问必须先在设置页生成 MCP API Key(没 key 只认本机 Host,见 docs/mcp-server.md「Host 检查」)
# 本地起一个 WebDAV 服务端联调(hacdias/webdav,端口 8081,账号 joplin/joplin)
docker compose -f docker-compose.dev.yml up -d
cd server && JASPER_SOURCE=http://127.0.0.1:8081/ \
JASPER_WEBDAV_USER=joplin JASPER_WEBDAV_PASS=joplin cargo run
docker compose -f docker-compose.dev.yml down -v # 用完清理(含数据卷)
```
环境变量(见 `server/src/main.rs` 头注释):
- `JASPER_HOST`(默认 127.0.0.1;局域网/容器设 0.0.0.0)
- `JASPER_PORT`(默认 27583)
- `JASPER_CONFIG_DIR`(配置库目录;默认平台配置目录 `jasper/config.db`)
- `JASPER_WEB_DIR`(前端静态目录;默认相对源码,容器里指向打包路径)
- `JASPER_READ_ONLY`(truthy=1/true/yes/on → 只读引导;仅当尚无保存配置时生效,之后以配置库为准)
- `JASPER_MCP_ALLOWED_HOSTS`(仅 mcp 构建;没设 MCP API Key 时额外放行的 Host,逗号分隔,写了端口就连端口一起比,`*` 放行全部;设了 key 后不查 Host)
- `RUST_LOG`(日志级别过滤,`tracing_subscriber::EnvFilter` 语法;不设时默认 `jasper=debug,tower_http=debug,info`——本项目内部与每条 HTTP 请求默认就是 debug 级可见,排障不用先设环境变量;调小用 `RUST_LOG=warn` 等)
- 首次引导:`JASPER_SOURCE` / `JASPER_WEBDAV_USER` / `JASPER_WEBDAV_PASS`
日志:用 **tracing**(+ `tracing-subscriber` env-filter)而非裸 `println!`/`eprintln!`;`api::router()` 挂了 `tower_http::trace::TraceLayer` 自动记每个 HTTP 请求(method/uri/status/latency)。写路径的单一咽喉 `persist_note_blocking`、插件调用的单一咽喉 `PluginHost::dispatch_with_notes` 都打了 debug 级日志,覆盖 API 写入/插件免确认直写/前端全部请求/一切插件调用,无需在各处分别加日志。测试里的 `跳过:...`/`skipping: ...` 提示仍用裸 `eprintln!`(多为无 subscriber 的场景,tracing 会静默不可见)。
## 开发工作流:新任务用独立分支 + worktree
- 每次开始一个新的 fix/feat,先用 `EnterWorktree`(`name` 用简短描述,如 `fix/xxx`/`feat/xxx`)开一个独立分支 + worktree,任务内独立提交、互不干扰;跨会话想回去用 `EnterWorktree({ path })` 重新进入。任务完成合并或废弃后用 `ExitWorktree`(`keep` 保留现场 / `remove` 连分支一并清理)。
- **进入新 worktree 后先接好构建缓存**,否则每个 worktree 要重新全量编译/装依赖:
- Rust:在 worktree 根写一份 `.cargo/config.toml`(已加入 `.gitignore`,不会被提交、也不会自动出现在其它 worktree)指向仓库外的共享 target 目录:
```toml
[build]
target-dir = "/Users/xvan/agent-home/jasper-note/.worktree-cache/cargo-target"
```
之后 `server/core/plugin-sdk/wasm/plugins-examples/*` 里跑 `cargo build/test` 都会自动复用这份编译缓存,只增量编译改动的部分。
- 前端:`web/node_modules` 仍需在每个 worktree 里跑一次 `pnpm install`,但 pnpm 全局 store 内容寻址、装得很快,不用额外配置。
## 浏览器 WASM 构建(无 server):只读展示 + 可写本地应用
`jasper-core`(model/parser/serialize/library)编译到 wasm,做成**零服务器**的两种前端构建(都无后端、走 WASM 内核)。设计文档见 `docs/wasm-app-design.md`。三态 api(`web/src/lib/api.ts`):`WASM_WRITABLE?localApi:IS_WASM?demoApi:httpApi`。
- **两个门闸标志**:`IS_WASM`(= DEMO||LOCAL,无后端构建,events/plugins 据此关闭)、`WASM_WRITABLE`(= LOCAL,可写持久)、`IS_DEMO`(= IS_WASM && !WASM_WRITABLE,只读展示横幅 + 只读闸门)。`readOnly = IS_DEMO || serverReadOnly || locked`。
- **只读展示站**(`pnpm build:demo` = `build:wasm` + `VITE_DEMO=1 vite build`):内置纯文本演示库(`wasm/src/demo.rs`,含标签),GitHub Pages 可挂;隐藏所有写入入口,顶部「演示预览」横幅。`demoApi` 走 `new Demo()`(内置库)。截图 `docs/screenshots/05-wasm-demo.png`。
- **可写本地应用**(`pnpm build:local` = `build:wasm` + `VITE_LOCAL=1 vite build`;`pnpm dev:local` 热更):笔记/笔记本/标签增删改移 + 资源图片,经 WASM 内核读写。`localApi` 走 `Demo.fromRaws(...)`;持久后端**运行时二选一**(`LocalBackend` 抽象:`loadRaws`/`persist`/资源读写)。设置页(数据源/鉴权/AI 无从谈起)隐藏,语言/主题在顶栏独立切换。
- **idbBackend**(默认,`web/src/lib/localStore.ts`):从 IndexedDB 恢复/首次用 `seedItems` 播种;每次写后 `saveRaws(inst.snapshot())` 全量快照落库;资源二进制存 IndexedDB。刷新不丢。
- **fsaBackend**(真实 Joplin 文件夹,`web/src/lib/fsStore.ts`,仅 Chromium):`showDirectoryPicker` 选文件夹 → 读 `.md`(过滤 `^[0-9a-fA-F]{32}\.md$`)→ `fromRaws`;写 = **per-file 增量**(`idOfRaw` 解析 id + `prevSnapshot` diff,只写改动/删已移除的 `.md`,不整目录重写);`.resource/` 二进制直读写。写回同 `serialize.rs` 格式 → Joplin 可同步。句柄存 IndexedDB `jasper-fs` 持久,回访 `queryPermission` 自动重连、过期 UI 提示「重新连接」(`requestPermission` 需用户手势)。顶栏「打开文件夹」控制挂 `export const localFolder = WASM_WRITABLE ? {...} : undefined`(native 折叠)。Firefox/Safari 无目录选择器 → 显示不支持提示(导入/导出降级待做,见设计文档 §9.1)。
- `resourceUrl` 同步返回 blob URL(启动/切后端时按当前后端预载资源建映射)。
- **WASM 内核写方法**(`wasm/src/lib.rs`):镜像 `api.rs` handler(createNote/updateNote/moveNote/deleteNote/create|rename|move|deleteFolder/add|removeNoteTag/upsertResourceMeta/rename|deleteResource),`Demo` 除 `Library` 外另存全类型 `raws: id→原始.md`(rename/move 笔记本/标签/资源需原始内容、也是 snapshot 来源)。**坑:`serialize::now_ms()` 用 `SystemTime::now()` 在 wasm32 上 panic**——所有写方法把 `now:f64` 当参数由 JS 注入 `Date.now()`,本层禁调 `now_ms()`;`new_id()` 走 getrandom js 后端可用。
- **不影响原生**:`DEMO`/`LOCAL` 是编译期常量,`false` 时三元折叠、`demoApi`/`localApi`/`localFolder` 连同 wasm 动态 import 与 fsStore 一起被 tree-shake 掉,原生构建既不打包也不依赖 `web/src/wasm-pkg`(wasm-pack 生成、已 gitignore)。需先装 `rustup target add wasm32-unknown-unknown` 与 `wasm-pack`。
- 测试:`localStore.test.ts`(IndexedDB 往返/播种,fake-indexeddb)+ `fsStore.test.ts`(fake `FileSystemDirectoryHandle` 内存实现,读/写/删/资源/文件名过滤);端到端 `build:local` 实机验证(IndexedDB:读+写+刷新持久;FSA:Playwright 注入 fake `showDirectoryPicker` 驱动 打开文件夹→读→新建→per-file 写回)。
## README 预览图(截图)
- `docs/screenshots/` 下 `01-reading`/`02-editor`/`03-resources`/`04-search` 各出**中英两套**:英文用基名(`*.png`,供英文 `README.md`),中文加 `.zh` 后缀(`*.zh.png`,供 `README.zh-CN.md`)。UI 语言与笔记内容都随之切换(英文页配英文笔记,中文页配中文笔记)。`05-wasm-demo.png` 单独手工出,不在此流程内。
- 一键重出:`cd web && pnpm shoot`(`scripts/shoot.mjs`)。前置:先 `pnpm build`(要 `web/dist`)+ `cd server && cargo build`(要 debug 二进制)。它对 en/zh 各自:`scripts/demo-library.mjs` 生成对应语言的演示库(临时目录,隔离配置)→ 起真后端 → Playwright(1280×820 @2x=2560×1640,浅色)截 4 张图。
- 浏览器:默认用 Playwright 自带 chromium;本机未下载对应版本时设 `SHOOT_CHANNEL=chrome` 用系统 Chrome(`pnpm shoot` 亦可 `SHOOT_CHANNEL=chrome pnpm shoot`)。
- `scripts/demo-library.mjs` 是 `docs/gen-demo-library.py` 的双语 JS 版(结构对齐、字段对齐 `serialize.rs`),仅供出图;改文案时两语言都要改。
- `06-access-control(.zh).png`(访问控制生效时的登录闸门)走独立脚本 `cd web && pnpm shoot:auth`(`scripts/shoot-auth.mjs`):起后端后先 `PUT /api/auth/settings` 设一个演示密码(`passwordless_read:false`)→ 全新浏览器 context(无 `jasper.token`)→ 截 `.locked-gate`。与 `shoot.mjs` 分开是因为其余 4 张图要保持「未配置鉴权」的默认状态,不能共用同一次启动。
## 数据源与配置
- 数据源支持**本地文件夹**和 **WebDAV**,配置(含 WebDAV 密码,**明文**)存到 SQLite(rusqlite bundled)。
- 启动时:已保存配置优先;否则用命令行/环境变量引导;都没有则**未配置态**(前端弹全屏向导:现有库/新建库 × 本地/WebDAV)。
- `AppState` 用 `RwLock