2026-09-23·by Sijie Wang#node#standmeet#obsidian#sync

obsidian-sync-mechanism

Obsidian 同步机制

父节点:corpus · 子节点:protocol · vault-ingestion · rendering-and-extensibility · 状态:双向都已建成 —— 完整的 SyncVault(raw + writing + wiki/subjectivity + .obsidian CSS);反向导出自 2026-07-29 起把 corp 笔记按 genre 文件夹 + 树 + folder-note 写回(export_corpus.go9f1ffcf13

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.go6cf4e2b08,2026-08-21)——在此之前这个页面完全没有"过去时"

同步流水线(backend/internal/corpus/obsidian/sync.go · SyncVault

导入是对上传的 vault 做的一次批处理classifyVaultsync_classify.go:65)按顶层文件夹把文件分成三个桶——corpwritingcss(隐藏路径和 _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

  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(upsertFromVaultimport.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.mdmarket 节点(路径 …/market);同级的 market/two-scarcities-arbitrage.mdmarket 的一个子节点parent_id → market)。每个文件夹一个节点,不会出现重复的 foo/foo slug。
  • 导出时反过来:有子节点的节点会被写成 foo/foo.md + 子节点放在 foo/ 下。
在 corp 路径上已实现

buildDesiredTreesync_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;keepPublishsync.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
about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →