Files
mnote/design/05-editor-mainline/done/5-34-local-markdown-attachment-ref-contract-v1.md

22 KiB
Raw Permalink Blame History

5-34 [process] 本地 Markdown 附件引用合同与执行清单 v1

创建时间:2026-05-28

Owner05-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 标准语法:

[同目录 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:// 或裸绝对路径输入。

默认上传插入规则:

当前文档:/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 中解析出统一引用:

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。

type OpenTarget = {
  targetKind: "resourceTab" | "browserPreview" | "systemExternal" | "download" | "blocked";
  refId: string;
  previewUrl?: string;
  absolutePath?: string;
  reason?: string;
};

分发规则:

  • image:内嵌图片或资源 tab 预览。
  • pdfPDF preview / resource tab。
  • pptx/docx/xlsxOffice 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。
  • Zedagent 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

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

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/SidexURI + opener service

reference-code/sidex-main/src/vs/platform/opener/common/opener.ts:77-119

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

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 Zedfile:// URI 可 roundtrip

reference-code/zed/crates/acp_thread/src/mention.rs:437-486

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

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

// 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

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

{
  "code": 0,
  "msg": "",
  "data": {
    "errFiles": [""],
    "succMap": {
      "foo.png": "assets/foo-20210719092549-9j5y79r.png"
    }
  }
}

reference-code/siyuan-master/kernel/model/upload.go:54-124

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 BMarkdown href 解析合同

  • 在 Rust 层新增 AttachmentRef 或等价协议类型。
  • 支持解析 Markdown image/link 的 rawHref、label、source range。
  • 支持同目录相对路径、file://http(s)、兼容裸绝对路径;不兼容 ../assets/a.pdf
  • 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。
  • 默认写入当前 .md 所在同目录。
  • 文件名净化、冲突命名、可选 hash 去重。
  • 返回 { displayName, markdownHref, attachmentRef }
  • 上传失败不能留下 orphan 正文链接;成功后 watcher 能刷新文件树。

Batch ETiptap / Markdown 编辑器渲染

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

Batch FResource opener 与 preview

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

Batch GFile Tree / watcher / missing asset

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

Batch H:开发态清理与非兼容边界(非迁移)

  • 确认新写入 Markdown 不再产生 /office-preview?.../api/local-folder/files/open 或 DOM 临时属性依赖。
  • 开发态不做旧 /office-preview?... href 历史迁移,也不做读取旧 href 后自动重写保存的兼容逻辑;发现旧数据时按当前开发数据重建或手工修正处理。
  • 不兼容 ../assets/a.pdf 上级目录相对路径;不解析、不生成、不迁移这类引用,跨目录引用走授权 file:// / 绝对路径。
  • 补设计说明:旧路径和 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 .

10. 执行证据(2026-05-28

  • Rustcargo test -p mnote-web markdown_attachment_refs --manifest-path rust/Cargo.toml 通过,覆盖标准 link/image、file://、http(s)、裸绝对路径、中文/空格/百分号、缺失文件、../assets unknown。
  • Rustcargo test -p mnote-web local_markdown_asset_upload_copies_next_to_markdown_with_relative_path --manifest-path rust/Cargo.toml 通过,确认上传写入当前 .md 同目录。
  • Rustcargo test -p mnote-web local_page_aggregate_marks_attachment_refs_authorization_from_sqlite_grants --manifest-path rust/Cargo.toml 通过,覆盖 SQLite allowed roots 与跨用户隔离。
  • Rustcargo test -p mnote-web local_markdown_save_does_not_migrate_runtime_open_url_inline_link --manifest-path rust/Cargo.tomlcargo test -p mnote-web local_markdown_save_does_not_migrate_relative_runtime_open_url --manifest-path rust/Cargo.toml 通过,确认开发态不做旧 runtime URL 自动迁移。
  • JSnode --check 通过 sidebar-attachment-open-runtime.jslocal-upload-runtime.jssidebar-tree-runtime.jsdocument-tiptap-conversion-runtime.jsdocument-editor-adapter-runtime.jstask506-local-markdown-attachment-ref-matrix-smoke.js
  • BrowserMNOTE_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
  • BrowserMNOTE_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 失败才退回父文件夹。
  • Browsernode scripts/task494-filetree-lazy-loading-dedup-smoke.js 通过,覆盖新建页面后 bundle 展开,内部 .md 行 selected/focused/active。
  • BrowserMNOTE_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
  • JSnode --check 通过 sidebar-attachment-open-runtime.jssidebar-filetree-command-runtime.jssidebar-page-tree-runtime.jstask494-filetree-lazy-loading-dedup-smoke.jstask506-local-markdown-attachment-ref-matrix-smoke.js
  • Rustcargo 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。