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

455 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 标准语法:
```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 预览。
- 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`
```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/SidexURI + 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 BMarkdown 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 ETiptap / Markdown 编辑器渲染
- [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
- [x] 新增或收口 `openAttachment(ref)`
- [x] PDF/Office/image/text/download/system external 分发到 `OpenTarget`
- [x] resource tab watch 与附件 ref 绑定,避免第二个附件打开后第一个失效。
- [x] 缺失和未授权路径显示明确阻断状态。
### Batch GFile 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] 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. 执行证据(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。