Writings 与 vault:Obsidian 导入导出子系统

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

目前(已落地): 手动的、由所有者触发的批量架构。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]](图片嵌入)或 ![alt](path)(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/>![alt](path)"] --> B2["resolve by BASENAME<br/>against uploaded attachments"]
    B2 --> B3["pending-uuid → standmeet-asset:uuid<br/>atomic with SaveWriting"]
  end

一个针对前向引用的"写一次就完事"陷阱:导入时的 CrossRefs 输入被完全忽略。取而代之的是:

  1. 用正文调用 SaveWriting
  2. 在 SaveWriting 内部,refreshCrossLinks 从已保存的正文中重新解析字面的 [[X]]
  3. 它通过只对照数据库中已经存在的 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

已知缺口

对当前局限的坦诚清单:

  1. 文件夹层级丢失——现在只剩 writings: 导出会把嵌套的 writings/ 文件夹拍平进单一的 writings/ 目录,pickSlug 也只从裸 basename 推导 writing 的 slug。自 9f1ffcf13(2026-07-29)起,wiki/ · subjectivity/ · output/ 按 genre 文件夹 + 树 + folder-note 导出(export_corpus.go),能完整往返;只有 writings 分支仍然不是文件夹对称的。

  2. 重命名会产生孤儿行——只在 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 分支有整库剪除。

  3. 流中途的错误不会被暴露: 导出以不缓冲的方式流式输出 standmeet-vault.zip。如果在流的中途发生错误,客户端收到的是一个被截断的 zip,且没有任何错误提示。

  4. 扩展名猜测是有损的: 通过嗅探 content-type 头来猜测文件扩展名。有些 MIME 类型对应多个扩展名,系统会挑一个(例如 .jpeg 还是 .jpg),重新导入时可能对不上原始扩展名。

  5. cover_image 不会内联渲染: cover_image 这个 frontmatter 字段是以纯 YAML 形式往返的。Obsidian 不会把它渲染成内联图片预览,使封面体验在 vault 端和 web 端之间不对称。

  6. 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,被否掉的理由跟通常一样:那是把数据库的账,记进了一个人手写的文件里。

交叉引用

相关笔记