Writings 与 vault:Obsidian 导入导出子系统
目前(已落地): 手动的、由所有者触发的批量架构。GET /api/admin/obsidian/export 流式输出一个 standmeet-vault.zip(不落临时文件);POST /api/admin/obsidian/import 接受整个 vault 的 multipart 上传(浏览器端 webkitdirectory,200 MB 上限——backend/internal/routes/admin/obsidian.go:149),返回一个 {created, updated, skipped, errors} 的汇总;GET /api/admin/obsidian/state 报告上一次导入是什么时候(6cf4e2b08,2026-08-21)。本页讲的是这条管线的 writings 分支——writings 在寻址上(扁平、按 slug)和链接器上(writing_refs)仍然是与 corpus 各层分开的一块(confusables),即便自 bfb8c7129(2026-07-09)起它们已是同一张 corpus_notes 表里 genre='writing' 的行。wiki / subjectivity / raw / output 走的是 SyncVault 和 export_corpus.go——见 obsidian-sync-mechanism。
Zip 布局与 frontmatter 契约
导出写出两个目录:
writings/<slug>.md——每篇 writing 表示为 frontmatter + 正文attachments/<asset-id>.<ext>——按存储 key 去重;扩展名从 content-type 头猜测
Frontmatter 字段(vault 到 writing 的 schema):
title、slug、excerpt、tags、aliases——元数据created、publish——时间戳与导入闸门cover_hue、cover_headline、cover_image——封面元数据visibility、locked_body——权限与锁定
导入闸门: publish: true 是进入数据库的唯一路径。其余一律被静默计入 skipped。这是一个刻意设计的边界:vault 是所有者的草稿或存档空间;只有明确标记为已发布的 frontmatter 才算数。
幂等性——以及那道已被拿掉的 web 编辑保护
幂等性键是 obsidian_source_path——.md 文件相对于 vault 的路径——以 slug 作为退路(upsertFromVault,backend/internal/corpus/obsidian/import.go:252 / :279),所以 frontmatter slug 不变的文件搬了位置也能对上原来那一行。
重新导入流程:
- 路径(或 slug)在数据库中已存在 → UPDATE 该行,用 vault 内容覆盖
- 两者都不存在 → CREATE 新行
web 编辑保护(updated_at > obsidian_imported_at + 1s → SKIP)已于 2026-07-15 拿掉(84080bac5,F-L-6):vault 是唯一的活数据源,同步让目的地等于源,不存在"谁赢"(import.go:14)。网页端的修改只有在下次同步前导出回 vault 才能保住。SetObsidianMeta 保存后仍会把 obsidian_imported_at 打上 now 的戳,但那是来源记录,不再是守卫。
flowchart TB
F["incoming .md<br/>publish: true"] --> L{"row with same<br/>obsidian_source_path,<br/>else same slug?"}
L -- no --> CR["CREATE"]
L -- yes --> UP["UPDATE<br/>overwrite from vault<br/>(no web-wins since 84080bac5)"]
CR --> ST["stamp imported_at"]
UP --> ST
附件引用的往返转换
Obsidian 和 StandMeet 说的是两种不同的引用语言。系统必须在两个方向上都做到无损(或至少可预测)的桥接。
导出(writings → Obsidian):
- 正文中含有
standmeet-asset:uuid令牌 - 导出时改写为
attachments/id.ext
导入(Obsidian → writings):
- 传入正文里有
![[img.png]](图片嵌入)或(markdown 图片链接) - 基名解析: 仅按文件名(例如
img.png)去匹配已上传的附件,与文件夹嵌套结构无关。这遵循的是 Obsidian 自身对跨 vault 文件移动的语义。 - 待定的 UUID 占位符 →
standmeet-asset:uuid令牌(与SaveWriting原子地一起完成) - 外部
http(s)://引用以及未匹配上的引用原样保留,不报错
flowchart LR
subgraph EXPORT
A1["body: standmeet-asset:uuid"] --> A2["rewrite → attachments/id.ext"]
end
subgraph IMPORT
B1["body: ![[img.png]]<br/>"] --> B2["resolve by BASENAME<br/>against uploaded attachments"]
B2 --> B3["pending-uuid → standmeet-asset:uuid<br/>atomic with SaveWriting"]
end
Backlinks 分两遍收敛——不动点迭代
一个针对前向引用的"写一次就完事"陷阱:导入时的 CrossRefs 输入被完全忽略。取而代之的是:
- 用正文调用
SaveWriting - 在 SaveWriting 内部,
refreshCrossLinks从已保存的正文中重新解析字面的[[X]] - 它通过只对照数据库中已经存在的 writings 来解析链接,从而重建
writing_refs
后果: 一条指向尚未导入的 writing 的前向链接,在第一遍导入中会保持未解析,只有在两者都创建之后的第二次导入中才会连上。系统实际上是在把 refs = F(refs) 迭代到一个不动点——一个确定性的、可重复运行的操作,会随着导入的 writings 越来越多而收敛。
sequenceDiagram participant V as vault upload participant S as SaveWriting participant R as writing_refs V->>S: import A (body has [[B]]) S->>R: rebuild refs for A — B not found, link dangles V->>S: import B S->>R: rebuild refs for B Note over R: A→B STILL missing;<br/>fixpoint not reached V->>S: re-import A (pass 2) S->>R: rebuild refs for A — B now resolves Note over R: fixpoint reached
要把"保证了什么"和"没保证什么"说清楚:每个文件都是幂等的(同一个文件 → 同一行,不会重复),但引用图不是单遍收敛的——把同一个 ZIP 再导入一次,会补上第一遍没能解析的前向链接。它是在极限意义上确定的(不动点),而不是每次运行都确定的;"导入两遍"这个瑕疵,正是这一点在用户面前的直接体现。
类视图——子系统的活动部件
classDiagram
class ExportDeps {
Writings *corpus.WritingRepo
Assets *corpus.AssetRepo
Storage *storage.Client
Corpus *corpus.VaultSyncRepo - corp notes, optional
}
class WriteZip {
<<func>>
takes ctx, ExportDeps, ownerID, io.Writer
streams standmeet-vault.zip - no temp file
rewrites standmeet-asset refs outbound
then writeCorpusNotes when Corpus is set
}
class ImportVault {
<<func>>
takes ctx, WritingsTxDeps, MetaSetter, ownerID, []VaultFile
returns ImportResult - created, updated, skipped, errors
publish gate + source-path-or-slug upsert, no web-edit guard
}
class SaveWriting {
<<func>>
takes ctx, WritingsTxDeps, *SaveWritingInput
returns (domain.Writing, error)
atomic with asset token swap
}
class refreshCrossLinks {
<<func>>
takes ctx, deps, pgx.Tx, *domain.Writing
re-parses literal wikilinks from the saved body
resolves against EXISTING rows only
rebuilds writing_refs - the fixpoint step
}
WriteZip ..> ExportDeps
ImportVault --> SaveWriting : per accepted file
SaveWriting --> refreshCrossLinks : inside the tx
已知缺口
对当前局限的坦诚清单:
-
文件夹层级丢失——现在只剩 writings: 导出会把嵌套的
writings/文件夹拍平进单一的writings/目录,pickSlug也只从裸 basename 推导 writing 的 slug。自9f1ffcf13(2026-07-29)起,wiki/·subjectivity/·output/按 genre 文件夹 + 树 + folder-note 导出(export_corpus.go),能完整往返;只有 writings 分支仍然不是文件夹对称的。 -
重命名会产生孤儿行——只在 slug 也跟着变时: 身份是
obsidian_source_path或slug(import.go:252/:279;由e2e/test/corpus-sync-rename.spec.ts钉住,82dd221aa,2026-07-05)。frontmatter slug 不变的文件,搬动或改名都会更新原来那一行。改名且 slug 也变了(没写 frontmatter slug 时文件名就是 slug)才会新建一行,而旧行会留下来:writings 分支永远不删(import.go:65),不像 corp 分支有整库剪除。 -
流中途的错误不会被暴露: 导出以不缓冲的方式流式输出
standmeet-vault.zip。如果在流的中途发生错误,客户端收到的是一个被截断的 zip,且没有任何错误提示。 -
扩展名猜测是有损的: 通过嗅探 content-type 头来猜测文件扩展名。有些 MIME 类型对应多个扩展名,系统会挑一个(例如
.jpeg还是.jpg),重新导入时可能对不上原始扩展名。 -
cover_image不会内联渲染:cover_image这个 frontmatter 字段是以纯 YAML 形式往返的。Obsidian 不会把它渲染成内联图片预览,使封面体验在 vault 端和 web 端之间不对称。 -
Wiki 和 output 两层尚未接入——已上线:导入走SyncVault(backend/internal/corpus/obsidian/sync.go:88),导出走export_corpus.go(9f1ffcf13,2026-07-29);见 obsidian-sync-mechanism。
两条提案都已落地 —— 但都不是这里写的那个形状
链接器式两遍 → 整批解析
当初提的是拆成两个先后阶段(先存正文,再重建 refs)。实际落地的更简单:resolveLinks 在所有 upsert 之后跑一次,对着 owner 全域的标题索引解析,于是前向链接在一次导入里就全解开了。"导入两遍"那个瑕疵没了,而那本来就是目的;"两遍"这个说法,事后看是那个目的的一个实现细节,不是目的本身。
稳定身份 → 以标题为键,而重命名故意产生孤儿
当初提的是导出时把 id 写进 frontmatter,重新导入按 id > source_path > slug 解析。落地的不是这个,而且这个差别是一个决定,不是一处欠账。 身份以标题(= 文件名 basename)为键,owner 范围内、跨体裁通用 —— vault 自己的 check-links.sh 已经保证 basename 在整个 vault 内唯一,所以vault 的约束本身就是那个键。
两个后果都是刻意的:移动会让同一行搬家(包括跨体裁 —— raw/x.md → wiki/x.md 是同一条笔记,符号学那次"搬运"能成立正是因为这一点);重命名会产生孤儿,因为 vault 那侧的 normalize-names 本来就会重指所有 wikilink,所以"重命名"这件事的主人是 vault。obsidian_source_path / imported_at 留着是做来源记录(它们曾经服务的 web-wins 守卫已经没了,84080bac5);只有当一个 basename 在整个 corpus 里重复时,source_path 才顶上来当身份——同名文件各自保住自己那一行(F-L-2 / F-L-61,sync.go:4-7、sync_ambiguity.go)。
往 owner 自己的 markdown 里写一个 id,被否掉的理由跟通常一样:那是把数据库的账,记进了一个人手写的文件里。
交叉引用
- obsidian-sync-mechanism——这就是它"writings 已接入"那部分的当前实现;它经由 connector-plugins 以同步模式连接器的身份在跑(
backend/internal/connector/obsidian.go) - backlinks-as-rebuilt-edge-tables——backlinks 在写入时重建的那些
writing_refs表 - corpus——包裹 writings、wiki、output 三层的上级层