diff --git a/design/05-editor-mainline/process/5-34-local-markdown-attachment-ref-contract-v1.md b/design/05-editor-mainline/process/5-34-local-markdown-attachment-ref-contract-v1.md new file mode 100644 index 00000000..029b0bd4 --- /dev/null +++ b/design/05-editor-mainline/process/5-34-local-markdown-attachment-ref-contract-v1.md @@ -0,0 +1,425 @@ +# 5-34 [process] 本地 Markdown 附件引用合同与执行清单 v1 + +> 创建时间:2026-05-28 +> +> Owner:05-editor-mainline / 04-tree-domain resource 边界 +> +> 背景:本地文件夹是 MNote 默认数据真相,`.md` 是正文真相。附件、图片、Office、PDF 等资源不能继续依赖 `/office-preview?...` 这类运行时 URL 或 DOM 临时属性作为持久合同。正文应保存标准 Markdown 链接,Rust/kernel 解析为稳定 `AttachmentRef` projection,前端按 projection 渲染和打开。 + +## 1. 目标 + +- 把图片、PDF、Office、音视频、普通文件统一收口到标准 Markdown link/image 表达。 +- 上传时默认把文件复制到当前 `.md` 邻接资源目录,并在正文插入相对链接。 +- 用户手写或粘贴 `file://`、`https://`、相对路径、兼容裸绝对路径时,内核都能解析为同一类 `AttachmentRef`。 +- Tiptap 只负责编辑器节点渲染,不持有资源真相;点击打开交给 resource opener。 +- 文件树、resource tab、preview viewer、系统打开、授权检查都基于 `AttachmentRef` / `OpenTarget`,不从 DOM 字符串重复猜测。 + +## 2. 非目标 + +- 不引入 SiYuan 式固定 `data/assets` 客户端工作区模型。 +- 不创建 `〈/mnt/.../1.pdf〉` 这类非标准 Markdown 存储语法。 +- 不把 `/office-preview`、`/pdf-preview`、localhost URL 写入 Markdown 正文。 +- 不让前端新增第二套附件归属、资源生命周期或授权真相。 +- 不在本清单内重做完整文件冲突系统;冲突语义继续由 `5-27` 文档保存合同推进,本清单只定义附件引用与打开合同。 + +## 3. 标准正文表达 + +推荐正文存储格式固定为 Markdown 标准语法: + +```md +[同目录 PDF](./report.pdf) +[页面附件](./dha_computational_modeling_literature.assets/model.pptx) +![图片](./dha_computational_modeling_literature.assets/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 内转换为相对路径。 +- `https://...` / `http://...`:远程 URL,不进入本地文件授权,但进入 URL 安全策略。 + +默认上传插入规则: + +```text +当前文档:/mnt/Data1T/research/DHA/literature/dha.md +上传文件:/home/lix/Downloads/model.pptx +复制目标:/mnt/Data1T/research/DHA/literature/dha.assets/model-.pptx +正文插入:[model.pptx](./dha.assets/model-.pptx) +``` + +说明: + +- 相对链接不是不唯一;内核解析时用当前 `.md` 路径补全,得到唯一绝对路径。 +- 正文优先保存相对路径,是为了移动文件夹、同步、分享、VSCode/Sidex/Markdown 工具打开时保持可用。 +- 绝对路径作为外部引用保留,但默认不作为上传后的持久格式。 + +## 4. `AttachmentRef` 合同 + +Rust/kernel 应从 Markdown link/image/html embed 中解析出统一引用: + +```ts +type AttachmentRef = { + refId: string; + ownerDocumentPath: string; + ownerRootUri: string; + rawHref: string; + normalizedHref: string; + label: string; + kind: "pageLocal" | "workspaceRelative" | "externalFile" | "remoteUrl" | "unknown"; + resolvedUri: string | null; + resolvedAbsolutePath: string | null; + relativePath: string | null; + ext: string | null; + contentType: string | null; + exists: boolean | null; + authorized: boolean | null; + openKind: "image" | "pdf" | "office" | "audio" | "video" | "text" | "download" | "external" | "unknown"; + sourceRange: { start: number; end: number } | null; +}; +``` + +长期 owner: + +- Markdown 原文:`.md` 文件。 +- 引用解析:Rust/kernel 或 mnote-web Rust route,不放在浏览器 DOM 增强器里当真相。 +- 授权判断:SQLite control-plane / allowed roots。 +- 展示消费:mnote-web browser runtime / Tiptap island。 +- 打开行为:resource opener,根据 `AttachmentRef.openKind` + 用户动作生成 `OpenTarget`。 + +## 5. 打开合同 + +点击附件卡片时,前端只能传 `AttachmentRef` 或 ref id,不能直接打开 Markdown 中的原始 href。 + +```ts +type OpenTarget = { + targetKind: "resourceTab" | "browserPreview" | "systemExternal" | "download" | "blocked"; + refId: string; + previewUrl?: string; + absolutePath?: string; + reason?: string; +}; +``` + +分发规则: + +- image:内嵌图片或资源 tab 预览。 +- pdf:PDF preview / resource tab。 +- pptx/docx/xlsx:Office preview / resource tab;不把 `/office-preview` 写回 Markdown。 +- audio/video:媒体卡片或资源 tab。 +- text/code:文本 preview 或普通文件打开。 +- unsupported:下载或系统外部打开。 +- unauthorized/missing:显示阻断或缺失状态,不吞点击。 + +## 6. 上传合同 + +上传流程固定为: + +1. 用户在 Tiptap/Markdown 位置发起上传。 +2. 浏览器只持有临时 `UploadDraft`:文件名、进度、取消、错误。 +3. Rust upload API 根据当前 `.md` 路径计算 page-local assets 目录。 +4. 后端写入文件,进行文件名净化、冲突命名、可选 hash 去重。 +5. API 返回稳定 Markdown href,如 `./dha.assets/model-.pptx`。 +6. Tiptap 将临时上传节点替换为标准 link/image 节点。 +7. 保存 Markdown 后,watcher / projection 重新解析出 `AttachmentRef`。 + +文件名规则: + +- 保留原始显示名作为 label。 +- 磁盘文件名进行净化,避免路径分隔符、控制字符、危险扩展伪装。 +- 冲突时追加稳定短 id,不覆盖已有文件。 +- 同名同内容可选 hash 去重,但不能破坏用户可理解的显示名。 + +## 7. Sidex / VSCode / Zed / Tiptap / SiYuan 对照 + +### 可借鉴模型 + +- VSCode/Sidex:资源用 URI 作为稳定身份,打开器通过 `open(resource)` 分发;tab input 也保存 URI,而不是保存某个预览页 URL。 +- Zed:agent mention 使用 `file://` URI 表达本地文件、目录、符号和选区,并有 URI parse/roundtrip 测试。 +- Tiptap:上传节点是临时 atom NodeView,上传成功后替换成 image/link 节点。 +- Kode/Tiptap Markdown:正文层保留标准 `![alt](src)` / `[text](href)`。 +- SiYuan:上传 API 返回稳定资源路径,并按类型渲染不同资源卡片。 + +### 只作参考实现 + +- SiYuan 的 `assets/` hash/cache/watcher 可借鉴,但它基于固定客户端 workspace/data,不符合 MNote 本地文件夹为核心的默认模型。 +- VSCode/Sidex 的 opener service 结构可借鉴,但 MNote opener 必须接入 SQLite 授权、local-first rootUri 和 resource tab。 +- Zed 的 `file://` mention 适合参考 URI 表达和解析测试,不代表 MNote 正文要使用 Zed 专用 mention UI。 + +### 不适合 MNote 的内容 + +- 不使用 SiYuan 全局 `data/assets` 作为唯一资产仓库。 +- 不使用 BlockNote file block 作为 MNote 正文真相。 +- 不让前端 DOM 属性成为附件引用合同。 +- 不把绝对本机路径默认写入 Markdown。 + +## 8. 引用代码 + +### 8.1 Tiptap:上传节点是临时 UI + +`reference-code/tiptap-notion-like-registry/materialized/tiptap-node/image-upload-node/image-upload-node-extension.ts:66-83` + +```ts +export const ImageUploadNode = Node.create({ + name: "imageUpload", + group: "block", + draggable: true, + selectable: true, + atom: true, + addOptions() { + return { + type: "image", + accept: "image/*", + limit: 1, + maxSize: 0, + upload: undefined, +``` + +`reference-code/tiptap-notion-like-registry/materialized/tiptap-node/image-upload-node/image-upload-node.tsx:453-479` + +```tsx +const handleUpload = async (files: File[]) => { + const urls = await uploadFiles(files) + + if (urls.length > 0) { + const pos = props.getPos() + if (isValidPosition(pos)) { + const imageNodes = urls.map((url, index) => ({ + type: extension.options.type, + attrs: { ...extension.options, src: url }, + })) + + props.editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + props.node.nodeSize }) + .insertContentAt(pos, imageNodes) + .run() +``` + +MNote 借鉴点:上传中状态只存在于临时节点;持久正文必须是标准 link/image。 + +### 8.2 VSCode/Sidex:URI + opener service + +`reference-code/sidex-main/src/vs/platform/opener/common/opener.ts:77-119` + +```ts +export interface IOpenerService { + registerOpener(opener: IOpener): IDisposable; + registerValidator(validator: IValidator): IDisposable; + registerExternalUriResolver(resolver: IExternalUriResolver): IDisposable; + open(resource: URI | string, options?: OpenInternalOptions | OpenExternalOptions): Promise; + resolveExternalUri(resource: URI, options?: ResolveExternalUriOptions): Promise; +} +``` + +`reference-code/sidex-main/src/vs/workbench/api/common/extHostEditorTabs.ts:110-128` + +```ts +private _initInput() { + switch (this._dto.input.kind) { + case TabInputKind.TextInput: + return new TextTabInput(URI.revive(this._dto.input.uri)); + case TabInputKind.CustomEditorInput: + return new CustomEditorTabInput(URI.revive(this._dto.input.uri), this._dto.input.viewType); + case TabInputKind.NotebookInput: + return new NotebookEditorTabInput(URI.revive(this._dto.input.uri), this._dto.input.notebookType); +``` + +MNote 借鉴点:资源身份是 URI;打开方式由 opener/editor 决定。 + +### 8.3 Zed:`file://` URI 可 roundtrip + +`reference-code/zed/crates/acp_thread/src/mention.rs:437-486` + +```rust +pub fn to_uri(&self) -> Url { + match self { + MentionUri::File { abs_path } => { + let mut url = Url::parse("file:///").unwrap(); + url.set_path(&abs_path.to_string_lossy()); + url + } + MentionUri::Directory { abs_path } => { + let mut url = Url::parse("file:///").unwrap(); + let mut path = abs_path.to_string_lossy().into_owned(); + if !path.ends_with('/') && !path.ends_with('\\') { + path.push('/'); + } + url.set_path(&path); + url + } +``` + +`reference-code/zed/crates/acp_thread/src/mention.rs:600-608` + +```rust +let file_uri = uri!("file:///path/to/file.rs"); +let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap(); +match &parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, Path::new(path!("/path/to/file.rs"))); + } + _ => panic!("Expected File variant"), +} +assert_eq!(parsed.to_uri().to_string(), file_uri); +``` + +MNote 借鉴点:`file://` 是标准本地绝对引用格式;必须有 parse/serialize 测试。 + +### 8.4 Kode:标准 Markdown link/image 可逆 + +`reference-code/kode/kode-doc/src/parse.rs:472-492` + +```rust +// Image: ![alt](src "title") +'!' if i + 1 < chars.len() && chars[i + 1] == '[' => { + if let Some((alt, src, title, end)) = parse_image_or_link(chars, i + 1) { + out.push(Node::leaf_with_attrs( + NodeType::Image, + image_attrs(&src, &alt, title.as_deref()), + )); + } +} + +// Link: [text](url "title") +'[' => { + if let Some((link_text, href, title, end)) = parse_image_or_link(chars, i) { + let link_mark = Mark::with_attrs(MarkType::Link, link_attrs(&href, title.as_deref())); +``` + +`reference-code/kode/kode-doc/src/serialize.rs:350-368` + +```rust +fn serialize_image(node: &Node, out: &mut String) { + let src = match get_attr(&node.attrs, "src") { + Some(AttrValue::String(s)) => s.as_str(), + _ => "", + }; + out.push_str("!["); + out.push_str(alt); + out.push_str("]("); + out.push_str(src); +``` + +MNote 借鉴点:正文层坚持 Markdown 标准语法,解析层再映射成内部节点。 + +### 8.5 SiYuan:上传返回稳定资源路径 + +`reference-code/siyuan-master/API_zh_CN.md:595-620` + +```json +{ + "code": 0, + "msg": "", + "data": { + "errFiles": [""], + "succMap": { + "foo.png": "assets/foo-20210719092549-9j5y79r.png" + } + } +} +``` + +`reference-code/siyuan-master/kernel/model/upload.go:54-124` + +```go +baseName := filepath.Base(assetAbsPath) +fName := util.FilterUploadFileName(baseName) +ext := filepath.Ext(fName) +ext = strings.ToLower(ext) + +hash, hashErr := util.GetEtagByHandle(f, fi.Size()) +existAssetPath := GetAssetPathByHash(hash) +if "" != existAssetPath && !strings.HasPrefix(hash, "random_") { + succMap[baseName] = strings.TrimPrefix(existAssetPath, "/") +} else { + fName = util.AssetName(fName, ast.NewNodeID()) + writePath := filepath.Join(assetsDirPath, fName) + p := "assets/" + fName + succMap[baseName] = p + cache.SetAssetHash(hash, p) +} +``` + +MNote 借鉴点:上传结果返回稳定 href;文件名净化、冲突命名和去重属于后端职责。 + +## 9. 执行清单 + +### Batch A:现状冻结与 RED 验证 + +- [ ] 复现并记录当前附件行为:第一个上传可打开、第二个上传后重渲染仍可打开、文件树显示附件。 +- [ ] 新增或扩展浏览器 smoke,断言 Markdown 中不能写入 `/office-preview` 作为持久 href。 +- [ ] 截图记录当前卡片、文件树、resource tab、preview 行为。 + +### Batch B:Markdown href 解析合同 + +- [ ] 在 Rust 层新增 `AttachmentRef` 或等价协议类型。 +- [ ] 支持解析 Markdown image/link 的 `rawHref`、label、source range。 +- [ ] 支持相对路径、`file://`、`http(s)`、兼容裸绝对路径。 +- [ ] Rust 单测覆盖路径空格、中文、百分号编码、目录链接、缺失文件。 + +### Batch C:授权与 root 解析 + +- [ ] `file://` 和裸绝对路径必须经过 SQLite control-plane allowed roots 检查。 +- [ ] 相对链接解析时必须基于 owner `.md` 路径和当前 rootUri。 +- [ ] 不同用户授权隔离,未授权路径 projection 标记 `authorized=false`。 +- [ ] Rust 定点测试覆盖已授权、未授权、跨用户隔离。 + +### Batch D:上传写入与 href 返回 + +- [ ] local upload API 接收 owner document path,不再只返回 preview URL。 +- [ ] 默认写入 `.assets/`。 +- [ ] 文件名净化、冲突命名、可选 hash 去重。 +- [ ] 返回 `{ displayName, markdownHref, attachmentRef }`。 +- [ ] 上传失败不能留下 orphan 正文链接;成功后 watcher 能刷新文件树。 + +### Batch E:Tiptap / Markdown 编辑器渲染 + +- [ ] 上传中使用临时 `UploadDraft` / upload NodeView。 +- [ ] 成功后替换为标准 link/image,不保存 preview URL。 +- [ ] 根据 `AttachmentRef.openKind` 渲染图片、PDF、Office、音视频、普通文件卡片。 +- [ ] 卡片 UI 只消费 projection;不把 DOM data 属性当持久真相。 +- [ ] 浏览器截图验证卡片形态、hover/click、保存重开后行为一致。 + +### Batch F:Resource opener 与 preview + +- [ ] 新增或收口 `openAttachment(ref)`。 +- [ ] PDF/Office/image/text/download/system external 分发到 `OpenTarget`。 +- [ ] resource tab watch 与附件 ref 绑定,避免第二个附件打开后第一个失效。 +- [ ] 缺失和未授权路径显示明确阻断状态。 + +### Batch G:File Tree / watcher / missing asset + +- [ ] 文件树从附件 projection 显示 page-local assets 目录和文件。 +- [ ] 上传后 watcher 刷新文件树,不依赖手动刷新。 +- [ ] 删除/移动附件后正文卡片显示 missing,不崩溃。 +- [ ] 外部新增同目录附件后,手写相对链接可解析并打开。 + +### Batch H:兼容迁移 + +- [ ] 兼容旧 Markdown 中已有 `/office-preview?...path=...` href,读取时转换为 `AttachmentRef`,保存时迁移为标准 href。 +- [ ] 兼容现有 `data-mnote-attachment-link` DOM 增强路径,但不作为主合同。 +- [ ] 补设计说明:旧路径只做读兼容,不再写入。 + +### Batch I:验证矩阵 + +- [ ] Rust:AttachmentRef parse/resolve/auth/open target 单测。 +- [ ] JS:Tiptap upload replacement、card renderer、resource opener `node --check` 与定点 smoke。 +- [ ] Browser:真实上传 pptx/pdf/docx/png 两个以上文件,保存重开后逐个点击可预览。 +- [ ] Browser:VSCode/Sidex 兼容表达验证,Markdown 原文为标准 link/image。 +- [ ] Browser:未授权 `file://` 阻断、授权后可打开。 +- [ ] `git diff --check`。 +- [ ] 涉及代码图修改后运行 `codegraph sync .`。 + +## 10. 验收标准 + +- 任意时刻上传附件,正文只产生标准 Markdown href,不产生运行时 preview URL。 +- 第一个、第二个、第三个附件保存重渲染后都能点击并打开。 +- 文件树能显示 page-local 附件,且 watcher 自动刷新。 +- 复制整个本地文件夹到新位置后,相对附件仍可解析。 +- `file://` 外部引用在授权 root 内可打开,未授权时阻断。 +- Tiptap 卡片、resource tab、preview opener 都消费同一 `AttachmentRef` projection。