/* app/shelf_index.h — 书架索引落盘(M32):把一次全量扫描的结果 + **Books 根与一级目录** * 的 mtime 存到 `MeowData/os.meow.reader/index/shelf.idx`,冷启动时只做 4 次左右的 * stat 校验,命中就完全跳过 readdir。 * * 为什么只校验一级(M32 r2,真机冷缓存实测后按任务 1 的判据改): * 真机 exFAT(drop_caches 后)量到 434 个目录逐个 stat = **3.15s(7.26ms/次)**, * 与全量 readdir 的 3.0s 同量级;全目录校验的索引命中冷启动仍要 3.06s,只比全扫快 0.2s, * 任务目标不成立。而 Books 根 + 3 个一级目录共 4 次 stat,冷缓存只有 **~20ms**。 * 真实数字(真机 drop_caches 实测,2026-11-10): * - 冷缓存:level-1 索引命中首开 ≈0.13–0.16s;全量扫描首开 ≈3.43–3.48s; * - 热 dentry:索引命中 ≈0.03–0.11s;全量扫描始终 ≈3.1–3.3s(readdir 不吃 page cache)。 * - 探针分项(冷):索引载入 20–30ms;载入+rdprog×595 70–120ms;4 个目录 stat 0–30ms。 * * 校验范围 = Books 根 + 每个一级目录(rel 为空或不含 '/')。**level-2 及更深的目录不落盘、 * 不 stat**(代价见下)。载入路径仍然完全没有 opendir/readdir。 * * 能自动失效 vs 必须点「刷新」: * 自动(Books 根 mtime 变):Books 根下直接放书/删书/改名;一级目录新增/删除/改名。 * 自动(一级目录 mtime 变):一级目录里**直接**放书/删书/改名/新建子目录。 * 必须刷新:level-2 及更深处的任何变化(新增/删除/改名书籍或目录)——这些只改更深层 * 目录的 mtime,一级目录与 Books 根都不变。这与 M29「新书靠刷新」是同一 UX。 * 刷新 = shelf_index_remove() + 全量重扫(之后写回新索引)。 * * mtime 精度(真机 exFAT 实测):**1 秒**(tm_sec 有值、tm_nsec=0);同一秒内连续两次改动, * 目录 mtime 完全不变,30 轮「扫描后同秒内新增」有 29 轮索引仍命中。因此:**不在同一 mtime * 秒刻度内的变更靠索引失效;同秒二次变更需刷新**。FAT32 是 2 秒。 * * 安全优先(fail-open):版本不符 / 文件缺失 / 截断 / CRC 错 / 根或一级目录 mtime 变化 / * 任何 IO 错误 → shelf_index_load() 返回 0,调用方照常全量重扫。**绝不**用陈旧索引冒充 * 真实书库,也绝不因为索引写失败而让书架打不开。 * * 索引里**不存阅读进度 ts**:ts 每次由调用方的 ts_fn(生产=rdprog_load)现读,否则 * 「刚读完回书架」会显示旧的置顶顺序。索引只存与进度无关的扫描事实。 */ #ifndef SHELF_INDEX_H #define SHELF_INDEX_H #include "shelf_model.h" /* 落盘格式版本:**扫描语义变了必须 +1**(新增格式扩展名/改深度/改分组/改校验范围等), * 版本不符时旧索引直接作废重扫。v2 = 只存根 + 一级目录的 mtime。 */ #define SHELF_INDEX_VERSION 2 /* 载入并校验索引。成功返回 1(books/rep 填好、已按 ts 排序,等价于一次全量扫描); * 任何异常返回 0(fail-open,books 内容不可用,调用方必须全量重扫)。 * 成功路径**不做 opendir/readdir**:只 open/read 索引 + stat 根/一级目录 + 每本书 ts_fn。 */ int shelf_index_load(const char *root, shelf_book *books, int cap, shelf_ts_fn ts_fn, shelf_scan_report *rep); /* 增量写索引(扫描时逐目录上报 → 扫描结束提交)。 * dir 回调会**过滤掉 level-2+**(只落根 + 一级目录的 mtime);书籍记录照旧全量。 * open 失败返回 NULL(卡满/写保护/无 MeowData):调用方传 NULL 即可,扫描照常。 * dir 回调签名与 shelf_dir_fn 一致,可直接传给 shelf_scan_books_ex。 * commit 成功返回 1 并原子发布(临时文件 + rename);失败返回 0 且清理临时文件。 * commit 总是接管 writer(成功/失败都会释放),之后不得再用。 */ typedef struct shelf_idx_writer shelf_idx_writer; shelf_idx_writer *shelf_index_writer_open(const char *root); void shelf_index_writer_dir(void *ctx, const char *rel, int64_t mtime_sec, long mtime_nsec, int books_here); int shelf_index_writer_commit(shelf_idx_writer *w, const shelf_book *books, int nbooks, const shelf_scan_report *rep); void shelf_index_writer_abort(shelf_idx_writer *w); /* 删除索引(顶栏「刷新」= 清索引 + 全量重扫;root 只用于定位,不存在也静默)。 */ void shelf_index_remove(const char *root); /* 索引文件路径(测试/诊断用)。0=成功;负值=无数据目录/路径超长。 */ int shelf_index_path(const char *root, char *out, int outsz); #endif /* SHELF_INDEX_H */