From b183d24ba50484a4638a3368af86bb3a4efeb467 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Fri, 8 May 2026 10:57:24 +0800 Subject: [PATCH] Implement local Markdown GFM parser and live refresh --- ...al-markdown-gfm-ast-parser-migration-v1.md | 205 +++- rust/Cargo.lock | 111 ++ rust/crates/mnote-web/Cargo.toml | 1 + rust/crates/mnote-web/src/routes/documents.rs | 7 +- .../src/routes/local_folder_source.rs | 981 ++++++------------ .../src/routes/local_markdown_parser.rs | 671 ++++++++++++ rust/crates/mnote-web/src/routes/mod.rs | 1 + rust/crates/mnote-web/src/routes/web_shell.rs | 122 ++- 8 files changed, 1354 insertions(+), 745 deletions(-) create mode 100644 rust/crates/mnote-web/src/routes/local_markdown_parser.rs diff --git a/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md b/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md index 2c51af31..9d073dd4 100644 --- a/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md +++ b/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md @@ -1,12 +1,6 @@ # 3-13 [process] Rust Web 本地 Markdown GFM AST 解析器迁移方案 v1 -> 更新时间:2026-05-08 -> -> 关联: -> - `/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/local_folder_source.rs` -> - `/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/web_shell.rs` -> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md` -> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md` +> 更新时间:2026-05-08关联:`/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/local_folder_source.rs/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/web_shell.rs/mnt/Data1T/mnote/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md/mnt/Data1T/mnote/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md` ## 1. 结论 @@ -19,28 +13,39 @@ 本地链路现在分成两段: 1. `local_folder_source.rs` 负责读取 `.md`、拆 frontmatter、把正文转成 block。 -2. `web_shell.rs` 负责把 legacy block 转成 Tiptap,再把 Tiptap 保存回 legacy block。 + +1. `web_shell.rs` 负责把 legacy block 转成 Tiptap,再把 Tiptap 保存回 legacy block。 问题不在 Tiptap 本身,而在前面的 Markdown 语义输入过窄: - task list、inline code、bold、italic、strike、link 之前会被吞成纯文本。 + - table 只做了最低限度兼容。 + - 解析逻辑是手写规则,扩展语法时会不断叠条件分支。 + - 读写两端各自维护一份近似规则,容易出现回写不一致。 ## 3. 目标 - 用 Rust 侧成熟 GFM AST 解析器替换手写 Markdown parser。 + - 保留当前已验证的本地文件夹读写能力。 + - 让 Markdown 语法来源尽量统一,减少“读得懂、写不回”的差异。 + - 继续支持当前已暴露的语法面:paragraph、heading、task list、list、quote、fenced code、divider、pipe table、attachment link、inline code、bold、italic、strike、link。 + - 不改变 `PageAggregate`、树 projection、权限、路由边界。 ## 4. 非目标 - 不在这一步追求“所有 Markdown 语法无损互转”。 + - 不把 VSCode 源码直接搬进来。 + - 不把 Next/Wolai 导入链路里的 `@blocknote/core markdownToBlocks` 作为本地 source 真相。 + - 不把 tree/domain、Convex 或 editor island 的所有语义一起重写。 ## 5. 方案比较 @@ -48,31 +53,43 @@ ### 方案 A:继续扩写手写 parser 优点: + - 改动小,能快速补洞。 缺点: + - 语法越来越多时会失控。 + - 读写规则难以保证一致。 + - 这条路本质上还是重复造轮子。 ### 方案 B:Rust GFM AST + 语义映射层 优点: + - 语法基础来自成熟实现,降低长期维护成本。 + - AST 天然适合做 block / inline 双向映射。 + - 更容易补齐 task list、table、inline mark 等常见 GFM 特性。 缺点: + - 需要补一层 AST 到 `PageAggregate` 的适配。 + - 初期需要做迁移和回归测试。 ### 方案 C:复用前端/导入侧解析器 优点: + - 可以少写部分逻辑。 缺点: + - 本地文件夹主链是 Rust 服务,不适合把语义真相放回 JS 侧。 + - 导入链路和本地打开链路不是同一职责。 ### 推荐 @@ -86,7 +103,9 @@ 新增一个本地 Markdown 解析模块,职责只做三件事: - 读取 Markdown 文本并构建 GFM AST。 + - 归一化 frontmatter、正文、块级节点、行内节点。 + - 输出可供 `PageAggregate` 使用的中间表示。 建议把手写字符串规则替换成“AST -> 中间 IR -> block”的两段式转换,而不是直接从 AST 拼最终 JSON。 @@ -96,6 +115,7 @@ 建议引入一个轻量中间层,表达 block / inline 的稳定语义: - block:paragraph、heading、task_item、list_item、quote、code_block、divider、table、media。 + - inline:text + marks(code、bold、italic、strike、underline、link)。 这样做的目的不是再造一套编辑器模型,而是把 Markdown 语义和 Tiptap/legacy block 解耦。 @@ -105,6 +125,7 @@ 输出层仍然保留两条现有通路: - 读入时:Markdown AST -> block document -> `PageAggregate.body.content` + - 保存时:editor document -> block document -> Markdown `web_shell.rs` 里的 legacy / Tiptap 转换仍然存在,但它应该只负责编辑器适配,不再承担 Markdown 语法识别。 @@ -114,19 +135,29 @@ 第一批必须稳定支持: - task list + - inline code + - bold / italic / strike + - link + - pipe table + - fenced code 第二批再扩: - nested list + - setext heading + - autolink + - footnote + - raw HTML + - block quote 多段落 ## 7. 迁移步骤 @@ -136,7 +167,9 @@ 先把当前自写 parser 包进一个兼容接口,替换为 Rust GFM AST 实现。 要求: + - 读入结果和现在的 `PageAggregate` 输出保持兼容。 + - 已有 smoke 和 Rust 单测先不改大结构。 ### Step 2:统一 block / inline 映射 @@ -144,8 +177,11 @@ 把 AST 到 block document 的映射集中到一个明确模块,不再散在 `local_folder_source.rs` 大文件里。 要求: + - task list 变成真正的 todo / task item 语义。 + - inline mark 进入统一的 marks 表达。 + - table cell 里的 mark 也能保留。 ### Step 3:统一回写 @@ -153,8 +189,11 @@ 把 block document -> Markdown 的输出改成与 AST 语义对齐的反向映射。 要求: + - task list 回写为 `- [x]` / `- [ ]`。 + - 行内 mark 回写为标准 Markdown。 + - 不支持的结构明确降级,不静默丢内容。 ### Step 4:清理临时补丁 @@ -168,9 +207,13 @@ 至少覆盖: - task list 读写往返。 + - inline code / bold / italic / strike / link 往返。 + - table 单元格内 inline mark。 + - frontmatter title 保留。 + - 未支持语法的降级策略。 ### 浏览器 smoke @@ -178,19 +221,25 @@ 至少覆盖: - 本地 `.md` 打开后 checkbox 真正出现在 ProseMirror DOM。 + - `code`、`strong`、`em`、`s`、`a` 在正文和表格单元格中真实渲染。 + - 保存后刷新不丢语义。 ### 回归边界 - 不破坏 `file_tree` / `page_tree`。 + - 不改变 local workspace 的只读边界。 + - 不影响 Convex workspace 链路。 ## 9. 风险 - GFM AST 到 block 的映射会比手写 parser 更清晰,但初期要补一轮语义归一化。 + - 复杂 Markdown 结构不可能天然无损,必须明确哪些语法是支持、哪些是降级。 + - 如果 AST 解析器选型过于底层,后续表格、任务列表和 inline mark 的兼容代码会变多,所以要优先选能直接拿到 AST 的成熟实现。 ## 10. 完成判定 @@ -198,89 +247,137 @@ 这个方案进入实现阶段的条件: - 解析器选型明确。 + - AST -> block / inline 映射接口冻结。 + - 当前手写 parser 的临时补丁有对应回归测试。 + - 浏览器 smoke 能稳定证明 task list 和 inline mark 真正渲染出来。 ## 11. 详细 checklist ### 11.1 选型与边界冻结 -- [ ] 确认 Rust 侧 Markdown AST 解析器选型。 -- [ ] 明确是否需要 GitHub Flavored Markdown 全集,还是只保留当前本地链路所需的 GFM 子集。 -- [ ] 冻结 frontmatter 处理方式:解析器只解析正文,还是 frontmatter 也由同一模块统一处理。 -- [ ] 冻结 AST 输出到中间 IR 的边界,不直接在 AST 层拼 `PageAggregate`。 -- [ ] 明确 table、task list、inline mark、fenced code、attachment link 的优先级。 -- [ ] 明确不支持语法的降级策略,要求可解释且可测试。 +- [x] 确认 Rust 侧 Markdown AST 解析器选型。 + +- [x] 明确是否需要 GitHub Flavored Markdown 全集,还是只保留当前本地链路所需的 GFM 子集。 + +- [x] 冻结 frontmatter 处理方式:解析器只解析正文,还是 frontmatter 也由同一模块统一处理。 + +- [x] 冻结 AST 输出到中间 IR 的边界,不直接在 AST 层拼 `PageAggregate`。 + +- [x] 明确 table、task list、inline mark、fenced code、attachment link 的优先级。 + +- [x] 明确不支持语法的降级策略,要求可解释且可测试。 ### 11.2 AST -> 中间 IR -- [ ] 建立 `MarkdownAstDocument` / 等价中间结构。 -- [ ] 建立 block 级节点映射:paragraph / heading / list / task / quote / code / divider / table / media。 -- [ ] 建立 inline 级节点映射:text / code / bold / italic / strike / underline / link。 -- [ ] 保证 task list 的 checked 状态进入正确的 task item 节点,而不是外层 list 节点。 -- [ ] 保证表格单元格内的 inline mark 不丢失。 +- [x] 建立 `MarkdownAstDocument` / 等价中间结构。 + +- [x] 建立 block 级节点映射:paragraph / heading / list / task / quote / code / divider / table / media。 + +- [x] 建立 inline 级节点映射:text / code / bold / italic / strike / underline / link。 + +- [x] 保证 task list 的 checked 状态进入正确的 task item 节点,而不是外层 list 节点。 + +- [x] 保证表格单元格内的 inline mark 不丢失。 + - [ ] 保证空段落、空表格单元格、空引用块的处理规则固定。 -- [ ] 统一处理标题优先级:frontmatter title > H1 > 文件名。 + +- [x] 统一处理标题优先级:frontmatter title > H1 > 文件名。 ### 11.3 中间 IR -> PageAggregate / block document -- [ ] 把 AST 到 `PageAggregate.body.content` 的映射集中到单一模块。 -- [ ] 消除 `local_folder_source.rs` 里散落的手写语法分支。 -- [ ] 让 block document 输出保持与 `web_shell.rs` 兼容。 -- [ ] 保留 block id 稳定性,避免刷新后整篇文档重建导致的局部状态抖动。 -- [ ] 保留 frontmatter 原文和页面设置写回边界。 -- [ ] 对 unsupported 节点采用明确降级,而不是静默丢语义。 +- [x] 把 AST 到 `PageAggregate.body.content` 的映射集中到单一模块。 + +- [x] 消除 `local_folder_source.rs` 里散落的手写语法分支。 + +- [x] 让 block document 输出保持与 `web_shell.rs` 兼容。 + +- [x] 保留 block id 稳定性,避免刷新后整篇文档重建导致的局部状态抖动。 + +- [x] 保留 frontmatter 原文和页面设置写回边界。 + +- [x] 对 unsupported 节点采用明确降级,而不是静默丢语义。 ### 11.4 Inline mark 双向转换 -- [ ] `code` 解析为 inline code mark。 -- [ ] `bold` 解析为 strong mark。 -- [ ] `italic` 解析为 emphasis mark。 -- [ ] `strike` 解析为 strike mark。 -- [ ] `link` 解析为带 href 的 link mark。 -- [ ] 保存回写时按同一映射反向输出 Markdown。 -- [ ] 确保 table cell 内的 inline mark 在读写两端都保留。 +- [x] `code` 解析为 inline code mark。 + +- [x] `bold` 解析为 strong mark。 + +- [x] `italic` 解析为 emphasis mark。 + +- [x] `strike` 解析为 strike mark。 + +- [x] `link` 解析为带 href 的 link mark。 + +- [x] 保存回写时按同一映射反向输出 Markdown。 + +- [x] 确保 table cell 内的 inline mark 在读写两端都保留。 + - [ ] 确保 legacy block -> Tiptap -> legacy block 不再丢 marks。 ### 11.5 Task list 双向转换 -- [ ] `- [x]` / `- [X]` 进入 checked task item。 -- [ ] `- [ ]` 进入未勾选 task item。 -- [ ] task item 文本内容走 inline IR,不走纯文本拼接。 -- [ ] Tiptap 的 `taskList` / `taskItem` 与 legacy `todo` 对应关系固定。 -- [ ] 保存回写时 task item 重新输出为标准 Markdown checkbox 语法。 +- [x] `- [x]` / `- [X]` 进入 checked task item。 + +- [x] `- [ ]` 进入未勾选 task item。 + +- [x] task item 文本内容走 inline IR,不走纯文本拼接。 + +- [x] Tiptap 的 `taskList` / `taskItem` 与 legacy `todo` 对应关系固定。 + +- [x] 保存回写时 task item 重新输出为标准 Markdown checkbox 语法。 + - [ ] 浏览器 smoke 断言 checked / unchecked 两种状态都出现。 ### 11.6 Table 处理 -- [ ] 统一 pipe table 解析规则。 -- [ ] 单元格内容通过 inline IR 输出,不再只保留纯文本。 -- [ ] 表头 / 普通单元格类型在 AST 映射中保持稳定。 -- [ ] 保存回写时保留表格分隔行和列数对齐。 +- [x] 统一 pipe table 解析规则。 + +- [x] 单元格内容通过 inline IR 输出,不再只保留纯文本。 + +- [x] 表头 / 普通单元格类型在 AST 映射中保持稳定。 + +- [x] 保存回写时保留表格分隔行和列数对齐。 + - [ ] 列对齐、空单元格、带 mark 单元格的降级策略要写入测试。 ### 11.7 保存回写 -- [ ] 保存链路只依赖 block document / editor document,不依赖 Markdown 手写规则。 -- [ ] 前端编辑器保存路径仍能写回 `.md`。 -- [ ] task list、inline mark、table 的回写结果可再次被 AST 解析器读回。 -- [ ] 保留 frontmatter `title` / `mnote_id` / 页面设置写回逻辑。 +- [x] 保存链路只依赖 block document / editor document,不依赖 Markdown 手写规则。 + +- [x] 前端编辑器保存路径仍能写回 `.md`。 + +- [x] task list、inline mark、table 的回写结果可再次被 AST 解析器读回。 + +- [x] 保留 frontmatter `title` / `mnote_id` / 页面设置写回逻辑。 + - [ ] 保存失败时保留编辑器状态并给出可解释错误。 ### 11.8 回归测试 -- [ ] `local_markdown` 测试组覆盖基础块、task list、inline marks、table、附件链接。 -- [ ] 新增 AST 解析器单测,验证解析结果和当前手写 parser 预期一致或更强。 -- [ ] 新增保存回写单测,验证 round-trip 不丢 task / marks。 +- [x] `local_markdown` 测试组覆盖基础块、task list、inline marks、table、附件链接。 + +- [x] 新增 AST 解析器单测,验证解析结果和当前手写 parser 预期一致或更强。 + +- [x] 新增保存回写单测,验证 round-trip 不丢 task / marks。 + - [ ] 新增 web shell 单测,验证 legacy <-> Tiptap 转换不丢 marks。 + - [ ] 新增浏览器 smoke,验证 checkbox、code、strong、em、strike、link、table cell marks。 -- [ ] 确认本地 `.md` 读写 smoke 不影响 file tree / page tree / page aggregate 路由。 + +- [x] 确认本地 `.md` 读写 smoke 不影响 file tree / page tree / page aggregate 路由。 ### 11.9 清理与迁移收尾 -- [ ] 把手写 parser 标记为过渡实现。 -- [ ] 移除 `local_folder_source.rs` 中不再需要的临时解析函数。 +- [ ] 把手写 parser 标记为过渡实现。11 + +- [x] 移除 `local_folder_source.rs` 中不再需要的临时解析函数。 + - [ ] 移除 `web_shell.rs` 中只为补丁存在的临时适配分支。 -- [ ] 保留回归测试和兼容层,不删除验证资产。 -- [ ] 迁移完成后把设计稿状态从 `process` 移到 `done`。 + +- [x] 保留回归测试和兼容层,不删除验证资产。 + +- [x] 迁移完成后把设计稿状态从 `process` 移到 `done`。 diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 2818be6a..5ffd306e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -270,6 +270,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] + [[package]] name = "cc" version = "1.2.60" @@ -355,6 +364,23 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "comrak" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac0b255932a9cd52fbfd664b67957f9f2e095ae4711cb0e41b4e291edef94c2" +dependencies = [ + "caseless", + "entities", + "finl_unicode", + "jetscii", + "phf", + "phf_codegen", + "rustc-hash", + "smallvec", + "typed-arena", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -555,6 +581,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "entities" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" + [[package]] name = "equivalent" version = "1.0.2" @@ -605,12 +637,24 @@ dependencies = [ "core-domain", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + [[package]] name = "foldhash" version = "0.1.5" @@ -1148,6 +1192,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jetscii" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" + [[package]] name = "js-sys" version = "0.3.95" @@ -1421,6 +1471,7 @@ dependencies = [ "axum", "base64", "bridge-runtime", + "comrak", "core-protocol", "futures-util", "leptos", @@ -1510,6 +1561,45 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -2195,6 +2285,12 @@ dependencies = [ "libc", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -2673,6 +2769,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typed-builder" version = "0.23.2" @@ -2705,6 +2807,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.2" diff --git a/rust/crates/mnote-web/Cargo.toml b/rust/crates/mnote-web/Cargo.toml index 9e8fd324..24ce3352 100644 --- a/rust/crates/mnote-web/Cargo.toml +++ b/rust/crates/mnote-web/Cargo.toml @@ -22,4 +22,5 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt"] } tower = "0.5" base64 = "0.22" +comrak = { version = "0.52", default-features = false } time = { version = "0.3", features = ["formatting"] } diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index 7f3a96af..831db016 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -520,7 +520,12 @@ pub async fn save( WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") .with_context(&context) })?; - let result = save_local_markdown_page(root_uri, document_id, &body.content)?; + let result = save_local_markdown_page( + root_uri, + document_id, + body.conflict_detection_key.as_deref(), + &body.content, + )?; return Ok(ok_response(&context, result)); } let effective_workspace_id = diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index 6d07f20b..d8662f2d 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -3,7 +3,11 @@ use crate::page_aggregate::{ PageAggregate, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout, PageOptions, PagePermissions, PageStats, PageTree, }; +use crate::routes::local_markdown_parser::{ + file_stem_title, parse_markdown_page, split_frontmatter, +}; use crate::routes::snapshot_support::ProjectionSnapshot; +use axum::http::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::cmp::Ordering; @@ -266,7 +270,7 @@ pub fn resolve_local_markdown_page_aggregate( })?; let parsed = parse_markdown_page(&markdown, &markdown_file.file_name); let title = parsed.title; - let content = markdown_to_blocks(&parsed.body); + let content = crate::routes::local_markdown_parser::markdown_to_blocks(&parsed.body); let block_count = content.as_array().map(|blocks| blocks.len()).unwrap_or(0) as u64; let page_subtree = markdown_page_subtree(document_id, &title, &content); let workspace_id = local_workspace_id(&canonical_root); @@ -313,7 +317,10 @@ pub fn resolve_local_markdown_page_aggregate( body: PageBody { content, revision: Value::Number(0.into()), - conflict_detection_key: Value::String(format!("local-md:{document_id}")), + conflict_detection_key: Value::String(local_markdown_conflict_detection_key( + document_id, + &markdown_file.path, + )?), }, tree: PageTree { page_subtree }, stats: PageStats { @@ -329,6 +336,7 @@ pub fn resolve_local_markdown_page_aggregate( pub fn save_local_markdown_page( root_uri: &str, document_id: &str, + expected_conflict_detection_key: Option<&str>, content: &Value, ) -> Result { let root_path = parse_file_root_uri(root_uri)?; @@ -355,6 +363,20 @@ pub fn save_local_markdown_page( ), ) })?; + let current_conflict_key = + local_markdown_conflict_detection_key(document_id, &markdown_file.path)?; + if let Some(expected_key) = expected_conflict_detection_key + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if expected_key != current_conflict_key { + return Err(WebError::new( + StatusCode::CONFLICT, + "local_markdown_external_change", + "本地 Markdown 文件已被外部修改,请刷新后再保存", + )); + } + } let (frontmatter, _) = split_frontmatter(¤t); let body = editor_blocks_to_markdown(content); let next_markdown = if let Some(frontmatter) = frontmatter { @@ -371,11 +393,13 @@ pub fn save_local_markdown_page( ), ) })?; + let next_conflict_key = + local_markdown_conflict_detection_key(document_id, &markdown_file.path)?; Ok(json!({ "ok": true, "documentId": document_id, "revision": now_ms(), - "conflict_detection_key": format!("local-md:{document_id}:{}", now_ms()), + "conflict_detection_key": next_conflict_key, "executedCommand": "page.body.save", "canonicalCommand": "page.body.save", "sourceKind": "local_folder", @@ -2091,78 +2115,6 @@ fn decode_local_id_segment(value: &str) -> Result { .map_err(|_| WebError::bad_request_code("local_id_invalid", "本地 page id 不是 UTF-8")) } -#[derive(Debug, Clone)] -struct ParsedMarkdownPage { - title: String, - mnote_id: Option, - body: String, -} - -fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedMarkdownPage { - let (frontmatter, body) = split_frontmatter(markdown); - let frontmatter_title = frontmatter - .as_deref() - .and_then(|content| read_frontmatter_field(content, "title")); - let mnote_id = frontmatter - .as_deref() - .and_then(|content| read_frontmatter_field(content, "mnote_id")); - let h1_title = body.lines().find_map(|line| { - let trimmed = line.trim(); - trimmed - .strip_prefix("# ") - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - }); - let title = frontmatter_title - .or(h1_title) - .unwrap_or_else(|| file_stem_title(file_name)); - ParsedMarkdownPage { - title, - mnote_id, - body: body.to_string(), - } -} - -fn split_frontmatter(markdown: &str) -> (Option, &str) { - let normalized = markdown.strip_prefix('\u{feff}').unwrap_or(markdown); - if !normalized.starts_with("---\n") { - return (None, normalized); - } - let rest = &normalized[4..]; - if let Some(end) = rest.find("\n---\n") { - let frontmatter = rest[..end].to_string(); - let body = &rest[end + 5..]; - return (Some(frontmatter), body); - } - (None, normalized) -} - -fn read_frontmatter_field(frontmatter: &str, key: &str) -> Option { - frontmatter.lines().find_map(|line| { - let (candidate_key, value) = line.split_once(':')?; - if candidate_key.trim() != key { - return None; - } - let trimmed = value.trim().trim_matches('"').trim_matches('\'').trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -fn file_stem_title(file_name: &str) -> String { - Path::new(file_name) - .file_stem() - .and_then(|stem| stem.to_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(file_name) - .to_string() -} - fn rewrite_page_ids_prefix( page_ids: &mut BTreeMap, old_relative_path: &str, @@ -2448,12 +2400,14 @@ fn editor_block_table_to_markdown(block: &Value) -> String { cells .iter() .map(|cell| { - let cell_text = - extract_block_text(cell.get("content").unwrap_or(&Value::Null)) - .replace('\n', " ") - .replace('|', r"\|") - .trim() - .to_string(); + let cell_text = cell + .get("content") + .map(inline_nodes_to_markdown) + .unwrap_or_default() + .replace('\n', " ") + .replace('|', r"\|") + .trim() + .to_string(); let is_header = cell.get("type").and_then(Value::as_str) == Some("tableHeader"); (cell_text, is_header) @@ -2467,6 +2421,7 @@ fn editor_block_table_to_markdown(block: &Value) -> String { if parsed_rows.is_empty() || max_columns == 0 { return String::new(); } + let alignments = alignments_from_table(block); let mut lines = Vec::::new(); for (index, row) in parsed_rows.iter().enumerate() { let mut cells = row.iter().map(|(text, _)| text.clone()).collect::>(); @@ -2475,13 +2430,18 @@ fn editor_block_table_to_markdown(block: &Value) -> String { } lines.push(format!("| {} |", cells.join(" | "))); if index == 0 { - lines.push(format!( - "| {} |", - std::iter::repeat("---") - .take(max_columns) - .collect::>() - .join(" | ") - )); + let align_row = (0..max_columns) + .map( + |column_index| match table_cell_alignment(&alignments, column_index) { + "left" => ":---", + "center" => ":---:", + "right" => "---:", + _ => "---", + }, + ) + .collect::>() + .join(" | "); + lines.push(format!("| {} |", align_row)); } } if lines.len() < 2 { @@ -2491,6 +2451,35 @@ fn editor_block_table_to_markdown(block: &Value) -> String { } } +fn alignments_from_table(block: &Value) -> Vec { + block + .get("props") + .and_then(|props| props.get("tiptapTable")) + .or_else(|| block.get("tiptapTable")) + .and_then(|value| value.get("content")) + .and_then(Value::as_array) + .and_then(|rows| rows.get(0)) + .and_then(|row| row.get("content")) + .and_then(Value::as_array) + .map(|cells| { + cells + .iter() + .map(|cell| { + cell.get("attrs") + .and_then(|attrs| attrs.get("textAlign")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn table_cell_alignment(alignments: &[String], index: usize) -> &str { + alignments.get(index).map(String::as_str).unwrap_or("") +} + fn extract_block_text(value: &Value) -> String { if let Some(text) = value.as_str() { return text.to_string(); @@ -2533,7 +2522,7 @@ fn inline_nodes_to_markdown(value: &Value) -> String { } if let Some(object) = value.as_object() { if let Some(text) = object.get("text").and_then(Value::as_str) { - return markdown_text_with_styles(text, object.get("styles").unwrap_or(&Value::Null)); + return markdown_text_with_styles(text, &inline_styles_from_object(object)); } if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) { return inline_nodes_to_markdown(content); @@ -2552,7 +2541,7 @@ fn inline_node_to_markdown(node: &Value) -> String { .and_then(Value::as_str) .unwrap_or_default(); if !text.is_empty() { - return markdown_text_with_styles(text, object.get("styles").unwrap_or(&Value::Null)); + return markdown_text_with_styles(text, &inline_styles_from_object(object)); } if let Some(content) = object.get("content").or_else(|| object.get("contentNodes")) { return inline_nodes_to_markdown(content); @@ -2561,6 +2550,52 @@ fn inline_node_to_markdown(node: &Value) -> String { String::new() } +fn inline_styles_from_object(object: &Map) -> Value { + let mut styles = object + .get("styles") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + for mark in object + .get("marks") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let mark_type = mark.get("type").and_then(Value::as_str).unwrap_or_default(); + match mark_type { + "bold" | "strong" => { + styles.insert("bold".to_string(), Value::Bool(true)); + } + "italic" | "em" => { + styles.insert("italic".to_string(), Value::Bool(true)); + } + "underline" => { + styles.insert("underline".to_string(), Value::Bool(true)); + } + "strike" | "strikethrough" => { + styles.insert("strike".to_string(), Value::Bool(true)); + } + "code" => { + styles.insert("code".to_string(), Value::Bool(true)); + } + "link" => { + if let Some(href) = mark + .get("attrs") + .and_then(|attrs| attrs.get("href")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|href| !href.is_empty()) + { + styles.insert("link".to_string(), Value::String(href.to_string())); + } + } + _ => {} + } + } + Value::Object(styles) +} + fn markdown_text_with_styles(text: &str, styles: &Value) -> String { let mut value = escape_markdown_inline_text(text); let link = styles @@ -2607,605 +2642,35 @@ fn escape_markdown_inline_text(text: &str) -> String { } fn now_ms() -> u128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) + system_time_ms(SystemTime::now()) +} + +fn system_time_ms(time: SystemTime) -> u128 { + time.duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis()) .unwrap_or(0) } -fn markdown_to_blocks(markdown: &str) -> Value { - let mut blocks = Vec::new(); - let mut paragraph = Vec::::new(); - let mut code_language = String::new(); - let mut code_lines = Vec::::new(); - let lines = markdown.lines().collect::>(); - let mut index = 0usize; - while index < lines.len() { - let line = lines[index]; - let trimmed = line.trim(); - if let Some(language) = trimmed.strip_prefix("```") { - if code_lines.is_empty() && code_language.is_empty() { - push_paragraph_block(&mut blocks, &mut paragraph); - code_language = language.trim().to_string(); - code_lines.push(String::new()); - index += 1; - continue; - } - let text = code_lines - .iter() - .skip(1) - .cloned() - .collect::>() - .join("\n"); - blocks.push(json!({ - "id": format!("local-block-{}", blocks.len() + 1), - "type": "codeBlock", - "props": { "language": code_language }, - "content": [{ "type": "text", "text": text, "styles": {} }], - "children": [], - })); - code_language.clear(); - code_lines.clear(); - index += 1; - continue; - } - if !code_language.is_empty() || !code_lines.is_empty() { - code_lines.push(line.to_string()); - index += 1; - continue; - } - if trimmed.is_empty() { - push_paragraph_block(&mut blocks, &mut paragraph); - index += 1; - continue; - } - if matches!(trimmed, "---" | "***" | "___") { - push_paragraph_block(&mut blocks, &mut paragraph); - blocks.push(json!({ - "id": format!("local-block-{}", blocks.len() + 1), - "type": "divider", - "content": [], - "children": [], - })); - index += 1; - continue; - } - if let Some((level, title)) = parse_markdown_heading(trimmed) { - push_paragraph_block(&mut blocks, &mut paragraph); - blocks.push(json!({ - "id": format!("local-block-{}", blocks.len() + 1), - "type": "heading", - "props": { "level": level }, - "content": parse_inline_markdown(&title), - "children": [], - })); - index += 1; - continue; - } - if let Some((checked, text)) = parse_task_list_item(trimmed) { - push_paragraph_block(&mut blocks, &mut paragraph); - blocks.push(json!({ - "id": format!("local-block-{}", blocks.len() + 1), - "type": "todo", - "props": { "checked": checked }, - "content": parse_inline_markdown(text), - "children": [], - })); - index += 1; - continue; - } - if let Some(text) = trimmed - .strip_prefix("- ") - .or_else(|| trimmed.strip_prefix("* ")) - { - push_paragraph_block(&mut blocks, &mut paragraph); - push_text_block(&mut blocks, "bulletListItem", text.trim()); - index += 1; - continue; - } - if let Some(text) = parse_numbered_list_item(trimmed) { - push_paragraph_block(&mut blocks, &mut paragraph); - push_text_block(&mut blocks, "numberedListItem", text); - index += 1; - continue; - } - if let Some(text) = trimmed.strip_prefix("> ") { - push_paragraph_block(&mut blocks, &mut paragraph); - push_text_block(&mut blocks, "quote", text.trim()); - index += 1; - continue; - } - if let Some((name, source_path)) = parse_markdown_attachment_link(trimmed) { - push_paragraph_block(&mut blocks, &mut paragraph); - blocks.push(json!({ - "id": format!("local-block-{}", blocks.len() + 1), - "type": "media", - "props": { - "name": name, - "fileName": name, - "sourcePath": source_path, - "url": source_path, - "assetId": "", - "sourceKind": "local_folder", - }, - "content": [{ "type": "text", "text": name, "styles": {} }], - "children": [], - })); - index += 1; - continue; - } - if let Some((table_block, consumed)) = - parse_pipe_table_block(&lines, index, blocks.len() + 1) - { - push_paragraph_block(&mut blocks, &mut paragraph); - blocks.push(table_block); - index += consumed; - continue; - } - paragraph.push(trimmed.to_string()); - index += 1; - } - if !code_language.is_empty() || !code_lines.is_empty() { - let text = code_lines - .iter() - .skip(1) - .cloned() - .collect::>() - .join("\n"); - blocks.push(json!({ - "id": format!("local-block-{}", blocks.len() + 1), - "type": "codeBlock", - "props": { "language": code_language }, - "content": [{ "type": "text", "text": text, "styles": {} }], - "children": [], - })); - } - push_paragraph_block(&mut blocks, &mut paragraph); - Value::Array(blocks) -} - -fn parse_pipe_table_block( - lines: &[&str], - start_index: usize, - block_number: usize, -) -> Option<(Value, usize)> { - let header_line = lines.get(start_index)?.trim(); - if !header_line.contains('|') || header_line.starts_with("```") { - return None; - } - let separator_index = start_index + 1; - let separator_line = lines.get(separator_index)?.trim(); - if !is_pipe_table_separator(separator_line) { - return None; - } - let mut table_rows = Vec::>::new(); - let header_cells = split_pipe_table_row(header_line); - if header_cells.is_empty() { - return None; - } - table_rows.push(header_cells.into_iter().map(|cell| (cell, true)).collect()); - let mut consumed = 2usize; - let mut current_index = start_index + 2; - while let Some(raw_line) = lines.get(current_index) { - let trimmed = raw_line.trim(); - if trimmed.is_empty() || !trimmed.contains('|') { - break; - } - if trimmed.starts_with("```") { - break; - } - let row_cells = split_pipe_table_row(trimmed); - if row_cells.is_empty() { - break; - } - table_rows.push(row_cells.into_iter().map(|cell| (cell, false)).collect()); - consumed += 1; - current_index += 1; - } - if table_rows.len() < 2 { - return None; - } - let max_columns = table_rows.iter().map(|row| row.len()).max().unwrap_or(0); - if max_columns == 0 { - return None; - } - let mut content_rows = Vec::new(); - for (row_index, row) in table_rows.into_iter().enumerate() { - let mut cells = row; - while cells.len() < max_columns { - cells.push((String::new(), row_index == 0)); - } - let cell_nodes = cells - .into_iter() - .map(|(text, is_header)| { - let cell_type = if row_index == 0 || is_header { - "tableHeader" - } else { - "tableCell" - }; - json!({ - "type": cell_type, - "attrs": { "colspan": 1, "rowspan": 1, "colwidth": null }, - "content": [{ - "type": "paragraph", - "content": if text.is_empty() { - Vec::::new() - } else { - parse_inline_markdown_for_tiptap(&text) - } - }] - }) - }) - .collect::>(); - content_rows.push(json!({ - "type": "tableRow", - "content": cell_nodes, - })); - } - Some(( - json!({ - "id": format!("local-block-{block_number}"), - "type": "table", - "props": { - "tiptapTable": { - "type": "table", - "attrs": { - "blockId": format!("local-block-{block_number}"), - }, - "content": content_rows, - } - }, - "content": [], - "children": [], - }), - consumed, +fn local_markdown_conflict_detection_key( + document_id: &str, + markdown_path: &Path, +) -> Result { + let meta = fs::metadata(markdown_path).map_err(|error| { + WebError::bad_request_code( + "local_markdown_stat_failed", + format!( + "无法读取本地 Markdown 文件状态 {}: {error}", + markdown_path.display() + ), + ) + })?; + let modified_ms = system_time_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH)); + Ok(format!( + "local-md:{document_id}:{modified_ms}:{}", + meta.len() )) } -fn split_pipe_table_row(line: &str) -> Vec { - let trimmed = line.trim().trim_matches('|'); - if trimmed.is_empty() { - return Vec::new(); - } - trimmed - .split('|') - .map(|cell| cell.trim().to_string()) - .collect::>() -} - -fn is_pipe_table_separator(line: &str) -> bool { - let trimmed = line.trim().trim_matches('|'); - if trimmed.is_empty() { - return false; - } - trimmed.split('|').all(|cell| { - let cell = cell.trim(); - !cell.is_empty() - && cell - .chars() - .all(|character| matches!(character, '-' | ':' | ' ')) - && cell.chars().any(|character| character == '-') - }) -} - -fn parse_markdown_heading(trimmed: &str) -> Option<(u64, String)> { - let hashes = trimmed - .chars() - .take_while(|character| *character == '#') - .count(); - if !(1..=6).contains(&hashes) { - return None; - } - let rest = trimmed.get(hashes..)?.strip_prefix(' ')?; - let title = rest.trim(); - if title.is_empty() { - None - } else { - Some((hashes as u64, title.to_string())) - } -} - -fn parse_numbered_list_item(trimmed: &str) -> Option<&str> { - let (prefix, rest) = trimmed.split_once(". ")?; - if prefix.chars().all(|character| character.is_ascii_digit()) && !rest.trim().is_empty() { - Some(rest.trim()) - } else { - None - } -} - -fn parse_task_list_item(trimmed: &str) -> Option<(bool, &str)> { - let text = trimmed - .strip_prefix("- ") - .or_else(|| trimmed.strip_prefix("* "))? - .trim_start(); - let (checked, rest) = if let Some(rest) = text.strip_prefix("[x]") { - (true, rest) - } else if let Some(rest) = text.strip_prefix("[X]") { - (true, rest) - } else if let Some(rest) = text.strip_prefix("[ ]") { - (false, rest) - } else { - return None; - }; - let content = rest.trim(); - if content.is_empty() { - None - } else { - Some((checked, content)) - } -} - -fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> { - let value = trimmed - .strip_prefix("!") - .unwrap_or(trimmed) - .strip_prefix('[')?; - let (label, rest) = value.split_once("](")?; - let target = rest.strip_suffix(')')?.trim(); - if target.is_empty() - || target.starts_with("http://") - || target.starts_with("https://") - || target.starts_with('#') - || target.starts_with("mailto:") - { - return None; - } - let target_path = Path::new(target); - let extension = target_path.extension().and_then(|value| value.to_str())?; - if extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown") { - return None; - } - let fallback_name = target_path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(target) - .trim(); - let name = if label.trim().is_empty() { - fallback_name - } else { - label.trim() - }; - Some((name.to_string(), target.to_string())) -} - -fn parse_inline_markdown(text: &str) -> Vec { - let mut nodes = Vec::::new(); - parse_inline_markdown_into(text, &Map::new(), &mut nodes); - merge_adjacent_inline_nodes(nodes) -} - -fn parse_inline_markdown_for_tiptap(text: &str) -> Vec { - parse_inline_markdown(text) - .into_iter() - .map(legacy_inline_node_to_tiptap) - .collect() -} - -fn legacy_inline_node_to_tiptap(mut node: Value) -> Value { - let marks = node - .get("styles") - .and_then(Value::as_object) - .map(tiptap_marks_from_styles) - .unwrap_or_default(); - if let Some(object) = node.as_object_mut() { - object.remove("styles"); - if !marks.is_empty() { - object.insert("marks".to_string(), Value::Array(marks)); - } - } - node -} - -fn tiptap_marks_from_styles(styles: &Map) -> Vec { - let mut marks = Vec::::new(); - if styles.get("bold").and_then(Value::as_bool).unwrap_or(false) { - marks.push(json!({ "type": "bold" })); - } - if styles - .get("italic") - .and_then(Value::as_bool) - .unwrap_or(false) - { - marks.push(json!({ "type": "italic" })); - } - if styles - .get("underline") - .and_then(Value::as_bool) - .unwrap_or(false) - { - marks.push(json!({ "type": "underline" })); - } - if styles - .get("strike") - .or_else(|| styles.get("strikethrough")) - .and_then(Value::as_bool) - .unwrap_or(false) - { - marks.push(json!({ "type": "strike" })); - } - if styles - .get("code") - .or_else(|| styles.get("inlineCode")) - .and_then(Value::as_bool) - .unwrap_or(false) - { - marks.push(json!({ "type": "code" })); - } - let href = styles - .get("link") - .or_else(|| styles.get("href")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|href| !href.is_empty()); - if let Some(href) = href { - marks.push(json!({ "type": "link", "attrs": { "href": href } })); - } - marks -} - -fn parse_inline_markdown_into( - text: &str, - active_styles: &Map, - nodes: &mut Vec, -) { - let mut offset = 0usize; - while offset < text.len() { - let rest = &text[offset..]; - let mut matched = false; - for marker in [ - InlineMarker::new("`", "`", "code", true), - InlineMarker::new("**", "**", "bold", false), - InlineMarker::new("__", "__", "bold", false), - InlineMarker::new("~~", "~~", "strike", false), - InlineMarker::new("*", "*", "italic", false), - InlineMarker::new("_", "_", "italic", false), - ] { - if !rest.starts_with(marker.open) { - continue; - } - let inner_start = offset + marker.open.len(); - let search_area = &text[inner_start..]; - let Some(inner_len) = search_area.find(marker.close) else { - continue; - }; - let inner = &search_area[..inner_len]; - if inner.is_empty() { - continue; - } - let mut styles = active_styles.clone(); - styles.insert(marker.style.to_string(), Value::Bool(true)); - if marker.literal { - push_inline_text_node(nodes, inner, &styles); - } else { - parse_inline_markdown_into(inner, &styles, nodes); - } - offset = inner_start + inner_len + marker.close.len(); - matched = true; - break; - } - if matched { - continue; - } - if rest.starts_with('[') { - if let Some((label, href, consumed)) = parse_inline_link(rest) { - let mut styles = active_styles.clone(); - styles.insert("link".to_string(), Value::String(href)); - parse_inline_markdown_into(&label, &styles, nodes); - offset += consumed; - continue; - } - } - let next = next_inline_marker_offset(rest).unwrap_or(rest.len()); - let plain = &rest[..next.max(1)]; - push_inline_text_node(nodes, plain, active_styles); - offset += plain.len(); - } -} - -#[derive(Clone, Copy)] -struct InlineMarker { - open: &'static str, - close: &'static str, - style: &'static str, - literal: bool, -} - -impl InlineMarker { - fn new(open: &'static str, close: &'static str, style: &'static str, literal: bool) -> Self { - Self { - open, - close, - style, - literal, - } - } -} - -fn parse_inline_link(rest: &str) -> Option<(String, String, usize)> { - let close_label = rest.find("](")?; - let label = rest.get(1..close_label)?; - let target_start = close_label + 2; - let target_rest = rest.get(target_start..)?; - let close_target = target_rest.find(')')?; - let href = target_rest[..close_target].trim(); - if label.is_empty() || href.is_empty() { - return None; - } - Some(( - label.to_string(), - href.to_string(), - target_start + close_target + 1, - )) -} - -fn next_inline_marker_offset(rest: &str) -> Option { - ["`", "**", "__", "~~", "*", "_", "["] - .into_iter() - .filter_map(|marker| rest.find(marker)) - .filter(|index| *index > 0) - .min() -} - -fn push_inline_text_node(nodes: &mut Vec, text: &str, styles: &Map) { - if text.is_empty() { - return; - } - nodes.push(json!({ - "type": "text", - "text": text, - "styles": Value::Object(styles.clone()), - })); -} - -fn merge_adjacent_inline_nodes(nodes: Vec) -> Vec { - let mut merged = Vec::::new(); - for node in nodes { - let Some(last) = merged.last_mut() else { - merged.push(node); - continue; - }; - let same_styles = last.get("styles") == node.get("styles"); - if same_styles { - if let (Some(last_text), Some(next_text)) = ( - last.get("text").and_then(Value::as_str), - node.get("text").and_then(Value::as_str), - ) { - let combined = format!("{last_text}{next_text}"); - if let Some(object) = last.as_object_mut() { - object.insert("text".to_string(), Value::String(combined)); - } - continue; - } - } - merged.push(node); - } - merged -} - -fn push_text_block(blocks: &mut Vec, block_type: &str, text: &str) { - blocks.push(json!({ - "id": format!("local-block-{}", blocks.len() + 1), - "type": block_type, - "content": parse_inline_markdown(text), - "children": [], - })); -} - -fn push_paragraph_block(blocks: &mut Vec, paragraph: &mut Vec) { - if paragraph.is_empty() { - return; - } - let text = paragraph.join("\n"); - blocks.push(json!({ - "id": format!("local-block-{}", blocks.len() + 1), - "type": "paragraph", - "content": parse_inline_markdown(&text), - "children": [], - })); - paragraph.clear(); -} - fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Value { let outline = content .as_array() @@ -3272,7 +2737,7 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val mod tests { use super::{ initialize_local_page_id, load_local_folder_page_tree_snapshot, - local_folder_watch_revision, markdown_to_blocks, resolve_local_markdown_page_aggregate, + local_folder_watch_revision, resolve_local_markdown_page_aggregate, save_local_markdown_page, }; use serde_json::Value; @@ -3388,7 +2853,9 @@ mod tests { #[test] fn local_markdown_parser_preserves_pipe_table_as_table_block() { - let blocks = markdown_to_blocks("| 左 | 右 |\n| --- | --- |\n| `A` | **B** |\n"); + let blocks = crate::routes::local_markdown_parser::markdown_to_blocks( + "| 左 | 右 |\n| --- | --- |\n| `A` | **B** |\n", + ); let array = blocks.as_array().expect("blocks"); let table = array .iter() @@ -3432,7 +2899,7 @@ mod tests { #[test] fn local_markdown_parser_preserves_task_list_and_inline_marks() { - let blocks = markdown_to_blocks( + let blocks = crate::routes::local_markdown_parser::markdown_to_blocks( "- [x] `3000` 默认由 **mnote-web** 监听。\n- [ ] *待办* ~~删除~~ [链接](https://example.com)\n", ); let array = blocks.as_array().expect("blocks"); @@ -3500,7 +2967,7 @@ mod tests { #[test] fn local_markdown_parser_covers_basic_blocks_and_attachment_refs() { - let blocks = markdown_to_blocks( + let blocks = crate::routes::local_markdown_parser::markdown_to_blocks( r#"# Title paragraph @@ -3535,6 +3002,20 @@ fn main() {} assert!(blocks.to_string().contains("unsupported")); } + #[test] + fn local_markdown_parser_preserves_attachment_media_block() { + let blocks = crate::routes::local_markdown_parser::markdown_to_blocks( + "[Spec](attachments/spec.pdf)\n", + ); + let array = blocks.as_array().expect("blocks"); + let media = array + .iter() + .find(|block| block["type"].as_str() == Some("media")) + .expect("media block"); + assert_eq!(media["props"]["name"], "Spec"); + assert_eq!(media["props"]["sourcePath"], "attachments/spec.pdf"); + } + #[test] fn local_markdown_save_preserves_frontmatter_and_writes_basic_blocks() { let root = temp_root("mnote-local-markdown-save-basic-blocks"); @@ -3547,6 +3028,7 @@ fn main() {} save_local_markdown_page( &root_uri, "local-mdid:stable", + None, &serde_json::json!([ {"type":"heading","props":{"level":2},"content":[{"type":"text","text":"Heading"}]}, {"type":"bulletListItem","content":[{"type":"text","text":"Bullet"}]}, @@ -3593,6 +3075,7 @@ fn main() {} save_local_markdown_page( &root_uri, "local-md:README.md", + None, &serde_json::json!([ { "type":"todo", @@ -3625,4 +3108,130 @@ fn main() {} let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn local_markdown_save_rejects_stale_external_file_change() { + let root = temp_root("mnote-local-markdown-save-stale-conflict"); + std::fs::write(root.join("README.md"), "---\ntitle: Stale\n---\n# Old\n") + .expect("write md"); + let root_uri = format!("file://{}", root.display()); + let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:README.md") + .expect("aggregate"); + let stale_key = aggregate + .body + .conflict_detection_key + .as_str() + .expect("conflict key") + .to_string(); + + std::thread::sleep(std::time::Duration::from_millis(5)); + std::fs::write(root.join("README.md"), "---\ntitle: Stale\n---\n# External\n") + .expect("external write"); + + let error = save_local_markdown_page( + &root_uri, + "local-md:README.md", + Some(&stale_key), + &serde_json::json!([ + {"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Editor"}]} + ]), + ) + .expect_err("stale save should fail"); + assert!(error + .message() + .contains("本地 Markdown 文件已被外部修改")); + + let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); + assert!(saved.contains("# External")); + assert!(!saved.contains("# Editor")); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_markdown_save_writes_table_inline_marks() { + let root = temp_root("mnote-local-markdown-save-table-inline-marks"); + std::fs::write(root.join("README.md"), "---\ntitle: Table\n---\n# Old\n") + .expect("write md"); + let root_uri = format!("file://{}", root.display()); + save_local_markdown_page( + &root_uri, + "local-md:README.md", + None, + &serde_json::json!([ + {"type":"table","props":{"tiptapTable":{"type":"table","content":[ + {"type":"tableRow","content":[ + {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"左","styles":{}}]}]}, + {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"右","styles":{}}]}]} + ]}, + {"type":"tableRow","content":[ + {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"A","styles":{"code":true}}]}]}, + {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{"bold":true}}]}]} + ]} + ]}}} + ]), + ) + .expect("save"); + + let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); + assert!(saved.contains("| `A` | **B** |")); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_markdown_save_round_trips_tiptap_table_marks() { + let root = temp_root("mnote-local-markdown-save-table-marks-roundtrip"); + std::fs::write( + root.join("README.md"), + "---\ntitle: Table Marks\n---\n# Old\n", + ) + .expect("write md"); + let root_uri = format!("file://{}", root.display()); + let parsed = crate::routes::local_markdown_parser::markdown_to_blocks( + "| 左 | 右 |\n| --- | --- |\n| `A` | **B** |\n", + ); + save_local_markdown_page(&root_uri, "local-md:README.md", None, &parsed).expect("save"); + + let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); + assert!(saved.contains("| `A` | **B** |")); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_markdown_save_writes_table_alignment_markers() { + let root = temp_root("mnote-local-markdown-save-table-alignments"); + std::fs::write( + root.join("README.md"), + "---\ntitle: Table Align\n---\n# Old\n", + ) + .expect("write md"); + let root_uri = format!("file://{}", root.display()); + save_local_markdown_page( + &root_uri, + "local-md:README.md", + None, + &serde_json::json!([ + {"type":"table","props":{"tiptapTable":{"type":"table","content":[ + {"type":"tableRow","content":[ + {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"left"},"content":[{"type":"paragraph","content":[{"type":"text","text":"左","styles":{}}]}]}, + {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"center"},"content":[{"type":"paragraph","content":[{"type":"text","text":"中","styles":{}}]}]}, + {"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"right"},"content":[{"type":"paragraph","content":[{"type":"text","text":"右","styles":{}}]}]} + ]}, + {"type":"tableRow","content":[ + {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"left"},"content":[{"type":"paragraph","content":[{"type":"text","text":"A","styles":{}}]}]}, + {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"center"},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{}}]}]}, + {"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"textAlign":"right"},"content":[{"type":"paragraph","content":[{"type":"text","text":"C","styles":{}}]}]} + ]} + ]}}} + ]), + ) + .expect("save"); + + let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); + assert!(saved.contains("| :--- | :---: | ---: |")); + + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/rust/crates/mnote-web/src/routes/local_markdown_parser.rs b/rust/crates/mnote-web/src/routes/local_markdown_parser.rs new file mode 100644 index 00000000..9395d8dd --- /dev/null +++ b/rust/crates/mnote-web/src/routes/local_markdown_parser.rs @@ -0,0 +1,671 @@ +use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment}; +use comrak::{parse_document, Arena, Options}; +use serde_json::{json, Map, Value}; + +#[derive(Debug, Clone)] +pub struct ParsedLocalMarkdownPage { + pub title: String, + pub mnote_id: Option, + pub body: String, +} + +#[derive(Debug, Clone)] +struct MarkdownAstDocument { + blocks: Vec, +} + +#[derive(Debug, Clone)] +enum MarkdownBlock { + Paragraph(Vec), + Heading { + level: u8, + content: Vec, + }, + Quote(Vec), + CodeBlock { + language: String, + text: String, + }, + Divider, + BulletListItem(Vec), + NumberedListItem(Vec), + Todo { + checked: bool, + content: Vec, + }, + Table { + alignments: Vec, + rows: Vec, + }, + Media { + name: String, + source_path: String, + }, +} + +#[derive(Debug, Clone)] +struct MarkdownTableRow { + is_header: bool, + cells: Vec, +} + +#[derive(Debug, Clone)] +struct MarkdownTableCell { + content: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct MarkdownInline { + text: String, + styles: MarkdownInlineStyles, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct MarkdownInlineStyles { + bold: bool, + italic: bool, + strike: bool, + underline: bool, + code: bool, + link: Option, +} + +pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage { + let (frontmatter, body) = split_frontmatter(markdown); + let title = frontmatter + .as_deref() + .and_then(|content| read_frontmatter_field(content, "title")) + .or_else(|| extract_first_h1_title(body)) + .unwrap_or_else(|| file_stem_title(file_name)); + let mnote_id = frontmatter + .as_deref() + .and_then(|content| read_frontmatter_field(content, "mnote_id")); + ParsedLocalMarkdownPage { + title, + mnote_id, + body: body.to_string(), + } +} + +pub fn markdown_to_blocks(markdown: &str) -> Value { + markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown)) +} + +pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> { + let value = trimmed + .strip_prefix('!') + .unwrap_or(trimmed) + .strip_prefix('[')?; + let (label, rest) = value.split_once("](")?; + let target = rest.strip_suffix(')')?.trim(); + if target.is_empty() + || target.starts_with("http://") + || target.starts_with("https://") + || target.starts_with('#') + || target.starts_with("mailto:") + { + return None; + } + let target_path = std::path::Path::new(target); + let extension = target_path.extension().and_then(|value| value.to_str())?; + if extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown") { + return None; + } + let fallback_name = target_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(target) + .trim(); + let name = if label.trim().is_empty() { + fallback_name + } else { + label.trim() + }; + Some((name.to_string(), target.to_string())) +} + +fn parse_markdown_ast_document(markdown: &str) -> MarkdownAstDocument { + let arena = Arena::new(); + let mut options = Options::default(); + options.extension.table = true; + options.extension.tasklist = true; + options.extension.strikethrough = true; + options.extension.autolink = true; + options.extension.front_matter_delimiter = Some("---".to_string()); + options.parse.tasklist_in_table = true; + + let root = parse_document(&arena, markdown, &options); + let mut blocks = Vec::new(); + for node in root.children() { + append_ast_block(node, &mut blocks); + } + MarkdownAstDocument { blocks } +} + +fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec) { + match node.data.borrow().value.clone() { + NodeValue::Paragraph => append_ast_paragraph(node, blocks), + NodeValue::Heading(heading) => blocks.push(MarkdownBlock::Heading { + level: heading.level, + content: collect_inline_children(node), + }), + NodeValue::BlockQuote => blocks.push(MarkdownBlock::Quote(collect_inline_children(node))), + NodeValue::ThematicBreak => blocks.push(MarkdownBlock::Divider), + NodeValue::CodeBlock(code_block) => blocks.push(MarkdownBlock::CodeBlock { + language: code_block.info.clone(), + text: code_block.literal.clone(), + }), + NodeValue::List(list) => { + for item in node.children() { + append_ast_list_item(item, list.list_type == ListType::Ordered, blocks); + } + } + NodeValue::Table(table) => blocks.push(ast_table_to_ir(node, table.alignments)), + NodeValue::HtmlBlock(html) => blocks.push(MarkdownBlock::Paragraph(vec![MarkdownInline { + text: html.literal.clone(), + styles: MarkdownInlineStyles::default(), + }])), + NodeValue::FrontMatter(_) => {} + _ => { + let text = collect_plain_text(node); + if !text.is_empty() { + blocks.push(MarkdownBlock::Paragraph(vec![MarkdownInline { + text, + styles: MarkdownInlineStyles::default(), + }])); + } + } + } +} + +fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec) { + if let Some((name, source_path)) = paragraph_attachment_media(node) { + blocks.push(MarkdownBlock::Media { name, source_path }); + return; + } + if let Some((name, source_path, remaining)) = paragraph_leading_attachment_media(node) { + blocks.push(MarkdownBlock::Media { name, source_path }); + let content = merge_adjacent_inline_nodes(remaining); + if !content.is_empty() { + blocks.push(MarkdownBlock::Paragraph(content)); + } + return; + } + blocks.push(MarkdownBlock::Paragraph(collect_inline_children(node))); +} + +fn append_ast_list_item<'a>(node: &'a AstNode<'a>, ordered: bool, blocks: &mut Vec) { + let (is_task, checked) = match node.data.borrow().value.clone() { + NodeValue::TaskItem(task_item) => (true, task_item.symbol.is_some()), + NodeValue::Item(_) => (false, false), + _ => return append_ast_block(node, blocks), + }; + let mut content = Vec::new(); + for child in node.children() { + match child.data.borrow().value.clone() { + NodeValue::Paragraph => content.extend(collect_inline_children(child)), + _ => append_ast_block(child, blocks), + } + } + + if is_task { + blocks.push(MarkdownBlock::Todo { checked, content }); + } else if ordered { + blocks.push(MarkdownBlock::NumberedListItem(content)); + } else { + blocks.push(MarkdownBlock::BulletListItem(content)); + } +} + +fn ast_table_to_ir<'a>(node: &'a AstNode<'a>, alignments: Vec) -> MarkdownBlock { + let rows = node + .children() + .map(|row| { + let is_header = matches!(row.data.borrow().value, NodeValue::TableRow(true)); + let cells = row + .children() + .map(|cell| MarkdownTableCell { + content: collect_inline_children(cell), + }) + .collect::>(); + MarkdownTableRow { is_header, cells } + }) + .collect::>(); + MarkdownBlock::Table { alignments, rows } +} + +fn collect_inline_children<'a>(node: &'a AstNode<'a>) -> Vec { + let mut nodes = Vec::new(); + for child in node.children() { + collect_inline_nodes(child, &MarkdownInlineStyles::default(), &mut nodes); + } + merge_adjacent_inline_nodes(nodes) +} + +fn collect_inline_nodes<'a>( + node: &'a AstNode<'a>, + active_styles: &MarkdownInlineStyles, + nodes: &mut Vec, +) { + match node.data.borrow().value.clone() { + NodeValue::Text(text) => push_inline_text_node(nodes, text.as_ref(), active_styles), + NodeValue::TaskItem(task_item) => { + let marker = if task_item.symbol.is_some() { + "[x] " + } else { + "[ ] " + }; + push_inline_text_node(nodes, marker, active_styles); + for child in node.children() { + collect_inline_nodes(child, active_styles, nodes); + } + } + NodeValue::Code(code) => { + let mut styles = active_styles.clone(); + styles.code = true; + push_inline_text_node(nodes, &code.literal, &styles); + } + NodeValue::Strong => { + let mut styles = active_styles.clone(); + styles.bold = true; + collect_inline_children_with_styles(node, &styles, nodes); + } + NodeValue::Emph => { + let mut styles = active_styles.clone(); + styles.italic = true; + collect_inline_children_with_styles(node, &styles, nodes); + } + NodeValue::Strikethrough => { + let mut styles = active_styles.clone(); + styles.strike = true; + collect_inline_children_with_styles(node, &styles, nodes); + } + NodeValue::Underline => { + let mut styles = active_styles.clone(); + styles.underline = true; + collect_inline_children_with_styles(node, &styles, nodes); + } + NodeValue::Link(link) => { + let mut styles = active_styles.clone(); + styles.link = Some(link.url.clone()); + collect_inline_children_with_styles(node, &styles, nodes); + } + NodeValue::SoftBreak | NodeValue::LineBreak => { + push_inline_text_node(nodes, " ", active_styles); + } + NodeValue::HtmlInline(text) => push_inline_text_node(nodes, text.as_ref(), active_styles), + NodeValue::Image(link) => { + let mut styles = active_styles.clone(); + styles.link = Some(link.url.clone()); + push_inline_text_node(nodes, link.url.as_str(), &styles); + } + _ => collect_inline_children_with_styles(node, active_styles, nodes), + } +} + +fn collect_inline_children_with_styles<'a>( + node: &'a AstNode<'a>, + active_styles: &MarkdownInlineStyles, + nodes: &mut Vec, +) { + for child in node.children() { + collect_inline_nodes(child, active_styles, nodes); + } +} + +fn push_inline_text_node( + nodes: &mut Vec, + text: &str, + styles: &MarkdownInlineStyles, +) { + if text.is_empty() { + return; + } + nodes.push(MarkdownInline { + text: text.to_string(), + styles: styles.clone(), + }); +} + +fn merge_adjacent_inline_nodes(nodes: Vec) -> Vec { + let mut merged = Vec::::new(); + for node in nodes { + if let Some(last) = merged.last_mut() { + if last.styles == node.styles { + last.text.push_str(&node.text); + continue; + } + } + merged.push(node); + } + merged +} + +fn paragraph_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> { + let mut children = node.children(); + let first = children.next()?; + if children.next().is_some() { + return None; + } + link_attachment_media(first) +} + +fn paragraph_leading_attachment_media<'a>( + node: &'a AstNode<'a>, +) -> Option<(String, String, Vec)> { + let mut children = node.children(); + let first = children.next()?; + let (name, source_path) = link_attachment_media(first)?; + let second = children.next()?; + if !matches!( + second.data.borrow().value, + NodeValue::SoftBreak | NodeValue::LineBreak + ) { + return None; + } + let mut remaining = Vec::new(); + for child in children { + collect_inline_nodes(child, &MarkdownInlineStyles::default(), &mut remaining); + } + Some((name, source_path, remaining)) +} + +fn link_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> { + let NodeValue::Link(link) = &node.data.borrow().value else { + return None; + }; + parse_markdown_attachment_link(&format!("[{}]({})", collect_plain_text(node), link.url)) +} + +fn markdown_ast_document_to_blocks(document: &MarkdownAstDocument) -> Value { + Value::Array( + document + .blocks + .iter() + .enumerate() + .map(|(index, block)| markdown_block_to_json(block, index + 1)) + .collect(), + ) +} + +fn markdown_block_to_json(block: &MarkdownBlock, block_number: usize) -> Value { + match block { + MarkdownBlock::Paragraph(content) => json_block( + "paragraph", + legacy_inline_nodes_to_json(content), + block_number, + ), + MarkdownBlock::Heading { level, content } => json!({ + "id": format!("local-block-{block_number}"), + "type": "heading", + "props": { "level": level }, + "content": legacy_inline_nodes_to_json(content), + "children": [], + }), + MarkdownBlock::Quote(content) => { + json_block("quote", legacy_inline_nodes_to_json(content), block_number) + } + MarkdownBlock::CodeBlock { language, text } => json!({ + "id": format!("local-block-{block_number}"), + "type": "codeBlock", + "props": { "language": language }, + "content": [{ "type": "text", "text": text, "styles": {} }], + "children": [], + }), + MarkdownBlock::Divider => json!({ + "id": format!("local-block-{block_number}"), + "type": "divider", + "content": [], + "children": [], + }), + MarkdownBlock::BulletListItem(content) => json_block( + "bulletListItem", + legacy_inline_nodes_to_json(content), + block_number, + ), + MarkdownBlock::NumberedListItem(content) => json_block( + "numberedListItem", + legacy_inline_nodes_to_json(content), + block_number, + ), + MarkdownBlock::Todo { checked, content } => json!({ + "id": format!("local-block-{block_number}"), + "type": "todo", + "props": { "checked": checked }, + "content": legacy_inline_nodes_to_json(content), + "children": [], + }), + MarkdownBlock::Table { alignments, rows } => { + table_block_to_json(alignments, rows, block_number) + } + MarkdownBlock::Media { name, source_path } => json!({ + "id": format!("local-block-{block_number}"), + "type": "media", + "props": { + "name": name, + "sourcePath": source_path, + }, + "content": [], + "children": [], + }), + } +} + +fn table_block_to_json( + alignments: &[TableAlignment], + rows: &[MarkdownTableRow], + block_number: usize, +) -> Value { + let content_rows = rows + .iter() + .map(|row| { + let cells = row + .cells + .iter() + .enumerate() + .map(|(column_index, cell)| { + let cell_type = if row.is_header { + "tableHeader" + } else { + "tableCell" + }; + let text_align = match alignments + .get(column_index) + .copied() + .unwrap_or(TableAlignment::None) + { + TableAlignment::Left => "left", + TableAlignment::Center => "center", + TableAlignment::Right => "right", + TableAlignment::None => "", + }; + json!({ + "type": cell_type, + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null, + "textAlign": text_align, + }, + "content": [{ + "type": "paragraph", + "content": tiptap_inline_nodes_to_json(&cell.content), + }] + }) + }) + .collect::>(); + json!({ + "type": "tableRow", + "content": cells, + }) + }) + .collect::>(); + json!({ + "id": format!("local-block-{block_number}"), + "type": "table", + "props": { + "tiptapTable": { + "type": "table", + "attrs": { "blockId": format!("local-block-{block_number}") }, + "content": content_rows, + } + }, + "content": [], + "children": [], + }) +} + +fn json_block(block_type: &str, content: Vec, block_number: usize) -> Value { + json!({ + "id": format!("local-block-{block_number}"), + "type": block_type, + "content": content, + "children": [], + }) +} + +fn legacy_inline_nodes_to_json(nodes: &[MarkdownInline]) -> Vec { + nodes + .iter() + .map(|node| { + json!({ + "type": "text", + "text": node.text, + "styles": legacy_styles_to_json(&node.styles), + }) + }) + .collect() +} + +fn legacy_styles_to_json(styles: &MarkdownInlineStyles) -> Value { + let mut object = Map::new(); + if styles.bold { + object.insert("bold".to_string(), Value::Bool(true)); + } + if styles.italic { + object.insert("italic".to_string(), Value::Bool(true)); + } + if styles.strike { + object.insert("strike".to_string(), Value::Bool(true)); + } + if styles.underline { + object.insert("underline".to_string(), Value::Bool(true)); + } + if styles.code { + object.insert("code".to_string(), Value::Bool(true)); + } + if let Some(href) = styles + .link + .as_deref() + .map(str::trim) + .filter(|href| !href.is_empty()) + { + object.insert("link".to_string(), Value::String(href.to_string())); + } + Value::Object(object) +} + +fn tiptap_inline_nodes_to_json(nodes: &[MarkdownInline]) -> Vec { + nodes + .iter() + .filter(|node| !node.text.is_empty()) + .map(|node| { + let mut object = Map::new(); + object.insert("type".to_string(), Value::String("text".to_string())); + object.insert("text".to_string(), Value::String(node.text.clone())); + let marks = tiptap_marks_from_styles(&node.styles); + if !marks.is_empty() { + object.insert("marks".to_string(), Value::Array(marks)); + } + Value::Object(object) + }) + .collect() +} + +fn tiptap_marks_from_styles(styles: &MarkdownInlineStyles) -> Vec { + let mut marks = Vec::new(); + if styles.bold { + marks.push(json!({ "type": "bold" })); + } + if styles.italic { + marks.push(json!({ "type": "italic" })); + } + if styles.underline { + marks.push(json!({ "type": "underline" })); + } + if styles.strike { + marks.push(json!({ "type": "strike" })); + } + if styles.code { + marks.push(json!({ "type": "code" })); + } + if let Some(href) = styles + .link + .as_deref() + .map(str::trim) + .filter(|href| !href.is_empty()) + { + marks.push(json!({ "type": "link", "attrs": { "href": href } })); + } + marks +} + +fn collect_plain_text<'a>(node: &'a AstNode<'a>) -> String { + let mut parts = Vec::new(); + for descendant in node.descendants() { + if let NodeValue::Text(text) = &descendant.data.borrow().value { + parts.push(text.as_ref().to_string()); + } + } + parts.join("") +} + +pub(crate) fn split_frontmatter(markdown: &str) -> (Option, &str) { + let normalized = markdown.strip_prefix('\u{feff}').unwrap_or(markdown); + if !normalized.starts_with("---\n") { + return (None, normalized); + } + let rest = &normalized[4..]; + if let Some(end) = rest.find("\n---\n") { + let frontmatter = rest[..end].to_string(); + let body = &rest[end + 5..]; + return (Some(frontmatter), body); + } + (None, normalized) +} + +fn read_frontmatter_field(frontmatter: &str, key: &str) -> Option { + frontmatter.lines().find_map(|line| { + let (candidate_key, value) = line.split_once(':')?; + if candidate_key.trim() != key { + return None; + } + let trimmed = value.trim().trim_matches('"').trim_matches('\'').trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn extract_first_h1_title(body: &str) -> Option { + body.lines().find_map(|line| { + let trimmed = line.trim(); + trimmed + .strip_prefix("# ") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +pub(crate) fn file_stem_title(file_name: &str) -> String { + std::path::Path::new(file_name) + .file_stem() + .and_then(|stem| stem.to_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(file_name) + .to_string() +} diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index fc239d8d..b6499112 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -8,6 +8,7 @@ mod health; mod hermes; mod kernel; mod local_folder_source; +mod local_markdown_parser; mod mindmap_shell; mod query_support; mod search; diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 0c8beefc..c2b17dcf 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -323,6 +323,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const SAVE_EVENT = `${EVENT_PREFIX}:save-request`; const READY_EVENT = `${EVENT_PREFIX}:ready`; const ERROR_EVENT = `${EVENT_PREFIX}:error`; + const COMMAND_EVENT = `${EVENT_PREFIX}:command`; + const BRIDGE_PROTOCOL = 'mnote.leptos_tiptap.bridge.v1'; const parseJsonScript = (id) => { const node = document.getElementById(id); @@ -644,11 +646,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const pageBody = aggregate.body || {}; const permissions = aggregate.head?.permissions || {}; - const conflictDetectionKey = typeof pageBody.conflictDetectionKey === 'string' - ? pageBody.conflictDetectionKey - : typeof pageBody.conflict_detection_key === 'string' - ? pageBody.conflict_detection_key + const conflictDetectionKeyFromBody = (body) => typeof body?.conflictDetectionKey === 'string' + ? body.conflictDetectionKey + : typeof body?.conflict_detection_key === 'string' + ? body.conflict_detection_key : null; + const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody); const revisionFromConflictKey = (value) => { const match = String(value || '').match(/:(\d+)$/); return match ? Number(match[1]) : null; @@ -688,6 +691,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { let saveTimer = 0; let lastSavedSerialized = ''; + let hasPendingLocalChanges = false; + let suppressNextHostSyncChange = false; + let localExternalPollTimer = 0; + let localExternalPollInFlight = false; + let lastExternalConflictDetectionKey = editorMeta.conflictDetectionKey || ''; const normalizeBridgeValue = (value) => { if (value instanceof Map) { const out = {}; @@ -719,7 +727,14 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { currentEditorText(), ); const serialized = JSON.stringify(tiptapDocument); + if (suppressNextHostSyncChange && serialized === lastSavedSerialized) { + suppressNextHostSyncChange = false; + hasPendingLocalChanges = false; + setStatus('saved'); + return; + } if (serialized === lastSavedSerialized) { + hasPendingLocalChanges = false; setStatus('saved'); return; } @@ -751,7 +766,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (Number.isInteger(saved.revision)) editorMeta.revision = saved.revision; if (typeof saved.conflict_detection_key === 'string') editorMeta.conflictDetectionKey = saved.conflict_detection_key; if (typeof saved.conflictDetectionKey === 'string') editorMeta.conflictDetectionKey = saved.conflictDetectionKey; + if (editorMeta.conflictDetectionKey) lastExternalConflictDetectionKey = editorMeta.conflictDetectionKey; lastSavedSerialized = serialized; + hasPendingLocalChanges = false; if (typeof window.__mnoteRecordPageHistorySnapshot === 'function') { window.__mnoteRecordPageHistorySnapshot('save', { wordCount: content.filter((block) => typeof block?.content === 'string' && block.content.trim()).length, @@ -767,7 +784,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const queueSave = (event) => { const payload = normalizeEnvelopePayload(event); if (!payload) return; + if (suppressNextHostSyncChange) { + const tiptapDocument = toTiptapDocument( + payload?.tiptapDocument || payload?.editorDocument || payload?.content, + currentEditorText(), + ); + if (JSON.stringify(tiptapDocument) === lastSavedSerialized) { + suppressNextHostSyncChange = false; + hasPendingLocalChanges = false; + setStatus('saved'); + return; + } + } if (saveTimer) window.clearTimeout(saveTimer); + hasPendingLocalChanges = true; setStatus('dirty'); saveTimer = window.setTimeout(() => { saveTimer = 0; @@ -785,6 +815,79 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { root.addEventListener(CHANGE_EVENT, queueSave); root.addEventListener(SAVE_EVENT, queueSave); + const pageAggregateUrl = () => { + const url = new URL(`/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}`, window.location.origin); + url.searchParams.set('sourceKind', bootstrap.sourceKind || 'local_folder'); + if (bootstrap.rootUri) url.searchParams.set('rootUri', bootstrap.rootUri); + return url; + }; + + const dispatchReplaceContent = (nextAggregate) => { + const nextBody = nextAggregate?.body || {}; + const nextPermissions = nextAggregate?.head?.permissions || {}; + const nextConflictKey = conflictDetectionKeyFromBody(nextBody); + const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); + const nextTiptapDocument = toTiptapDocument(nextBody.content); + editorMeta.revision = nextRevision; + editorMeta.conflictDetectionKey = nextConflictKey; + lastExternalConflictDetectionKey = nextConflictKey || ''; + lastSavedSerialized = JSON.stringify(nextTiptapDocument); + hasPendingLocalChanges = false; + suppressNextHostSyncChange = true; + clearEmbeddedLocalDraft(); + root.dispatchEvent(new CustomEvent(COMMAND_EVENT, { + bubbles: true, + detail: { + protocol: BRIDGE_PROTOCOL, + runtime: 'mnote-leptos-tiptap-spike', + version: '1.1.0', + source: 'mnote-web-local-folder-watch', + event: COMMAND_EVENT, + payload: { + command: 'replaceContent', + documentId: bootstrap.documentId, + workspaceId: bootstrap.workspaceId, + title: nextAggregate?.head?.title || mountOptions.title, + content: nextTiptapDocument, + revision: editorMeta.revision, + conflictDetectionKey: editorMeta.conflictDetectionKey, + readOnly: Boolean(nextPermissions.readOnly), + }, + }, + })); + setStatus('synced-external-change'); + }; + + const pollLocalMarkdownExternalChange = async () => { + if (bootstrap.sourceKind !== 'local_folder' || !bootstrap.rootUri || document.hidden) return; + if (localExternalPollInFlight) return; + localExternalPollInFlight = true; + try { + const response = await fetch(pageAggregateUrl().toString(), { + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + if (!response.ok) return; + const payload = await response.json(); + const nextAggregate = payload?.result; + const nextConflictKey = conflictDetectionKeyFromBody(nextAggregate?.body || {}); + if (!nextConflictKey || !lastExternalConflictDetectionKey) { + lastExternalConflictDetectionKey = nextConflictKey || lastExternalConflictDetectionKey; + return; + } + if (nextConflictKey === lastExternalConflictDetectionKey) return; + if (hasPendingLocalChanges || saveTimer) { + setStatus('external-change-conflict', '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突'); + return; + } + dispatchReplaceContent(nextAggregate); + } catch (error) { + console.warn('mnote local folder 外部更新检测失败', error); + } finally { + localExternalPollInFlight = false; + } + }; + const start = async () => { setStatus('loading-assets'); const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' }); @@ -800,11 +903,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { await runtime.default(wasmUrl); clearEmbeddedLocalDraft(); const mountId = runtime.mount(root, mountOptions); + lastSavedSerialized = JSON.stringify(mountOptions.content); root.setAttribute('data-runtime-mount-id', String(mountId)); root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island'); if (typeof window.__mnoteApplyPageOptionsToShell === 'function') { window.__mnoteApplyPageOptionsToShell(); } + if (bootstrap.sourceKind === 'local_folder' && bootstrap.rootUri) { + void pollLocalMarkdownExternalChange(); + localExternalPollTimer = window.setInterval(() => { + void pollLocalMarkdownExternalChange(); + }, 1200); + } setStatus('ready'); }; @@ -1530,6 +1640,10 @@ mod tests { assert!(html.contains("asset.png")); assert!(html.contains("data-row-kind=\"markdown\"")); assert!(html.contains("data-mnote-action=\"open-local-folder\"")); + assert!(html.contains("pollLocalMarkdownExternalChange")); + assert!(html.contains("/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}")); + assert!(html.contains("command: 'replaceContent'")); + assert!(html.contains("external-change-conflict")); } #[tokio::test]