# 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)
[页面附件](./model.pptx)
![图片](./figure.png)
[外部本地文件](file:///mnt/Data1T/research/shared/reference.pdf)
[网页资料](https://example.com/paper.pdf)
```

支持输入：

- `./a.pdf`：相对当前 `.md` 所在目录解析。
- `file:///mnt/Data1T/a.pdf`：按标准 file URI 解析，必须经过授权 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/model-<stable-id>.pptx
正文插入：[model.pptx](./model-<stable-id>.pptx)
```

说明：

- 相对链接不是不唯一；内核解析时用当前 `.md` 路径补全，得到唯一绝对路径。
- 正文优先保存相对路径，是为了移动文件夹、同步、分享、VSCode/Sidex/Markdown 工具打开时保持可用。
- 绝对路径作为外部引用保留；上传后的持久格式仍固定为当前 `.md` 同目录相对链接。
- 默认上传目录维持当前 MNote 行为：与当前 `.md` 同目录。`<mdStem>.assets/`、`assets/` 或用户自定义资源目录只作为未来可选整理策略，不作为本清单默认行为。

## 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` 路径计算同目录上传目标。
4. 后端写入文件，进行文件名净化、冲突命名、可选 hash 去重。
5. API 返回稳定 Markdown href，如 `./model-<id>.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<ImageUploadNodeOptions>({
  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<boolean>;
  resolveExternalUri(resource: URI, options?: ResolveExternalUriOptions): Promise<IResolvedExternalUri>;
}
```

`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 验证

- [x] 复现并记录当前附件行为：第一个上传可打开、第二个上传后重渲染仍可打开、文件树显示附件。
- [x] 新增或扩展浏览器 smoke，断言 Markdown 中不能写入 `/office-preview` 作为持久 href。
- [x] 截图记录当前卡片、文件树、resource tab、preview 行为。

### Batch B：Markdown href 解析合同

- [x] 在 Rust 层新增 `AttachmentRef` 或等价协议类型。
- [x] 支持解析 Markdown image/link 的 `rawHref`、label、source range。
- [x] 支持同目录相对路径、`file://`、`http(s)`、兼容裸绝对路径；不兼容 `../assets/a.pdf`。
- [x] Rust 单测覆盖路径空格、中文、百分号编码、目录链接、缺失文件。

### Batch C：授权与 root 解析

- [x] `file://` 和裸绝对路径必须经过 SQLite control-plane allowed roots 检查。
- [x] 相对链接解析时必须基于 owner `.md` 路径和当前 rootUri。
- [x] 不同用户授权隔离，未授权路径 projection 标记 `authorized=false`。
- [x] Rust 定点测试覆盖已授权、未授权、跨用户隔离。

### Batch D：上传写入与 href 返回

- [x] local upload API 接收 owner document path，不再只返回 preview URL。
- [x] 默认写入当前 `.md` 所在同目录。
- [x] 文件名净化、冲突命名、可选 hash 去重。
- [x] 返回 `{ displayName, markdownHref, attachmentRef }`。
- [x] 上传失败不能留下 orphan 正文链接；成功后 watcher 能刷新文件树。

### Batch E：Tiptap / Markdown 编辑器渲染

- [x] 上传中使用临时 `UploadDraft` / upload NodeView。
- [x] 成功后替换为标准 link/image，不保存 preview URL。
- [x] 根据 `AttachmentRef.openKind` 渲染图片、PDF、Office、音视频、普通文件卡片。
- [x] 卡片 UI 只消费 projection；不把 DOM data 属性当持久真相。
- [x] 浏览器截图验证卡片形态、hover/click、保存重开后行为一致。

### Batch F：Resource opener 与 preview

- [x] 新增或收口 `openAttachment(ref)`。
- [x] PDF/Office/image/text/download/system external 分发到 `OpenTarget`。
- [x] resource tab watch 与附件 ref 绑定，避免第二个附件打开后第一个失效。
- [x] 缺失和未授权路径显示明确阻断状态。

### Batch G：File Tree / watcher / missing asset

- [x] 文件树从附件 projection 显示当前 `.md` 同目录上传的附件文件。
- [x] 上传后 watcher 刷新文件树，不依赖手动刷新。
- [x] 删除/移动附件后正文卡片显示 missing，不崩溃。
- [x] 外部新增同目录附件后，手写相对链接可解析并打开。

### Batch H：开发态清理与非兼容边界（非迁移）

- [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：验证矩阵

- [x] Rust：AttachmentRef parse/resolve/auth/open target 单测。
- [x] JS：Tiptap upload replacement、card renderer、resource opener `node --check` 与定点 smoke。
- [x] Browser：真实上传 pptx/pdf/docx/png 两个以上文件，保存重开后逐个点击可预览。
- [x] Browser：VSCode/Sidex 兼容表达验证，Markdown 原文为标准 link/image。
- [x] Browser：未授权 `file://` 阻断、授权后可打开。
- [x] `git diff --check`。
- [x] 涉及代码图修改后运行 `codegraph sync .`。

## 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。
- 第一个、第二个、第三个附件保存重渲染后都能点击并打开。
- 文件树能显示当前 `.md` 同目录上传的附件，且 watcher 自动刷新。
- 复制整个本地文件夹到新位置后，相对附件仍可解析。
- `file://` 外部引用在授权 root 内可打开，未授权时阻断。
- Tiptap 卡片、resource tab、preview opener 都消费同一 `AttachmentRef` projection。
