M29 只解决了「回书架不重扫」;首次进书架仍要全量扫 434 目录(真机 3.0–3.4s, 其中 opendir+readdir 占 96%)。本单把扫描结果 + 每个扫描目录的 mtime 存到 MeowData/os.meow.reader/index/shelf.idx,冷启动逐目录 stat 比对 mtime,命中即 完全跳过 readdir;任何异常一律 fail-open 全量重扫。 先量再选(任务 1):宿主 434 目录/595 本/51 rar 虚拟库(贴真机量级) - 434 次目录 stat = 0.48ms/pass(1.1µs/次,不含 readdir); - 读 72KB 索引 = 0.009ms;全量扫描 3.54ms;索引命中 1.29ms(ts_fn=NULL)。 真机按 M28 实测 1079 次 stat=160–170ms(≈150µs/次)外推:434 次 ≈ 65ms, 远低于「可能不可行」的 1s 阈值 → 选「校验全部扫描目录 mtime」(非只 level-1)。 strace 断言:indexonly 载入 0 次 getdents64、恰好 434 次 stat(真的没 readdir)。 生产路径 ≈ 65ms(stat) + <20ms(索引读) + 40–50ms(rdprog×595) + <20ms(build_rows) ≈ 0.13–0.15s(对比 3.0s)。 索引格式 v1(手工小端序列化,不依赖结构体布局): header(magic/version/header_size/root_len) + root + dir 记录×(rel,mtime_s, mtime_ns,books_here) + book 记录×(rel,size,fmt) + trailer(ndirs,nbooks,flags, depth_skipped,crc32,magic)。truncated/depth_skipped 存在 flags/字段里,载入时 如实重建提示条;**不存阅读进度 ts**,载入后每本由 rdprog_load 现读再排序。 失效语义(存每个扫描过的目录,不只 level-1): - level-2 新增 / level-3 深处新增 / 删书 → 该目录 mtime 变 → 重扫; - 一级目录改名 → 父目录 mtime 变 + 旧目录 stat ENOENT → 重扫; - 超深(>6 层)未扫描子树不在索引内,其内部变化不自动失效(本来就不显示), 点「刷新」更新 depth_skipped 计数;mtime 精度受限(exFAT 10ms/FAT32 2s)的 同刻度变更也可能漏判,点「刷新」必可纠正。 M29 语义不变:首次进书架=索引或全扫;回书架=不扫;刷新=删索引+全扫(之后原子 写回新索引)。 安全/健壮(宁可慢不可错): - 写:shelf.idx.tmp 全量写完(尽力 fsync)后 rename;中途被杀只剩 tmp,正式索引 要么旧要么完整新;rename 覆盖失败先删旧再改名;fflush/写错即放弃,绝不影响书架。 - 读:O_NOFOLLOW|O_NONBLOCK + S_ISREG(FIFO/符号链接直接拒绝不阻塞);CRC32 覆盖 全部记录;rel 做绝对路径/“..”/内嵌 NUL/长度校验;版本/根路径/计数/逐目录 mtime 任一不符即重扫;载入崩溃路径全部 goto 清理,不改书架可用性。 - writer_open 失败(卡满/写保护/无 MeowData)→ 扫描不采集目录、照常出书; commit 失败(含只读目录 rename 失败)不留正式索引。 测试:test/shelf_index_smoke 154 pass(含四种变更场景失效、截断/字节翻转/垃圾/ 缺失/FIFO/符号链接/版本不符/根路径不符、ts 现读、truncated/depth_skipped 重建、 只读目录写失败、空 Books、root 缺失、.tmp 残渣),ASan+UBSan 干净; test/shelf_index_bench 模拟 434 目录/595 本/51 rar:scan/load 均 595 本、 truncated=0/depth_skipped=0、索引 72,156B。build_reader.sh 全绿 0 warning (core 6846 / seg 19 / chapter 71 / shelf_group 63 / index 154 / font 14 / loaders PASS)。shelf_bench_ondev 增加 index_load/scan_save 计时与 MEOW_BENCH_IDX=miss(真机首开命中 vs 未命中对比,待有卡复核)。 交付:app.json 0.1.4/code5 → 0.1.5/code6;reader md5 df4cadcf 1,711,924B;mpk md5 fec7b1bb 814,535B;未签名不推卡。
327 lines
12 KiB
C
327 lines
12 KiB
C
/* app/shelf_model.c — 书架数据模型实现(见 app/shelf_model.h)。 */
|
||
#include "shelf_model.h"
|
||
|
||
#include <dirent.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <sys/stat.h>
|
||
|
||
int shelf_fmt_from_ext(const char *name)
|
||
{
|
||
const char *d = strrchr(name, '.');
|
||
if (!d) return -1;
|
||
if (strcasecmp(d, ".txt") == 0) return 0;
|
||
if (strcasecmp(d, ".fb2") == 0) return 1;
|
||
if (strcasecmp(d, ".epub") == 0) return 2;
|
||
if (strcasecmp(d, ".mobi") == 0) return 3;
|
||
if (strcasecmp(d, ".cbz") == 0) return 4;
|
||
return -1;
|
||
}
|
||
|
||
const char *shelf_fmt_name(int f)
|
||
{
|
||
switch (f) {
|
||
case 0: return "TXT";
|
||
case 1: return "FB2";
|
||
case 2: return "EPUB";
|
||
case 3: return "MOBI";
|
||
case 4: return "CBZ";
|
||
default: return "?";
|
||
}
|
||
}
|
||
|
||
void shelf_fmt_size(char *out, int outsz, long size)
|
||
{
|
||
if (!out || outsz <= 0) return;
|
||
if (size >= 1 << 20) snprintf(out, (size_t)outsz, "%.1fMB", (double)size / (1 << 20));
|
||
else if (size >= 1 << 10) snprintf(out, (size_t)outsz, "%.0fKB", (double)size / (1 << 10));
|
||
else snprintf(out, (size_t)outsz, "%ldB", size);
|
||
}
|
||
|
||
int shelf_copy_utf8(char *dst, int cap, const char *src)
|
||
{
|
||
if (!dst || cap <= 0) return 0;
|
||
if (!src) { dst[0] = 0; return 0; }
|
||
int limit = cap - 1;
|
||
int n = 0;
|
||
while (src[n] && n < limit) n++;
|
||
if (src[n]) {
|
||
/* 截断:若 src[n] 是续字节(10xxxxxx),回退到该字符首字节之前,整段丢弃。 */
|
||
while (n > 0 && ((unsigned char)src[n] & 0xC0) == 0x80) n--;
|
||
}
|
||
memcpy(dst, src, (size_t)n);
|
||
dst[n] = 0;
|
||
return n;
|
||
}
|
||
|
||
static int cmp_recent_then_name(const void *a, const void *b)
|
||
{
|
||
const shelf_book *x = (const shelf_book *)a, *y = (const shelf_book *)b;
|
||
if (x->ts != y->ts) return (x->ts < y->ts) ? 1 : -1; /* 最近在前 */
|
||
return strcasecmp(x->title, y->title);
|
||
}
|
||
|
||
/* 最近阅读排序(扫描后 / 刚读完置顶共用同一口径)。 */
|
||
void shelf_sort_recent(shelf_book *books, int nbooks)
|
||
{
|
||
if (!books || nbooks < 2) return;
|
||
qsort(books, (size_t)nbooks, sizeof(shelf_book), cmp_recent_then_name);
|
||
}
|
||
|
||
/* 重建一本书的两种副题(扫描后与「刚读完置顶」共用同一份格式化逻辑)。
|
||
* sub = 格式 · 大小 [· 读](format 模式/宫格)
|
||
* sub_folder = 相对 book.group 第一级之后的子路径 · sub(folder 模式)
|
||
*/
|
||
void shelf_fmt_sub(shelf_book *b)
|
||
{
|
||
if (!b) return;
|
||
char sz[24];
|
||
shelf_fmt_size(sz, sizeof sz, b->size);
|
||
/* 默认副题:与 M27 前逐字一致(format 模式/宫格都用它)。 */
|
||
snprintf(b->sub, sizeof b->sub, "%s · %s%s", shelf_fmt_name(b->fmt), sz,
|
||
b->ts ? " · 读" : "");
|
||
/* folder 副题:第一级之后的子路径(分类/作品)前置,其余同默认副题。 */
|
||
const char *rest = strchr(b->group, '/');
|
||
if (rest) rest++;
|
||
if (rest && rest[0]) {
|
||
char pre[100];
|
||
shelf_copy_utf8(pre, (int)sizeof pre, rest);
|
||
snprintf(b->sub_folder, sizeof b->sub_folder, "%s · %s", pre, b->sub);
|
||
} else {
|
||
snprintf(b->sub_folder, sizeof b->sub_folder, "%s", b->sub);
|
||
}
|
||
}
|
||
|
||
typedef struct scan_ctx {
|
||
shelf_book *books;
|
||
int cap;
|
||
int n;
|
||
int truncated;
|
||
int depth_skipped;
|
||
shelf_ts_fn ts_fn;
|
||
shelf_dir_fn dir_fn; /* M32:目录 mtime 采集(NULL=不采集) */
|
||
void *dir_ctx;
|
||
} scan_ctx;
|
||
|
||
/* 统计 dir 子树里还能识别的书籍文件数(只数不收集)。自身留 8 层硬上限,
|
||
* 防符号链接/异常深目录把计数也拖死。返回书籍文件数。 */
|
||
static int count_books_under(const char *dir, int depth)
|
||
{
|
||
if (depth > SHELF_SCAN_MAX_DEPTH + 8) return 0;
|
||
int n = 0;
|
||
DIR *dp = opendir(dir);
|
||
if (!dp) return 0;
|
||
struct dirent *de;
|
||
while ((de = readdir(dp))) {
|
||
if (de->d_name[0] == '.') continue;
|
||
char path[400];
|
||
if (snprintf(path, sizeof path, "%s/%s", dir, de->d_name) >= (int)sizeof path) continue;
|
||
struct stat st;
|
||
if (stat(path, &st) != 0) continue;
|
||
if (S_ISDIR(st.st_mode)) { n += count_books_under(path, depth + 1); continue; }
|
||
if (shelf_fmt_from_ext(de->d_name) >= 0) n++;
|
||
}
|
||
closedir(dp);
|
||
return n;
|
||
}
|
||
|
||
/* 递归收集。rel = 相对 root 的完整目录路径(root 自身为 ""),
|
||
* top = 第一级目录名(root 自身/根下文件为 "")。
|
||
* dst = 本目录的 stat 结果(调用方已 stat;NULL=没有 mtime 可上报)。
|
||
* M32:扫描完一个目录后用 dir_fn 上报 (rel, mtime, 本目录收录书数),供索引做失效校验。
|
||
* books_here 只数**实际写进 books[] 的**书(被 cap 丢掉的不计),保证索引里
|
||
* sum(books_here) == nbooks,载入时可用它做一致性校验。 */
|
||
static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel,
|
||
const char *top, const struct stat *dst)
|
||
{
|
||
if (depth > SHELF_SCAN_MAX_DEPTH) return; /* 防深嵌套/循环 */
|
||
if (c->n >= c->cap) return; /* 已满:不再进新目录 */
|
||
DIR *dp = opendir(dir);
|
||
if (!dp) return;
|
||
int books_here = 0;
|
||
struct dirent *de;
|
||
while ((de = readdir(dp))) {
|
||
if (de->d_name[0] == '.') continue;
|
||
char path[400];
|
||
if (snprintf(path, sizeof path, "%s/%s", dir, de->d_name) >= (int)sizeof path) continue;
|
||
struct stat st;
|
||
if (stat(path, &st) != 0) continue;
|
||
if (S_ISDIR(st.st_mode)) {
|
||
if (depth + 1 > SHELF_SCAN_MAX_DEPTH) {
|
||
c->depth_skipped += count_books_under(path, depth + 1); /* 超深:计数→可见提示 */
|
||
continue;
|
||
}
|
||
if (c->n >= c->cap) {
|
||
/* 上限卡在目录边界:探一下这个子树是否真有书,有书才算被截断。
|
||
* 只探空目录不置位,避免“正好装满且后面全是空目录”误报。 */
|
||
if (!c->truncated && count_books_under(path, depth + 1) > 0) c->truncated = 1;
|
||
if (c->truncated) break; /* 已确认丢书,后面的目录/文件不必再跑 */
|
||
continue;
|
||
}
|
||
char subrel[SHELF_GROUP_MAX * 16];
|
||
snprintf(subrel, sizeof subrel, "%s%s%s", rel, rel[0] ? "/" : "", de->d_name);
|
||
/* 第一级目录名 = top;更深层沿用。de->d_name 在递归返回前有效。 */
|
||
scan_dir(c, path, depth + 1, subrel, depth == 0 ? de->d_name : top, &st);
|
||
continue;
|
||
}
|
||
int f = shelf_fmt_from_ext(de->d_name);
|
||
if (f < 0) continue;
|
||
if (c->n >= c->cap) { c->truncated = 1; break; } /* 超限:可见提示,不静默 */
|
||
shelf_book *b = &c->books[c->n];
|
||
shelf_copy_utf8(b->path, (int)sizeof b->path, path);
|
||
shelf_copy_utf8(b->title, (int)sizeof b->title, de->d_name);
|
||
char *dot = strrchr(b->title, '.');
|
||
if (dot) *dot = 0; /* 标题去扩展名 */
|
||
shelf_copy_utf8(b->group, (int)sizeof b->group, rel);
|
||
shelf_copy_utf8(b->top, (int)sizeof b->top, top);
|
||
b->fmt = f;
|
||
b->size = (long)st.st_size;
|
||
b->ts = c->ts_fn ? c->ts_fn(b->path) : 0;
|
||
shelf_fmt_sub(b); /* 副题:格式 · 大小 [· 读](+ folder 子路径) */
|
||
c->n++;
|
||
books_here++;
|
||
}
|
||
closedir(dp);
|
||
if (c->dir_fn && dst)
|
||
c->dir_fn(c->dir_ctx, rel, (int64_t)dst->st_mtime,
|
||
(long)dst->st_mtim.tv_nsec, books_here);
|
||
}
|
||
|
||
int shelf_scan_books_ex(const char *root, shelf_book *books, int cap,
|
||
shelf_ts_fn ts_fn, shelf_scan_report *rep,
|
||
shelf_dir_fn dir_fn, void *dir_ctx)
|
||
{
|
||
if (rep) { rep->nbooks = 0; rep->truncated = 0; rep->depth_skipped = 0; rep->root_missing = 0; }
|
||
if (!root || !books || cap <= 0) {
|
||
if (rep) rep->root_missing = 1;
|
||
return 0;
|
||
}
|
||
DIR *dp = opendir(root);
|
||
if (!dp) {
|
||
if (rep) rep->root_missing = 1;
|
||
return 0;
|
||
}
|
||
closedir(dp);
|
||
|
||
/* root 自身的 mtime 也要上报(新增/删除 Books/ 下的一级目录会改它)。 */
|
||
struct stat rst;
|
||
int have_rst = (stat(root, &rst) == 0);
|
||
|
||
scan_ctx c;
|
||
c.books = books; c.cap = cap; c.n = 0; c.truncated = 0; c.depth_skipped = 0;
|
||
c.ts_fn = ts_fn; c.dir_fn = dir_fn; c.dir_ctx = dir_ctx;
|
||
scan_dir(&c, root, 0, "", "", have_rst ? &rst : NULL);
|
||
shelf_sort_recent(books, c.n);
|
||
if (rep) {
|
||
rep->nbooks = c.n;
|
||
rep->truncated = c.truncated;
|
||
rep->depth_skipped = c.depth_skipped;
|
||
}
|
||
return c.n;
|
||
}
|
||
|
||
int shelf_scan_books(const char *root, shelf_book *books, int cap,
|
||
shelf_ts_fn ts_fn, shelf_scan_report *rep)
|
||
{
|
||
return shelf_scan_books_ex(root, books, cap, ts_fn, rep, NULL, NULL);
|
||
}
|
||
|
||
/* ---- 行序列 ---- */
|
||
|
||
/* 分组键 = top(Books 第一级目录名)。长度截到第一个 '/',排序时忽略大小写。 */
|
||
static int top_len(const char *s)
|
||
{
|
||
const char *p = strchr(s, '/');
|
||
return p ? (int)(p - s) : (int)strlen(s);
|
||
}
|
||
static int top_cmp(const char *a, const char *b)
|
||
{
|
||
int la = top_len(a), lb = top_len(b);
|
||
if (!la != !lb) return la ? 1 : -1; /* 根组 "" 恒置顶 */
|
||
int m = la < lb ? la : lb;
|
||
int c = strncasecmp(a, b, (size_t)m);
|
||
if (c) return c;
|
||
return la - lb;
|
||
}
|
||
static int top_eq(const char *a, const char *b) { return top_cmp(a, b) == 0; }
|
||
|
||
/* 段头排序:根组(top=="")恒置顶,其余按第一级目录名不区分大小写排序。 */
|
||
static int cmp_group_key(const void *pa, const void *pb)
|
||
{
|
||
return top_cmp(*(const char *const *)pa, *(const char *const *)pb);
|
||
}
|
||
|
||
static int build_rows_format(const shelf_book *books, int nbooks,
|
||
shelf_row *rows, int rowcap)
|
||
{
|
||
static const int fmts[] = { 0, 1, 2, 3, 4 };
|
||
int nrows = 0;
|
||
for (size_t fi = 0; fi < sizeof fmts / sizeof fmts[0]; fi++) {
|
||
int fmt = fmts[fi];
|
||
int have = 0;
|
||
if (nrows >= rowcap) break;
|
||
for (int i = 0; i < nbooks; i++) {
|
||
if (books[i].fmt != fmt) continue;
|
||
if (!have) {
|
||
if (nrows >= rowcap) break;
|
||
rows[nrows].section = 1; rows[nrows].fmt = fmt;
|
||
rows[nrows].book = -1; rows[nrows].label = NULL;
|
||
nrows++;
|
||
have = 1;
|
||
}
|
||
if (nrows >= rowcap) break;
|
||
rows[nrows].section = 0; rows[nrows].fmt = fmt;
|
||
rows[nrows].book = i; rows[nrows].label = NULL;
|
||
nrows++;
|
||
}
|
||
}
|
||
return nrows;
|
||
}
|
||
|
||
static int build_rows_folder(const shelf_book *books, int nbooks,
|
||
shelf_row *rows, int rowcap)
|
||
{
|
||
/* 去重分组键(books[].top,""=根组)。键指向 books 内字符串,生存期覆盖 rows。 */
|
||
static const char *keys[SHELF_MAX_BOOKS];
|
||
int nkeys = 0;
|
||
for (int i = 0; i < nbooks; i++) {
|
||
const char *k = books[i].top;
|
||
int seen = 0;
|
||
for (int j = 0; j < nkeys; j++) {
|
||
if (top_eq(keys[j], k)) { seen = 1; break; }
|
||
}
|
||
if (!seen) keys[nkeys++] = k;
|
||
}
|
||
qsort(keys, (size_t)nkeys, sizeof keys[0], cmp_group_key);
|
||
|
||
int nrows = 0;
|
||
for (int ki = 0; ki < nkeys; ki++) {
|
||
if (nrows >= rowcap) break;
|
||
rows[nrows].section = 1;
|
||
rows[nrows].fmt = -1;
|
||
rows[nrows].book = -1;
|
||
rows[nrows].label = keys[ki][0] ? keys[ki] : SHELF_GROUP_ROOT;
|
||
nrows++;
|
||
for (int i = 0; i < nbooks; i++) {
|
||
if (!top_eq(books[i].top, keys[ki])) continue;
|
||
if (nrows >= rowcap) break;
|
||
rows[nrows].section = 0;
|
||
rows[nrows].fmt = books[i].fmt;
|
||
rows[nrows].book = i;
|
||
rows[nrows].label = NULL;
|
||
nrows++;
|
||
}
|
||
}
|
||
return nrows;
|
||
}
|
||
|
||
int shelf_build_rows(const shelf_book *books, int nbooks, int folder_mode,
|
||
shelf_row *rows, int rowcap)
|
||
{
|
||
if (!books || !rows || nbooks <= 0 || rowcap <= 0) return 0;
|
||
if (nbooks > SHELF_MAX_BOOKS) nbooks = SHELF_MAX_BOOKS;
|
||
return folder_mode ? build_rows_folder(books, nbooks, rows, rowcap)
|
||
: build_rows_format(books, nbooks, rows, rowcap);
|
||
}
|