Tolaria ADR 0038:用 Frontmatter 双系统属性实现侧边栏收藏夹(`_favorite` 与 `_favorite_index`)

发布时间:2026/9/13 21:20:14
Tolaria ADR 0038:用 Frontmatter 双系统属性实现侧边栏收藏夹(`_favorite` 与 `_favorite_index`) Tolaria ADR 0038用 Frontmatter 双系统属性实现侧边栏收藏夹_favorite与_favorite_index【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria本文解析 Tolaria一款以 Markdown 文件为唯一事实来源的桌面知识库应用的架构决策记录 0038-frontmatter-backed-favorites.md收藏夹状态如何仅靠每个笔记 YAML frontmatter 中的两个下划线系统属性_favorite和_favorite_index实现持久化、排序与跨设备同步。读完后你能理解 Tolaria 收藏夹从“点击收藏”到“拖拽重排”的完整数据链路并掌握 frontmatter-first 数据模型下系统属性system properties的设计范式。问题背景需要一个可持久化、可排序的收藏夹Tolaria 的侧边栏有一个独立的 FAVORITES收藏夹分区用于把高频访问的笔记置顶以便快速导航。这个功能提出了一个典型的持久化问题哪些笔记被收藏了收藏的展示顺序是什么用户通过拖拽调整由于 Tolaria 的数据模型是“文件系统即真相”filesystem source of truth见 ADR 0002且 vault 通过 git 同步ADR 0034收藏状态必须落在能被 git 同步、能被任何读取 frontmatter 的客户端理解的位置。ADR 0038 正是在这个约束下做出的决策。核心决策每个笔记自带两个系统属性决策原文摘自 ADR 0038Favorites are stored as two system properties in each notes YAML frontmatter:_favorite: trueand_favorite_index: integer._favorite布尔标志用“存在性”表达状态属性类型语义_favoriteboolean存在且为true即表示已收藏键不存在即表示未收藏。取消收藏时整个键被删除而不是写成_favorite: false这是一种“以存在性代替值”的稀疏存储策略绝大多数笔记都没有收藏因此它们的 frontmatter 里根本不出现这个键避免给每个文件注入噪声。_favorite_index控制展示顺序的整数属性类型语义_favorite_indexinteger控制笔记在 FAVORITES 分区中的展示顺序数值越小越靠前。收藏时自动分配拖拽重排时更新一个被收藏笔记的 frontmatter 实际形态如下--- type: note _favorite: true _favorite_index: 3 --- # 一个常用笔记下划线前缀系统属性约定ADR 0008这两个属性都遵循 Tolaria 的_前缀系统属性约定ADR 0008任何以_开头的 frontmatter 字段都属于系统所有——从 Properties 属性面板中隐藏、不进入搜索/过滤字段但仍在 raw 编辑器中可被高级用户直接查看和修改。ADR 0008 给出了归一化系统属性表其中明确列出了收藏相关的两个键Canonical key旧键读取时回退由谁写入_favorite—收藏切换动作_favorite_index—收藏重排动作写入规则始终使用带_前缀的规范键读取规则接受规范键与遗留键大小写不敏感但读取时不重写文件。这一约定在前端有清晰的代码对应systemMetadata.ts 维护了系统元数据的别名分组其中_favorite与_favorite_index各自只映射到自身没有遗留别名并据此判断一个 frontmatter 键是否属于系统元数据const SYSTEM_METADATA_ALIAS_GROUPS { _archived: [_archived, archived], _favorite: [_favorite], _favorite_index: [_favorite_index], // ... }方案对比为什么选 per-note frontmatterADR 0038 的Options considered部分比较了三种方案这是文章最核心的权衡分析方案优点缺点per-note frontmatter选中每条笔记自带收藏状态通过 git 天然跨设备可移植不引入额外元数据文件重排时需要对多个笔记各做一次 frontmatter 写入独立.laputa/favorites.json文件集中式收藏路径列表重排只需写一个文件若.laputa/被 gitignore 则不可移植笔记重命名时路径引用会断裂SQLite / 应用级元数据库查询快不随 git 同步违背 ADR 0008 确立的 frontmatter-first 数据模型选择 per-note frontmatter 的关键理由有两条其一Tolaria 的 vault 通过 git 同步写在笔记内部的属性会随笔记一起流动任何能读 frontmatter 的客户端编辑器、脚本、其他设备都能看到收藏状态其二它避免了“路径引用”问题——独立元数据文件需要以路径索引笔记而笔记重命名见 ADR 0075 描述的崩溃安全重命名机制会打破这种引用。代价是重排 N 个收藏项要写 N 个文件但 ADR 认为对典型收藏夹规模 20 项完全可以接受。实现佐证从 Rust 解析到 React 状态下面沿着“文件 → 状态 → 用户操作”的链路用仓库源码验证 ADR 中的每条声明。1. Rust 端frontmatter 解析把两个键映射为favorite/favoriteIndexTauri 后端的 frontmatter.rs 定义了 frontmatter 反序列化结构其中收藏字段为#[serde( rename _favorite, default, deserialize_with deserialize_bool_or_string )] pub favorite: Optionbool, #[serde(rename _favorite_index, default)] pub favorite_index: Optioni64,两个细节值得注意_favorite使用了deserialize_bool_or_stringYAML 的Yes/No会被 gray_matter 转成字符串因此反序列化器同时接受真正的布尔值和布尔的字符串表示保证_favorite: yes之类的写法也能被正确识别两个字段都是Option与 ADR 中“不存在即未收藏”的语义一致——缺失的键反序列化为None而不是报错。后端的键表 keys.rs 同样登记了read_key: _favorite/write_key: _favorite及_favorite_index保证读写两侧使用同一个规范键。解析行为有测试兜底archival_metadata.rs 中的test_parse_favorite_fields验证了_favorite: true_favorite_index: 3能解析出favorite true, favorite_index Some(3)而不含这些键的笔记解析为“未收藏、无索引”。2. 前端frontmatter 键到VaultEntry字段的统一映射前端的单一映射点在 frontmatterOps.ts。删除映射表ENTRY_DELETE_MAP把删除这两个键翻译成状态重置_favorite: { favorite: false }, _favorite_index: { favoriteIndex: null },更新映射knownFrontmatterUpdatesfrontmatterOps.ts则把值翻译为对应字段_favorite: { favorite: Boolean(value) }, _favorite_index: { favoriteIndex: frontmatterNumber(value) },也就是说update_frontmatter/delete_frontmatter_property两个 Tauri 命令的返回值都会被frontmatterToEntryPatch转成对内存中VaultEntry的增量补丁实现“文件内容与界面状态”的双向一致。3. 收藏切换乐观更新 失败回滚 可撤销收藏切换的核心逻辑在 useEntryActions.ts。切换为收藏时执行两条 frontmatter 写入if (favorite) { await handleUpdateFrontmatter(path, _favorite, true, { silent: true }) await handleUpdateFrontmatter(path, _favorite_index, favoriteIndex ?? 1, { silent: true }) } else { await handleDeleteProperty(path, _favorite, { silent: true }) await handleDeleteProperty(path, _favorite_index, { silent: true }) }这与 ADR 声明逐条对应收藏 → 写入_favorite: true并分配_favorite_index取消收藏 → 两个键整体删除不写false。新收藏项的索引由nextFavoriteIndex计算取当前所有收藏项favoriteIndex的最大值加 1即新收藏项排在列表末尾。整个切换通过runFavoriteTransition以“乐观更新”方式执行先updateEntry立即反映到 UI再异步持久化若持久化失败则用transition.rollback恢复 UI 并提示Failed to favorite — rolled back。同时recordFavoriteHistory会把这次转换记入 renderer action history见 ADR 0126undo重放before状态、redo重放after状态——撤销/重做收藏同样走 frontmatter 写删路径保证历史操作与文件内容一致。4. 拖拽重排一次重排 N 次单键写入侧边栏的 FAVORITES 分区由 FavoritesSection.tsx 实现基于dnd-kit的DndContextuseSortable提供拖拽排序。列表排序函数正是 ADR 语义的直接翻译function sortFavorites(entries: VaultEntry[]) { return entries .filter((entry) entry.favorite !entry.archived) .sort((a, b) (a.favoriteIndex ?? Infinity) - (b.favoriteIndex ?? Infinity)) }(favoriteIndex ?? Infinity)对应 ADR 的后果条款之一若_favorite: true存在但_favorite_index缺失该笔记被排到列表末尾。另外收藏数为 0 时整个分区返回null不渲染。拖拽结束后的持久化在 useEntryActions.ts 的useReorderFavoritesAction中async (orderedPaths: string[]) { for (let i 0; i orderedPaths.length; i) { const orderedPath orderedPaths.at(i) if (!orderedPath) continue updateEntry(orderedPath, { favoriteIndex: i }) await handleUpdateFrontmatter(orderedPath, _favorite_index, i, { silent: true }) } onFrontmatterPersisted?.() }即先乐观更新所有条目的favoriteIndex再逐个串行把_favorite_index写回每个笔记文件——这正是 ADR 中“重排要写 N 个文件受影响的每条笔记各写一次”的工程落地也是该方案唯一被明确标注的性能代价。后果与再评估触发条件ADR 0038 的Consequences部分列出了四条长期影响逐条都有源码或模型层面的依据收藏夹随 vault 的 git 同步存活——任何读取 frontmatter 的客户端都能识别。这是 per-note 方案相对 SQLite/独立元数据文件的根本收益。重排写入 N 个文件——对典型收藏夹列表 20 项可接受useReorderFavoritesAction的串行循环证实了这一点且每次写入都触发onFrontmatterPersisted与 ADR 0043 的保存后反应式状态更新机制衔接。索引缺失的兜底——_favorite: true而无_favorite_index时排到末尾由sortFavorites的?? Infinity实现。再评估触发器——如果收藏夹规模超过约 50 项、重排写入成为性能问题需要重新评估存储方案例如批量写或独立索引文件。小结frontmatter-first 模型下的系统属性范式ADR 0038 的完整链路可以概括为点击收藏 ──▶ 乐观更新 VaultEntryfavorite/favoriteIndex ──▶ update_frontmatter(_favorite, true) ──▶ update_frontmatter(_favorite_index, max1) ──▶ 失败则回滚 UI toast 拖拽重排 ──▶ 按新顺序逐个写 _favorite_index (0..N-1) 读取渲染 ──▶ Rust 解析 _favorite/_favorite_indexOption 语义 ──▶ 按 favoriteIndex 升序渲染缺失索引排末尾这条决策展示了 Tolaria 处理“应用级 UI 状态”的通用范式状态直接编码为笔记自身的_前缀系统属性解析层Rust/TS 双端负责映射为类型化字段操作层useEntryActions.ts frontmatterOps.ts负责乐观更新、失败回滚与撤销历史。相比独立元数据文件或本地数据库这种方案牺牲了一次重排的多文件写入换来的是收藏状态的可移植性、可审查性git diff 直接可见谁被收藏了以及与 frontmatter-first 数据模型的一致性。【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考