跳转到主要内容

工作原理

Crib 怎样把内容、搜索索引和个人资料分开存放,以及为什么导入或更新版本时旧窗口还能继续读。

Crib 由两部分组成:构建端用 Go 把手册和 Wiki 做成签名的资料包和预建索引;macOS 应用用 SwiftUI、少量AppKit、WKWebView 和 SQLite FTS5 读取它们。应用运行时不需要网络服务,也不启动本地 HTTP 服务器(本地 API 是可选功能,默认关闭)。

flowchart LR
  P["手册源码与 HTML<br>Wiki 冻结快照"] --> G["Go 构建工具<br>crib-pack"]
  G --> B["签名资料包<br>.crib"]
  G --> S["出厂搜索索引<br>seed"]
  B --> V["应用:验签与安全解包"]
  V --> W["后台构建新的搜索世代"]
  S --> A["当前世代"]
  W --> A
  A --> Q["搜索"]
  A --> R["阅读器 WKWebView"]
  U["个人资料<br>user.sqlite"] --> R
  U --> Q

三类数据分开存放

数据存放位置谁可以写
文档内容:HTML、资源、章节、双语对应、精确名称键只读的资料包只有构建端,写入后签名
搜索索引资料库里的“世代”(generation)应用的后台构建器,构建完成后不再修改
笔记、书签、标签、历史、本地更正用户目录中的 user.sqlite只有个人资料模块

资料包导入器不能写个人资料库,个人资料也不建立指向资料包的级联删除。所以卸载一个版本不会删掉挂在上面的笔记,只会让它们暂时显示为目标不可用。

资料包

每条 PostgreSQL 版本线是一个双语原子包,Wiki 是另一个。包里有 manifest.json、签名、来源清单、章节数据库、两种语言的文档和许可文件。同一个版本、同一个修订号的文件字节永远不变;内容有变化就发新的修订号。格式细节见资料包格式。

应用只接受签名有效的包:检查清单签名、文件长度和哈希、ZIP 边界和资源清单,拒绝绝对路径和路径穿越。

搜索世代

搜索时只查一个聚合的 SQLite 数据库,不同时挂接多个包的数据库。这样所有版本的正文用同一套统计排序,结果可以直接比较。

这个聚合数据库叫一个“世代”。它由当前启用的资料包组合唯一决定:

  • 完整离线版和 Core 版都随附一个出厂世代,首次启动直接使用,不需要建索引。
  • 导入、更新、启用或停用资料包时,应用在后台从各包流式读取章节,写一个新的世代。
  • 新世代验证通过后才原子切换。构建期间旧世代照常服务,已经打开的阅读窗口继续停在原来的世代上。
  • 构建失败、被取消或磁盘不足时,保留原来的世代,不会出现一半新一半旧的状态。

只移动版本别名(例如 pg18 从 18.6 指向 18.7)不需要重建世代;只有实际更换资料包才重建。

查询先解析成结构化条件:版本前缀和 lang:、kind: 等过滤器来自白名单,其余是文本。用户输入不会直接拼进 SQL。排序分层进行:

  1. 当前范围内的精确名称和别名(work_mem、->>、\dC、SQLSTATE 代码)。
  2. 名称前缀。
  3. 标题和章节名。
  4. 正文。

中文按连续汉字的重叠双字切分:“事务隔离”切成“事务、务隔、隔离”。单个汉字(例如“锁”)走标题和名称的字符索引,在有限范围内查,不扫描全库正文。构建端和应用使用同一个切分规则(analyzer),并用同一组测试样例核对。具体写法见搜索。

阅读器

正文是资料包里的 HTML,由 WKWebView 通过一个受限的内部地址读取:地址里带有资料包清单的摘要,只能访问该包内的文件,不能加载远程脚本或资源。切换语言时,按两种语言之间已验证的章节对应跳转,没有对应就回到父节并提示。

个人资料

user.sqlite 使用 WAL 和完整同步写入,每次保存在一个事务里比较修订号:两个编辑器同时改同一条笔记时,后保存的一方得到冲突提示,不会覆盖对方。迁移或备份使用 SQLite 在线备份,而不是复制正在使用的数据库文件。个人资料库打不开时,只停用个人功能,阅读和搜索照常工作。见笔记、书签与本地更正。