feat(reader): M27 书架按目录分组(ui.shelf_group=folder|format,默认 format 不变)

- 新增 shelf_model.c/.h:扫描/行序列从 shelf.c 拆出(纯 C,宿主可测);shelf_book 加 group[96](相对 Books/ 的目录路径,根下空串),path 语义/内容完全不变(进度键仍为 rdprog_hash(path))
- folder 模式:目录段头(根下=未分类),未分类置顶、其余按目录名排序;组内沿用最近阅读优先/其次书名;空目录不产生段头
- format 模式逐行保持旧行为;新增 reader.conf ui.shelf_group,缺键默认 format
- 上限 256→2048(SHELF_MAX_ROWS=2N=4096,段头≤书数),超限置可读提示,不再静默截断
- 顶栏加「目录/格式」切换,设置页加「书架分组」;UTF-8 安全截断长中文目录名
- 单测 test/shelf_group_smoke.c(38 断言)+ 计时 test/shelf_scan_bench.c
- build_reader.sh:链 meow_ui.a 解析冒烟 stbi_*;无 qemu 时跳过执行而非失败
This commit is contained in:
AskaEth committed 2026-11-09 21:55:33 +08:00
1 parent db672cbe8f
commit 409fd1edf7
11 files changed
+800 -129

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
/* 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>
#define SHELF_SCAN_MAX_DEPTH 2 /* 子目录深度上限(Books/a/b 有书,Books/a/b/c 不扫) */
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);
}
typedef struct scan_ctx {
shelf_book *books;
int cap;
int n;
int truncated;
shelf_ts_fn ts_fn;
} scan_ctx;
/* 递归收集:rel = 相对 root 的目录路径(root 自身为 "")。 */
static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel)
{
if (depth > SHELF_SCAN_MAX_DEPTH) return; /* 防深嵌套/循环 */
if (c->n >= c->cap) return; /* 已满:不再进新目录 */
DIR *dp = opendir(dir);
if (!dp) return;
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)) {
char subrel[SHELF_GROUP_MAX * 4];
if (snprintf(subrel, sizeof subrel, "%s%s%s", rel, rel[0] ? "/" : "", de->d_name)
>= (int)sizeof subrel) continue;
scan_dir(c, path, depth + 1, subrel);
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);
b->fmt = f;
b->size = (long)st.st_size;
b->ts = c->ts_fn ? c->ts_fn(b->path) : 0;
char sz[24];
shelf_fmt_size(sz, sizeof sz, b->size);
snprintf(b->sub, sizeof b->sub, "%s · %s%s", shelf_fmt_name(f), sz,
b->ts ? " · 读" : "");
c->n++;
}
closedir(dp);
}
int shelf_scan_books(const char *root, shelf_book *books, int cap,
shelf_ts_fn ts_fn, shelf_scan_report *rep)
{
if (rep) { rep->nbooks = 0; rep->truncated = 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);
scan_ctx c;
c.books = books; c.cap = cap; c.n = 0; c.truncated = 0; c.ts_fn = ts_fn;
scan_dir(&c, root, 0, "");
qsort(books, (size_t)c.n, sizeof(shelf_book), cmp_recent_then_name);
if (rep) { rep->nbooks = c.n; rep->truncated = c.truncated; }
return c.n;
}
/* ---- 行序列 ---- */
/* 段头排序:根组(group=="")恒置顶,其余按目录名不区分大小写排序。 */
static int cmp_group_key(const void *pa, const void *pb)
{
const char *a = *(const char *const *)pa;
const char *b = *(const char *const *)pb;
if (!a[0] != !b[0]) return a[0] ? 1 : -1; /* "" 在前 */
return strcasecmp(a, b);
}
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[].group 的字符串,books 生存期覆盖 rows。 */
static const char *keys[SHELF_MAX_BOOKS];
int nkeys = 0;
for (int i = 0; i < nbooks; i++) {
const char *k = books[i].group;
int seen = 0;
for (int j = 0; j < nkeys; j++) {
if (strcmp(keys[j], k) == 0) { 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 (strcmp(books[i].group, keys[ki]) != 0) 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);
}