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

426 lines
17 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)
[页面附件](./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-<stable-id>.pptx
正文插入:[model.pptx](./dha.assets/model-<stable-id>.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 预览。
- 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` 路径计算 page-local assets 目录。
4. 后端写入文件,进行文件名净化、冲突命名、可选 hash 去重。
5. API 返回稳定 Markdown href,如 `./dha.assets/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 验证
- [ ] 复现并记录当前附件行为:第一个上传可打开、第二个上传后重渲染仍可打开、文件树显示附件。
- [ ] 新增或扩展浏览器 smoke,断言 Markdown 中不能写入 `/office-preview` 作为持久 href。
- [ ] 截图记录当前卡片、文件树、resource tab、preview 行为。
### Batch BMarkdown 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。
- [ ] 默认写入 `<mdStem>.assets/`
- [ ] 文件名净化、冲突命名、可选 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 显示 page-local assets 目录和文件。
- [ ] 上传后 watcher 刷新文件树,不依赖手动刷新。
- [ ] 删除/移动附件后正文卡片显示 missing,不崩溃。
- [ ] 外部新增同目录附件后,手写相对链接可解析并打开。
### Batch H:兼容迁移
- [ ] 兼容旧 Markdown 中已有 `/office-preview?...path=...` href,读取时转换为 `AttachmentRef`,保存时迁移为标准 href。
- [ ] 兼容现有 `data-mnote-attachment-link` DOM 增强路径,但不作为主合同。
- [ ] 补设计说明:旧路径只做读兼容,不再写入。
### 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. 验收标准
- 任意时刻上传附件,正文只产生标准 Markdown href,不产生运行时 preview URL。
- 第一个、第二个、第三个附件保存重渲染后都能点击并打开。
- 文件树能显示 page-local 附件,且 watcher 自动刷新。
- 复制整个本地文件夹到新位置后,相对附件仍可解析。
- `file://` 外部引用在授权 root 内可打开,未授权时阻断。
- Tiptap 卡片、resource tab、preview opener 都消费同一 `AttachmentRef` projection。