Obsidian 同步机制
父节点:corpus · 子节点:protocol · vault-ingestion · rendering-and-extensibility · 状态:双向都已建成 —— 完整的
SyncVault(raw + writing + wiki/subjectivity +.obsidianCSS);反向导出自 2026-07-29 起把 corp 笔记按 genre 文件夹 + 树 + folder-note 写回(export_corpus.go,9f1ffcf13)
StandMeet 与 Obsidian vault 之间的双向同步。形态类似 Quartz / obsidian-importer:两个按钮,各自对应一个批处理任务,由所有者手动触发——没有文件监听,没有实时同步。
Endpoints(backend/internal/routes/admin/obsidian.go:152)
GET /api/admin/obsidian/export→ 一个 zip(writings/<slug>.md+attachments/<id>.<ext>,再加wiki/…·subjectivity/…·output/…按 genre 文件夹 + 树);每条笔记被渲染为 frontmatter + 正文POST /api/admin/obsidian/import→ 整个 vault 的 multipart 上传(浏览器webkitdirectory,200 MB 上限);每个文件都带着它在 vault 中的相对路径GET /api/admin/obsidian/state→ 上一次导入是什么时候(obsidian_state.go,6cf4e2b08,2026-08-21)——在此之前这个页面完全没有"过去时"
同步流水线(backend/internal/corpus/obsidian/sync.go · SyncVault)
导入是对上传的 vault 做的一次批处理。classifyVault(sync_classify.go:65)按顶层文件夹把文件分成三个桶——corp、writing、css(隐藏路径和 _templates 被过滤,除了会被采集的 .obsidian CSS)。raw/ 不再是独立的桶:自 d925d9081(2026-07-09)起它折进 corp,作为 genre='raw',免 frontmatter(整个文件就是正文),走同一个节点树物化器。corp 桶还会额外跑 tree → 歧义检查 → reconcile → link-resolution → prune 这几个阶段。下面这张流程图是权威总览;后面的文字逐阶段展开细节。
反方向(导出)是独立的一次批处理——有子节点的节点会被拍平回
foo/foo.md+ 子节点放在foo/下(export_corpus.go);writings 则扁平地写成writings/<slug>.md;见 writings-import-export。
Writing 分支的导入细节(backend/internal/corpus/obsidian/import.go)
- 把
.md和非.md分开;附件按 basename 建立索引(这与 Obsidian 解析[[image.png]]的方式一致——不管子文件夹是什么,只按 basename 匹配) - 对每个
.md:- 解析 frontmatter;
publish != true→ 直接跳过(import.go:146——writings 分支仍然是摄入闸门;corp 分支不是,见下面的不变量) - 正文中的图片引用 → 在附件索引里查字节 → 铸造一个
pending-<uuid>,把正文里的引用改写为standmeet-asset:pending-<uuid> - 先按
obsidian_source_path、再按slug查找已存在的 writing(upsertFromVault,import.go:252/:279)→ 更新;没有 → 创建。已经没有 web-wins 守卫了(84080bac5,2026-07-15,F-L-6):vault 是唯一的活数据源;网页端的修改只有在下次同步前导出回 vault 才能保住 SetObsidianMeta打上imported_at时间戳
- 解析 frontmatter;
- 返回
{ created, updated, skipped, errors }(writings 永远不删)
Folder notes(约定 A)——必须被特殊识别
vault 里,一个有子节点的节点被表示为一个文件夹 foo/,它自己的内容放在 foo/foo.md(folder note;由一个 pre-commit hook 强制执行)。同步逻辑必须对此特殊处理,让 folder note 映射到节点本身,而不是变成一个重复的子节点:
- 规则: 如果
basename(file, ".md") == basename(dirname(file)),这个文件就是一个 folder note → 它的节点路径 = 目录路径(a/b/foo),而不是朴素的文件系统推导结果a/b/foo/foo。 - 所以
market/market.md⇒market节点(路径…/market);同级的market/two-scarcities-arbitrage.md⇒market的一个子节点(parent_id → market)。每个文件夹一个节点,不会出现重复的foo/fooslug。 - 导出时反过来:有子节点的节点会被写成
foo/foo.md+ 子节点放在foo/下。
在 corp 路径上已实现
buildDesiredTree(sync_tree.go)为 wiki/subjectivity 同步折叠了 folder notes——market/market.md变成market节点,同级文件变成它的子节点(parent_id),不会重复出现foo/foo。仍然悬而未决的是:writings分支目前把 slug 直接推导为basename(path)(pickSlug,见 protocol),还没有折叠 folder notes。
关键不变量
publish是可见性闸门,不是摄入闸门(F-L-8,84080bac5,2026-07-15;sync.go:13):每个被路由到的 corp.md都会落库;publish只喂给数据库的published= 匿名访客可见 / 进 sitemap;published=false的意思是"需要码",不是"不存在"。frontmatter 里没写publish时保留现值——沉默不是否定(F-L-22,8bb2e1c00,2026-08-08;keepPublish,sync.go:229)。只有 writings 分支仍然对publish != true直接跳过- vault 是唯一的活数据源(F-L-6,取代"网页端编辑优先"):同步让目的地等于源,不比时间戳;整库(authoritative)同步会剪掉 vault 里已经没有的、由 vault 导入的笔记(
sync_prune.go),部分上传永远不删 - 正文里的
[[slug]]/[[Title]]→ 边表:链接关系来自正文,而不是 frontmatter——corp 笔记写入note_refs(按标题和 frontmatter 的aliases解析),writings 写入writing_refs - 哪些字段来自模板、哪些来自别处 → 见 protocol