455 lines
22 KiB
Markdown
455 lines
22 KiB
Markdown
# 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)
|
||

|
||
[外部本地文件](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:正文层保留标准 `` / `[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: 
|
||
'!' 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(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。
|