Tolaria 类型文档创建策略:ADR-0096「根目录创建类型文档」及其源码实现剖析
【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria
Tolaria 用 Markdown frontmatter(type: Type)而非文件位置来定义"类型",但早期的 UI 创建流程仍默认把新类型文档写进type/目录。ADR-0096 决定了新的创建策略:UI 创建的类型文档一律落在 vault 根目录({vault}/{slug}.md),已有类型文档保持原位有效。读完本文,你会理解这一决策如何与 Tolaria 的元数据优先模型对齐、创建流程在源码中如何以"三态计划"保证碰撞安全,以及旧 vault 的type/、types/目录为什么无需迁移即可继续工作。
背景:类型身份来自 frontmatter,而不是文件夹
Tolaria 的类型系统有一条根本原则:类型定义(type document)就是 frontmatter 中带type: Type的普通 Markdown 笔记,与它存放在哪个文件夹无关。官方概念文档 Types 明确写道:"Tolaria does not infer type from folder location. Moving a file into another folder does not change its type."(Tolaria 不会根据文件夹位置推断类型,移动文件不会改变其类型)。
一个标准的类型文档长这样(节选自 site/concepts/types.md):
--- type: Type _icon: folder _color: blue _sidebar_label: Projects _order: 10 --- # Project其中type: Type是"这是一份类型定义"的标记;_icon、_color、_sidebar_label、_order这些下划线前缀的系统属性控制侧边栏分组、图标、颜色、排序。类型文档还可以携带template字段或直接在# TypeName标题之后写模板结构,为新建该类型笔记时提供初始内容。
这条元数据优先原则并非孤立存在。配套的 ADR-0025:type 字段规范化 确立了type:作为规范化字段(仍兼容Is A等旧别名),ADR-0006:扁平 vault 结构 确立了"大多数笔记平铺在 vault 根目录"的整体布局,ADR-0033:子文件夹扫描 则让扫描器覆盖所有非隐藏子目录。ADR-0096 是在这套地基上对"创建动作"本身做的收口。
决策前的矛盾:type/目录的"路径特殊性"与 vault 模型的冲突
ADR-0096 的 Context 一节记录了决策动因:
- Tolaria 依据 frontmatter(
type: Type)识别类型定义,不依据文件系统位置; - 但旧文档和 UI 创建流程仍把
type/当作新类型文档的规范落点,并对已经使用types/复数目录的 vault 做了兼容回退; - 这种基于文件夹的创建策略与整体 vault 模型冲突:笔记从所有非隐藏文件夹扫描而来,类型身份来自元数据,而且根目录的
type.md/note.md定义早已被修复(repair)和引导(bootstrap)流程使用。
仓库中仍能看到旧约定的痕迹。例如内置的 getting started 引导文本(src-tauri/src/vault/getting_started.rs)里还写着 "Type definitions live intype/",demo vault 也把类型文档放在type/目录下(如 demo-vault-v2/type/note.md、demo-vault-v2/type/project.md)。ADR-0096 正是针对这类"路径被特殊化"的历史包袱做出的修正。
决策:新建类型文档一律落在 vault 根目录
ADR-0096 的核心决策可以概括为五条:
- 类型文档 = 任意 frontmatter 含
type: Type的 Markdown 笔记; - UI 新创建的类型文档使用
{vault}/{slug}.md(即 vault 根目录下,以类型名 slug 化命名); - 已经存在于
type/、types/或其他被扫描文件夹中的类型文档保持有效,继续驱动模板、图标、颜色、可见性、排序和侧边栏分组; - 创建动作不静默迁移或移动任何已有类型文档;
- 根目录文件名冲突按"文件冲突"处理——创建类型文档时绝不允许覆盖已有笔记。
源码印证:resolveNewType如何决定落点
创建逻辑的前端实现在 src/hooks/useNoteCreation.ts。关键函数resolveNewType(src/hooks/useNoteCreation.ts#L377-L398)展示了落点决策的全部细节:
export function resolveNewType({ typeName, vaultPath, defaultWorkspacePath, vaults = [] }: NewTypeParams): { entry: VaultEntry content: string } { const normalizedTypeName = normalizeTypeCreationName(typeName) const creationVaultPath = resolveCreationVaultPath(vaultPath, defaultWorkspacePath, vaults) const slug = slugify(normalizedTypeName) const entry = { ...buildNewEntry({ path: joinVaultPath(creationVaultPath, `${slug}.md`), // 注意:没有 type/ 前缀 slug, title: normalizedTypeName, type: 'Type', status: null, }), workspace: workspaceForVaultPath(creationVaultPath, vaults, defaultWorkspacePath), } return { entry, content: `---\ntype: Type\n---\n\n# ${normalizedTypeName}\n`, } }两个细节值得注意:
- 路径拼接直接是
joinVaultPath(creationVaultPath,${slug}.md),与笔记创建(resolveNewNote)使用同一种根目录命名规则——这正是 ADR 所说的"移除创建路径的特殊性"(removes special casing from creation)。类型创建与笔记创建共享同一套路径约定,不再分叉。 - 生成的内容是最小骨架:frontmatter 只有一行
type: Type,正文只有一个# 类型名标题。图标、颜色、模板等都留给用户后续编辑,符合"类型文档就是普通笔记"的定位。
类型名还会先经过normalizeTypeCreationName规范化(src/hooks/useNoteCreation.ts#L370-L375):内置别名表TYPE_CREATION_ALIASES把notes映射到Note,再走canonicalizeTypeName做大小写规范化。这解释了后文测试中"对内置 Note 类型创建"会被视为已存在的场景。
创建流程的"三态计划":existing / blocked / create
真正保证创建安全的是planNewTypeCreation(src/hooks/useNoteCreation.ts#L502-L531)。它把一次创建请求归约为三种互斥状态:
export function planNewTypeCreation({ defaultWorkspacePath, entries, typeName, vaultPath, vaults, }: NewTypeParams & { entries: VaultEntry[] }): TypeCreationPlan { const existingType = findEquivalentTypeEntry(entries, typeName) if (existingType) return { status: 'existing', entry: existingType } const resolved = resolveNewType({ typeName, vaultPath, defaultWorkspacePath, vaults }) const collision = findPathCollision(entries, resolved.entry.path) if (collision) { return { status: 'blocked', message: buildCreationCollisionMessage({ noun: 'type', title: typeName, path: resolved.entry.path }), } } return { status: 'create', resolved } }| 状态 | 判定条件 | 用户可见行为 |
|---|---|---|
existing | 按标题/slug 匹配到已有类型条目(findEquivalentTypeEntry,L468-L474,对entry.isA === 'Type'且标题或 slug 相同的条目做等价匹配) | Toast:Type "X" already exists,不写入任何文件 |
blocked | vault 中不存在同名类型,但根目录已存在同 slug 的任意笔记(findPathCollision) | Toast:Cannot create type "X" because x.md already exists,写入中止 |
create | 无等价类型、无路径冲突 | 持久化{vault}/{slug}.md并上报type_created事件 |
冲突消息由buildCreationCollisionMessage统一生成(src/hooks/useNoteCreation.ts#L462-L466):
const filename = notePathFilename(path) return `Cannot create ${noun} "${title}" because ${filename} already exists`这正是 ADR 中"root filename collisions are handled as file collisions"(根目录文件名冲突按文件冲突处理)的直接落地。执行层createTypeFromName(src/hooks/useNoteCreation.ts#L698-L737)在持久化前还会追加一次findTypeTargetCollision复查,兜住计划之后才出现的文件系统状态变化;而isAlreadyExistsError(L533-L536)把already exists / file exists / eexist之类的持久化异常统一翻译成同样的冲突话术,确保"绝不覆盖"的语义在 UI 层一致呈现。此外还有一个静默变体createTypeSilently(L739-L775),供 wikilink 缺失类型补建等流程复用同一套计划逻辑,冲突时抛出异常而不是覆盖。
碰撞安全的端到端验证:Playwright 冒烟测试
上述语义不是孤立的单元测试假设,而是被桌面端冒烟测试逐条验证的。tests/smoke/collision-create-flows.spec.ts 中第一个用例"missing-type creation keeps the dialog open when a root filename already exists":
- 在临时 vault 根目录预置
hotel.md(一个type: Note的普通笔记),再建一份hotel-guide.md(type: Hotel); - 通过缺失类型提示触发"创建 Hotel 类型"的对话框;
- 断言 Toast 显示
Cannot create type "Hotel" because hotel.md already exists; - 关键断言:回读
hotel.md,确认内容仍是# Existing Hotel Note——原有笔记没有被触碰,对话框也没有关闭。
另一个用例则验证existing分支:当根目录已存在note.md(内置 Note 类型文档)时,通过命令面板创建 "Note" 类型,预期 Toast 为Type "Note" already exists,且文件内容保持原样。这两组断言与 ADR 决策中"collision message instead of writing into a fallback folder"(用冲突消息取代写入回退文件夹)完全对应。辅助的单测可继续在 src/hooks/useNoteCreation.helpers.test.ts 与 src/hooks/useNoteCreation.extra.test.ts 中查看。
兼容性:旧type/、types/目录为什么"免迁移"
ADR-0096 的 Consequences 一节承诺:已有 vault 中的type/或types/类型文档保持可读,因为vault 扫描本来就包含非隐藏子目录。这一点可以从两条证据链确认:
- 扫描模型:ADR-0033 确立的"子文件夹扫描 + 文件夹树"使任何非隐藏目录下的
.md都会进入索引,isA: Type的判定只依赖 frontmatter; - 实际样例:仓库自带演示库 demo-vault-v2/type/ 下存放着
note.md、project.md、area.md等类型文档,它们全部以type: Typefrontmatter 自声明,位置在type/子目录而非根目录,却同样是有效的类型定义。
因此 ADR 对旧 vault 采取的是"只改新增行为、不动存量数据"的策略:
- 用户默认可以把类型文档当作普通根笔记检查与编辑;
- 如果根目录已有同 slug 笔记,类型创建直接失败并给出冲突消息,而不是偷偷写进某个回退文件夹;
- ADR 还留了一个实现余量:legacy
type/目录可以从文件夹树中隐藏,避免旧类型文档与侧边栏 Types 分组重复展示; - 若未来用户需要"从文件夹型类型文档到根目录型类型文档"的引导式迁移,需要重新评估。
被否决的备选方案
ADR 同时记录了两条未选路线及其代价,对理解当前设计边界很有帮助:
- 规范化的
type/目录:好处是天然避免根目录文件名冲突,但代价是把"类型身份"这件事人为地与路径绑定——既然身份已由 frontmatter 定义,路径特殊化就是模型不一致; - 动态沿用每个 vault 的现有文件夹约定:对复数
types/的 vault 改动最小,但会造成不同 vault 行为不一致,且让types/只处于"部分支持"状态。
选中的根目录方案则一次性消除了创建路径的特殊分支,并与根目录管理的默认类型脚手架(如 bootstrap/repair 流程使用的根note.md/type.md)对齐。
小结
ADR-0096 用一个看似微小的落点变更,把"创建类型文档"纳入了 Tolaria 的元数据优先模型:类型身份看 frontmatter,创建落点看 vault 根目录,兼容性看既有扫描能力,安全性看三态计划与碰撞消息。对使用者而言,实际效果是——通过 创建类型指南 或命令面板新建一个类型后,你会在 vault 根目录得到一个形如---\ntype: Type\n---\n\n# 类型名\n的.md文件;若根目录已有同名笔记,你会看到明确的冲突提示而不是被静默覆盖。相关的源码入口(planNewTypeCreation/resolveNewType,均在 src/hooks/useNoteCreation.ts)与端到端测试(tests/smoke/collision-create-flows.spec.ts)构成了这条策略可验证的完整证据链。
【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考