fix local markdown attachment regressions

This commit is contained in:
lix-2026
2026-05-29 11:13:05 +08:00
parent 1109e3c0d8
commit cbe789e034
63 changed files with 3249 additions and 1502 deletions
@@ -9,8 +9,8 @@
## 1. 目标
- 把图片、PDF、Office、音视频、普通文件统一收口到标准 Markdown link/image 表达。
- 上传时默认把文件复制到当前 `.md` 邻接资源目录,并在正文插入相对链接。
- 用户手写或粘贴 `file://``https://`相对路径、兼容裸绝对路径时,内核都能解析为同一类 `AttachmentRef`
- 上传时默认把文件复制到当前 `.md` 所在同目录,并在正文插入相对链接。
- 用户手写或粘贴同目录相对路径、`file://``https://`、兼容裸绝对路径时,内核都能解析为同一类 `AttachmentRef`
- Tiptap 只负责编辑器节点渲染,不持有资源真相;点击打开交给 resource opener。
- 文件树、resource tab、preview viewer、系统打开、授权检查都基于 `AttachmentRef` / `OpenTarget`,不从 DOM 字符串重复猜测。
@@ -28,34 +28,39 @@
```md
[同目录 PDF](./report.pdf)
[页面附件](./dha_computational_modeling_literature.assets/model.pptx)
![图片](./dha_computational_modeling_literature.assets/figure.png)
[页面附件](./model.pptx)
![图片](./figure.png)
[外部本地文件](file:///mnt/Data1T/research/shared/reference.pdf)
[网页资料](https://example.com/paper.pdf)
```
兼容输入:
支持输入:
- `./a.pdf`:相对当前 `.md` 所在目录解析。
- `../assets/a.pdf`:相对当前 `.md` 所在目录解析。
- `file:///mnt/Data1T/a.pdf`:按标准 file URI 解析,必须经过授权 root 检查。
- `/mnt/Data1T/a.pdf`:作为兼容输入接受保存时建议规范化为 `file:///mnt/Data1T/a.pdf`,或在同 root 内转换为相对路径。
- `/mnt/Data1T/a.pdf`:作为开发期兼容输入接受并解析为外部本地文件;本清单不要求保存时自动规范化为 `file://`,也不要求转换为相对路径。
- `https://...` / `http://...`:远程 URL,不进入本地文件授权,但进入 URL 安全策略。
明确不做:
- 当前仍处开发态,不做历史 Markdown 中旧 `/office-preview?...` href 的批量迁移、读取兼容迁移或保存时自动重写。
- 不兼容 `../assets/a.pdf` 这类上级目录相对路径;不解析、不生成、不迁移这类引用。跨目录本地附件请使用经过授权的 `file://` 或裸绝对路径输入。
默认上传插入规则:
```text
当前文档:/mnt/Data1T/research/DHA/literature/dha.md
上传文件:/home/lix/Downloads/model.pptx
复制目标:/mnt/Data1T/research/DHA/literature/dha.assets/model-<stable-id>.pptx
正文插入:[model.pptx](./dha.assets/model-<stable-id>.pptx)
复制目标:/mnt/Data1T/research/DHA/literature/model-<stable-id>.pptx
正文插入:[model.pptx](./model-<stable-id>.pptx)
```
说明:
- 相对链接不是不唯一;内核解析时用当前 `.md` 路径补全,得到唯一绝对路径。
- 正文优先保存相对路径,是为了移动文件夹、同步、分享、VSCode/Sidex/Markdown 工具打开时保持可用。
- 绝对路径作为外部引用保留,但默认不作为上传后的持久格式。
- 绝对路径作为外部引用保留上传后的持久格式仍固定为当前 `.md` 同目录相对链接
- 默认上传目录维持当前 MNote 行为:与当前 `.md` 同目录。`<mdStem>.assets/``assets/` 或用户自定义资源目录只作为未来可选整理策略,不作为本清单默认行为。
## 4. `AttachmentRef` 合同
@@ -120,9 +125,9 @@ type OpenTarget = {
1. 用户在 Tiptap/Markdown 位置发起上传。
2. 浏览器只持有临时 `UploadDraft`:文件名、进度、取消、错误。
3. Rust upload API 根据当前 `.md` 路径计算 page-local assets 目录
3. Rust upload API 根据当前 `.md` 路径计算同目录上传目标
4. 后端写入文件,进行文件名净化、冲突命名、可选 hash 去重。
5. API 返回稳定 Markdown href,如 `./dha.assets/model-<id>.pptx`
5. API 返回稳定 Markdown href,如 `./model-<id>.pptx`
6. Tiptap 将临时上传节点替换为标准 link/image 节点。
7. 保存 Markdown 后,watcher / projection 重新解析出 `AttachmentRef`
@@ -351,75 +356,99 @@ MNote 借鉴点:上传结果返回稳定 href;文件名净化、冲突命名
### Batch A:现状冻结与 RED 验证
- [ ] 复现并记录当前附件行为:第一个上传可打开、第二个上传后重渲染仍可打开、文件树显示附件。
- [ ] 新增或扩展浏览器 smoke,断言 Markdown 中不能写入 `/office-preview` 作为持久 href。
- [ ] 截图记录当前卡片、文件树、resource tab、preview 行为。
- [x] 复现并记录当前附件行为:第一个上传可打开、第二个上传后重渲染仍可打开、文件树显示附件。
- [x] 新增或扩展浏览器 smoke,断言 Markdown 中不能写入 `/office-preview` 作为持久 href。
- [x] 截图记录当前卡片、文件树、resource tab、preview 行为。
### Batch BMarkdown href 解析合同
- [ ] 在 Rust 层新增 `AttachmentRef` 或等价协议类型。
- [ ] 支持解析 Markdown image/link 的 `rawHref`、label、source range。
- [ ] 支持相对路径、`file://``http(s)`、兼容裸绝对路径。
- [ ] Rust 单测覆盖路径空格、中文、百分号编码、目录链接、缺失文件。
- [x] 在 Rust 层新增 `AttachmentRef` 或等价协议类型。
- [x] 支持解析 Markdown image/link 的 `rawHref`、label、source range。
- [x] 支持同目录相对路径、`file://``http(s)`、兼容裸绝对路径;不兼容 `../assets/a.pdf`
- [x] Rust 单测覆盖路径空格、中文、百分号编码、目录链接、缺失文件。
### Batch C:授权与 root 解析
- [ ] `file://` 和裸绝对路径必须经过 SQLite control-plane allowed roots 检查。
- [ ] 相对链接解析时必须基于 owner `.md` 路径和当前 rootUri。
- [ ] 不同用户授权隔离,未授权路径 projection 标记 `authorized=false`
- [ ] Rust 定点测试覆盖已授权、未授权、跨用户隔离。
- [x] `file://` 和裸绝对路径必须经过 SQLite control-plane allowed roots 检查。
- [x] 相对链接解析时必须基于 owner `.md` 路径和当前 rootUri。
- [x] 不同用户授权隔离,未授权路径 projection 标记 `authorized=false`
- [x] Rust 定点测试覆盖已授权、未授权、跨用户隔离。
### Batch D:上传写入与 href 返回
- [ ] local upload API 接收 owner document path,不再只返回 preview URL。
- [ ] 默认写入 `<mdStem>.assets/`
- [ ] 文件名净化、冲突命名、可选 hash 去重。
- [ ] 返回 `{ displayName, markdownHref, attachmentRef }`
- [ ] 上传失败不能留下 orphan 正文链接;成功后 watcher 能刷新文件树。
- [x] local upload API 接收 owner document path,不再只返回 preview URL。
- [x] 默认写入当前 `.md` 所在同目录
- [x] 文件名净化、冲突命名、可选 hash 去重。
- [x] 返回 `{ displayName, markdownHref, attachmentRef }`
- [x] 上传失败不能留下 orphan 正文链接;成功后 watcher 能刷新文件树。
### Batch ETiptap / Markdown 编辑器渲染
- [ ] 上传中使用临时 `UploadDraft` / upload NodeView。
- [ ] 成功后替换为标准 link/image,不保存 preview URL。
- [ ] 根据 `AttachmentRef.openKind` 渲染图片、PDF、Office、音视频、普通文件卡片。
- [ ] 卡片 UI 只消费 projection;不把 DOM data 属性当持久真相。
- [ ] 浏览器截图验证卡片形态、hover/click、保存重开后行为一致。
- [x] 上传中使用临时 `UploadDraft` / upload NodeView。
- [x] 成功后替换为标准 link/image,不保存 preview URL。
- [x] 根据 `AttachmentRef.openKind` 渲染图片、PDF、Office、音视频、普通文件卡片。
- [x] 卡片 UI 只消费 projection;不把 DOM data 属性当持久真相。
- [x] 浏览器截图验证卡片形态、hover/click、保存重开后行为一致。
### Batch FResource opener 与 preview
- [ ] 新增或收口 `openAttachment(ref)`
- [ ] PDF/Office/image/text/download/system external 分发到 `OpenTarget`
- [ ] resource tab watch 与附件 ref 绑定,避免第二个附件打开后第一个失效。
- [ ] 缺失和未授权路径显示明确阻断状态。
- [x] 新增或收口 `openAttachment(ref)`
- [x] PDF/Office/image/text/download/system external 分发到 `OpenTarget`
- [x] resource tab watch 与附件 ref 绑定,避免第二个附件打开后第一个失效。
- [x] 缺失和未授权路径显示明确阻断状态。
### Batch GFile Tree / watcher / missing asset
- [ ] 文件树从附件 projection 显示 page-local assets 目录和文件。
- [ ] 上传后 watcher 刷新文件树,不依赖手动刷新。
- [ ] 删除/移动附件后正文卡片显示 missing,不崩溃。
- [ ] 外部新增同目录附件后,手写相对链接可解析并打开。
- [x] 文件树从附件 projection 显示当前 `.md` 同目录上传的附件文件。
- [x] 上传后 watcher 刷新文件树,不依赖手动刷新。
- [x] 删除/移动附件后正文卡片显示 missing,不崩溃。
- [x] 外部新增同目录附件后,手写相对链接可解析并打开。
### Batch H兼容迁移
### Batch H开发态清理与非兼容边界(非迁移
- [ ] 兼容旧 Markdown 中已有 `/office-preview?...path=...` href,读取时转换为 `AttachmentRef`,保存时迁移为标准 href
- [ ] 兼容现有 `data-mnote-attachment-link` DOM 增强路径,但不作为主合同
- [ ] 补设计说明:旧路径只做读兼容,不再写入
- [x] 确认新写入 Markdown 不再产生 `/office-preview?...``/api/local-folder/files/open` 或 DOM 临时属性依赖
- [x] 开发态不做旧 `/office-preview?...` href 历史迁移,也不做读取旧 href 后自动重写保存的兼容逻辑;发现旧数据时按当前开发数据重建或手工修正处理
- [x] 不兼容 `../assets/a.pdf` 上级目录相对路径;不解析、不生成、不迁移这类引用,跨目录引用走授权 `file://` / 绝对路径
- [x] 补设计说明:旧路径和 `data-mnote-attachment-link` 只作为开发期遗留现象排查,不作为本清单验收项。
### Batch I:验证矩阵
- [ ] RustAttachmentRef parse/resolve/auth/open target 单测。
- [ ] JSTiptap upload replacement、card renderer、resource opener `node --check` 与定点 smoke。
- [ ] Browser:真实上传 pptx/pdf/docx/png 两个以上文件,保存重开后逐个点击可预览。
- [ ] BrowserVSCode/Sidex 兼容表达验证,Markdown 原文为标准 link/image。
- [ ] Browser:未授权 `file://` 阻断、授权后可打开。
- [ ] `git diff --check`
- [ ] 涉及代码图修改后运行 `codegraph sync .`
- [x] RustAttachmentRef parse/resolve/auth/open target 单测。
- [x] JSTiptap upload replacement、card renderer、resource opener `node --check` 与定点 smoke。
- [x] Browser:真实上传 pptx/pdf/docx/png 两个以上文件,保存重开后逐个点击可预览。
- [x] BrowserVSCode/Sidex 兼容表达验证,Markdown 原文为标准 link/image。
- [x] Browser:未授权 `file://` 阻断、授权后可打开。
- [x] `git diff --check`
- [x] 涉及代码图修改后运行 `codegraph sync .`
## 10. 验收标准
## 10. 执行证据(2026-05-28
- Rust`cargo test -p mnote-web markdown_attachment_refs --manifest-path rust/Cargo.toml` 通过,覆盖标准 link/image、`file://`、http(s)、裸绝对路径、中文/空格/百分号、缺失文件、`../assets` unknown。
- Rust`cargo test -p mnote-web local_markdown_asset_upload_copies_next_to_markdown_with_relative_path --manifest-path rust/Cargo.toml` 通过,确认上传写入当前 `.md` 同目录。
- Rust`cargo test -p mnote-web local_page_aggregate_marks_attachment_refs_authorization_from_sqlite_grants --manifest-path rust/Cargo.toml` 通过,覆盖 SQLite allowed roots 与跨用户隔离。
- Rust`cargo test -p mnote-web local_markdown_save_does_not_migrate_runtime_open_url_inline_link --manifest-path rust/Cargo.toml``cargo test -p mnote-web local_markdown_save_does_not_migrate_relative_runtime_open_url --manifest-path rust/Cargo.toml` 通过,确认开发态不做旧 runtime URL 自动迁移。
- JS`node --check` 通过 `sidebar-attachment-open-runtime.js``local-upload-runtime.js``sidebar-tree-runtime.js``document-tiptap-conversion-runtime.js``document-editor-adapter-runtime.js``task506-local-markdown-attachment-ref-matrix-smoke.js`
- Browser`MNOTE_SMOKE_UI_TIMEOUT_MS=60000 node scripts/task503-local-pptx-upload-filetree-open-smoke.js` 通过,证据:`tmp/task503-local-pptx-upload-filetree-open-smoke/result.json`
- Browser`MNOTE_SMOKE_UI_TIMEOUT_MS=60000 node scripts/task506-local-markdown-attachment-ref-matrix-smoke.js` 通过,证据:`tmp/task506-local-markdown-attachment-ref-matrix-smoke/result.json`;覆盖 PDF/PNG/DOCX 上传、授权外部 file、未授权 file 阻断、`../assets` unknown、缺失附件阻断、Markdown 原文标准 link/image。
- 收尾:`git diff --check` 通过;`codegraph sync .` 通过。
剩余风险:missing/unauthorized 的可见样式以 Tiptap link mark 中的稳定 class 为主;`data-mnote-attachment-*` 只作为 runtime 补偿属性,不作为持久合同。已用 `task506` 验证无需显式 flush 的自动 missing 显示、点击阻断和截图证据。附件增强链不得引入周期刷新;当前已移除 `setInterval` 与启动延迟轮询,只保留 MutationObserver、page aggregate synced、tree delta/resync 等事件驱动入口。
## 10.1 回归修复证据(2026-05-29
- Root cause:中文 bundle 页面刷新前的附件 fallback 曾只把 `local-md:` 中的 `~2F` 解为 `/`,未完整解码 `~E6...` UTF-8 字节,导致 `./a.pdf` 被拼成 `~E6.../a.pdf` 并请求 `/api/local-folder/files/open` 返回 400。已改为 `~XX -> %XX -> decodeURIComponent`
- Root cause:新建页面和点击 `.md` 行时,filetree 选择逻辑会先命中带同一 `documentId` 的 bundle folder,导致 selected/focused/active 落父文件夹。已改为优先消费 `selectTarget.rowId/relativePath` 并通过 `revealFileTreeResource` 展开、聚焦真实 Markdown 行,只有 reveal 失败才退回父文件夹。
- Browser`node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 通过,覆盖新建页面后 bundle 展开,内部 `.md` 行 selected/focused/active。
- Browser`MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:45231 node scripts/task506-local-markdown-attachment-ref-matrix-smoke.js` 通过,覆盖中文页面下 PDF 上传后不刷新立即点击打开、文件树显示同目录附件、授权/未授权 file、missing 阻断;截图 `tmp/task506-local-markdown-attachment-ref-matrix-smoke/00-uploaded-pdf-open-before-reload.png`
- JS`node --check` 通过 `sidebar-attachment-open-runtime.js``sidebar-filetree-command-runtime.js``sidebar-page-tree-runtime.js``task494-filetree-lazy-loading-dedup-smoke.js``task506-local-markdown-attachment-ref-matrix-smoke.js`
- Rust`cargo test --manifest-path rust/Cargo.toml -p mnote-web local_tree_command_create_page_creates_timestamped_nested_bundle -- --nocapture` 通过。
- 收尾:`git diff --check` 通过;`codegraph sync .` 通过。`codegraph status .` 仍显示 `Pending Changes: Added: 1 files`,属于当前工作区 5-34 文件移动/未跟踪状态遗留,非本轮 runtime 修复失败。
## 11. 验收标准
- 任意时刻上传附件,正文只产生标准 Markdown href,不产生运行时 preview URL。
- 第一个、第二个、第三个附件保存重渲染后都能点击并打开。
- 文件树能显示 page-local 附件,且 watcher 自动刷新。
- 文件树能显示当前 `.md` 同目录上传的附件,且 watcher 自动刷新。
- 复制整个本地文件夹到新位置后,相对附件仍可解析。
- `file://` 外部引用在授权 root 内可打开,未授权时阻断。
- Tiptap 卡片、resource tab、preview opener 都消费同一 `AttachmentRef` projection。