Obsidian 同步机制

更新于 · 在 sijie.xyz 查看原条目 ↗

父节点:corpus · 子节点:protocol · vault-ingestion · rendering-and-extensibility · 状态:双向都已建成 —— 完整的 SyncVault(raw + writing + wiki/subjectivity + .obsidian CSS);反向导出自 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 这几个阶段。下面这张流程图是权威总览;后面的文字逐阶段展开细节。

flowchart TD
  U["POST /obsidian/import<br/>whole vault (webkitdirectory);<br/>each file carries its vault-relative path"] --> C["classifyVault(files)<br/>hidden + _templates filtered (except .obsidian CSS)<br/>top folder → bucket"]
  C -->|"writing/ (or writings/)"| W["syncWritings → Writings.ImportWritings<br/>.md + attachments · publish-gated · writing_refs"]
  C -->|".obsidian snippets + appearance.json"| S["syncCSS<br/>harvest owner CSS config"]
  C -->|"genre = wiki / subjectivity / raw"| T["corp bucket · vaultNote list<br/>parseCorpNote: tolerant frontmatter<br/>(raw: fm-exempt, whole file = body)"]
  T --> BT["buildDesiredTree(corp)<br/>folder nesting → node tree (parent_id)<br/>folder-note collapse: foo/foo.md ⇒ the foo node"]
  BT --> AM["ambiguousTitles (sync_ambiguity.go)<br/>ask the CORPUS which basenames are duplicated;<br/>unanswerable → abort the whole batch"]
  AM --> RN["reconcileNode per node (parent-first)<br/>claim by title (cross-genre, supports move);<br/>duplicated title → claim by source_path<br/>unchanged → skip · else create / update<br/>record id → child parenting + link index"]
  RN --> RL["resolveLinks(tree)<br/>whole-batch: owner title + aliases index → note_refs edges<br/>(forward links after all nodes exist;<br/>backlinks = reverse query over note_refs)"]
  RL --> PR["pruneAbsent (sync_prune.go)<br/>authoritative mode: delete vault-imported notes<br/>absent from this batch · partial mode: never delete"]
  PR --> RES["ImportResult { created, updated, skipped, deleted, errors }"]

反方向(导出)是独立的一次批处理——有子节点的节点会被拍平回 foo/foo.md + 子节点放在 foo/ 下(export_corpus.go);writings 则扁平地写成 writings/<slug>.md;见 writings-import-export。

Writing 分支的导入细节(backend/internal/corpus/obsidian/import.go)

  1. 把 .md 和非 .md 分开;附件按 basename 建立索引(这与 Obsidian 解析 [[image.png]] 的方式一致——不管子文件夹是什么,只按 basename 匹配)
  2. 对每个 .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 时间戳
  3. 返回 { 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/foo slug。
  • 导出时反过来:有子节点的节点会被写成 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

相关笔记