# 3-15 [process] 本地 Markdown 图片与附件上传相对路径设计 v1 > 创建时间:2026-05-18 > > 状态:`[process]`(代码、单测和 HTTP smoke 已完成;真实浏览器 smoke 待补) > > 所属主线:`03-rust-web` / `local_folder` / `local markdown` > > 参考:`design/05-editor-mainline/reference-code/vscode` > > 实现与缺陷闭环:`/mnt/Data1T/mnote/bugs/03-rust-web/done/3-16-local-markdown-upload-uses-convex-media-asset-v1.md` ## 1. 背景 云端页面的图片与附件上传当前走 `/api/media/upload`,生成 Convex media asset,并在正文中写入 image block 或 OnlyOffice 附件链接。 本地文件夹模式不同:页面真源是本地 `.md` 文件,资源真源是同一 local root 下的真实文件。若本地 `.md` 页面内的“上传图片/附件”继续走云端 media asset,会导致 Markdown 文件不可迁移、离开 MNote 后链接失效,也会破坏 local folder 的文件系统可理解性。 当前本地 Markdown 读写已具备基础能力: - `local_markdown_parser.rs` 能把非 `.md` 相对链接解析为 `media` 块。 - `save_local_markdown_page` 能把 `media` 块写回 `[name](sourcePath)`。 - 本地文件树已能显示 local root 下的普通资源文件,并支持资源生命周期。 缺口是:编辑器上传文件时,还没有把外部文件复制到本地 Markdown 资源目录,并把页面正文写成标准相对 Markdown 链接。 ## 2. VS Code 参考结论 参考文件: - `extensions/markdown-language-features/src/languageFeatures/copyFiles/newFilePathGenerator.ts` - `extensions/markdown-language-features/src/languageFeatures/copyFiles/copyFiles.ts` - `extensions/markdown-language-features/src/languageFeatures/copyFiles/shared.ts` VS Code 的关键做法: - 复制文件时先计算目标路径,默认目标在当前 Markdown 文档旁边。 - 支持 `markdown.copyFiles.destination` 配置,目标路径可包含 `${documentBaseName}`、`${fileName}` 等变量。 - 冲突策略默认递增命名,如 `image.png`、`image-1.png`。 - 插入 Markdown 时使用相对当前文档目录的路径。 - 图片使用 `![alt](relative/path)`,普通文件使用 `[text](relative/path)`。 - 路径包含空格或括号不匹配时用 `<...>` 包裹。 MNote 不需要先完整复刻 VS Code 的配置系统,但应采用同一底层原则:本地 Markdown 写标准相对路径,资源文件留在 local root 内。 ## 3. 目标 本阶段目标是给本地 `.md` 页面提供可验证的最小上传链路: 1. 在 `sourceKind=local_folder` 的文档编辑器中上传图片或附件时,不调用云端 `/api/media/upload`。 2. 外部文件复制到当前 `.md` 文件旁的 `{documentBaseName}.assets/` 目录。 3. 页面正文写入标准 Markdown 相对路径: - 图片:`![file.png](README.assets/file.png)` - 附件:`[file.docx](README.assets/file.docx)` 4. 保存后 `.md` 文件落盘包含相对路径;刷新页面能恢复图片/附件块。 5. local file tree 能通过现有 watch / projection 看到新增资源文件。 非目标: - 不做 VS Code 风格可配置 `copyFiles.destination`。 - 不自动删除未引用资源文件。 - 不把本地附件接入 Convex media asset。 - 不在本阶段实现 OnlyOffice 对本地 Office 文件的完整编辑写回;可先作为本地文件链接/附件行打开或下载。 ## 4. 目录与命名规则 默认目标目录: ```text /.assets/ ``` 示例: ```text docs/README.md docs/README.assets/image.png docs/README.assets/spec.docx ``` 命名规则: - 原始文件名做 UTF-8 保留,但必须去掉路径分隔符和空名。 - 若目标已存在且内容未判断相同,使用递增后缀: - `image.png` - `image-1.png` - `image-2.png` - 生成的相对路径使用 `/`,不使用平台分隔符。 - 返回给编辑器的 `sourcePath` 必须是相对当前 `.md` 文件所在目录的路径。 ## 5. 安全边界 所有本地上传必须在 Rust Web 层校验: - `rootUri` 必须解析为已存在 local root。 - `documentId` 必须解析到 local root 下的 `.md` 文件。 - 目标目录和目标文件必须在 local root 内。 - 禁止绝对目标路径、`..` 逃逸、空文件名、目录文件名。 - 不覆盖已有文件,除非后续有显式配置;本阶段只递增命名。 ## 6. 数据流 ### 6.1 图片上传 ```text 编辑器 slash 图片 / 拖入图片 → local markdown asset upload route → 复制文件到 README.assets/ → 返回 local asset descriptor → 编辑器插入 image node,src=相对路径 → /api/documents/save?sourceKind=local_folder → save_local_markdown_page 写 ![alt](relative/path) ``` ### 6.2 附件上传 ```text 编辑器 slash 附件 / 拖入附件 → local markdown asset upload route → 复制文件到 README.assets/ → 返回 local asset descriptor → 编辑器插入普通 link mark 或 media block → /api/documents/save?sourceKind=local_folder → save_local_markdown_page 写 [name](relative/path) ``` ## 7. 接口设计 新增本地上传入口: ```http POST /api/local-folder/assets/upload multipart/form-data ``` 字段: - `file`: 上传文件。 - `rootUri`: local folder root URI。 - `documentId`: 当前 local markdown document id。 - `kind`: `image | attachment`,可由 MIME 推断兜底。 响应: ```json { "ok": true, "asset": { "id": "local:asset:docs/README.assets/image.png", "asset_type": "image", "file_name": "image.png", "mime_type": "image/png", "file_size": 1234, "file_url": "README.assets/image.png", "sourcePath": "README.assets/image.png", "document_id": "local-md:docs/README.md", "sourceKind": "local_folder" } } ``` 说明: - `file_url` 在本地模式中不是 HTTP URL,而是给 editor 插入的 Markdown 相对路径。 - 若前端需要实际预览,可用后续已有 local asset serving route 或补同源读取 route;正文落盘仍保持相对路径。 ## 8. 前端分流 `uploadFileToMediaAsset` 在上传前判断: - 若 `currentSourceKind() === "local_folder"`:调用 `/api/local-folder/assets/upload`。 - 否则:继续调用 `/api/media/upload`。 `insertUploadedAssetIntoEditor` 对 local asset 的处理: - 图片:`setImage({ src: sourcePath, alt: file_name, title: file_name })`。 - 附件:插入 link,`href=sourcePath`,不生成 `/onlyoffice?...assetId=...`。 这能让现有 `inlineTextNodes -> styles.link -> save_local_markdown_page` 路径写出普通 Markdown 链接。 ## 9. Checklist - [x] 设计稿落入 `design/03-rust-web/process/`。 - [x] Rust route 新增 local markdown asset upload handler。 - [x] handler 能解析 `rootUri + documentId` 到本地 `.md` 文件路径。 - [x] handler 能创建 `{mdBase}.assets/` 并递增命名避免覆盖。 - [x] handler 拒绝 `..` 逃逸、空文件名、非 local folder 请求。 - [x] 前端上传分流:local folder 走本地 route,云端继续走 `/api/media/upload`。 - [x] 前端 local image 插入相对路径 image node。 - [x] 前端 local attachment 插入相对路径 link,不生成 OnlyOffice assetId URL。 - [x] `save_local_markdown_page` 写回 `![alt](path)` 与 `[name](path)`。 - [x] 单测覆盖本地上传目标路径和冲突递增。 - [x] 单测覆盖 local markdown 图片/附件 roundtrip。 - [x] HTTP smoke 覆盖 `/api/local-folder/assets/upload` 真实 multipart 上传与落盘。 - [ ] 浏览器 smoke 覆盖本地 `.md` 页面上传图片/附件、刷新后恢复、文件树可见。 ## 10. 验收命令 ```bash cargo fmt --manifest-path rust/Cargo.toml --all --check cargo test --manifest-path rust/Cargo.toml -p mnote-web local_markdown -- --nocapture cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder -- --nocapture ``` 浏览器 smoke 后续新增: ```bash MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-local-markdown-asset-upload-smoke.js ```