Files
MeowOS-For-R3P2/patch_v2/reader_app/app/shelf_model.c
T
AskaEth 2881ce908a feat(reader): M33 目录模式逐级下钻 — 子目录/书分层显示(416 本不再平铺)
- folder 模式从「按第一级目录分段」改为当前目录视图:子目录在前(带子树书数),
  直接书在后;进入/返回只过滤已扫平的 g_books,不 readdir、不重扫(M32 索引不动)。
- 纯函数 shelf_build_rows_at(books,n,folder,cwd,...) + nav 工具(enter/up/breadcrumb/
  count)全部宿主可测;format 模式行序列/副题逐字不变,path 仍是完整路径(进度键不变)。
- 交互:点目录进入;顶栏返回箭头/左缘返回手势回上一级,根层再返回退出 App;顶栏标题=
  当前目录名(按像素宽截断),副题行=面包屑全路径+当前层书数;list/grid 均支持导航。
- 状态:folder↔format、list↔grid 切换保留 cwd;刷新重扫后 cwd 失效则收缩到有书的祖先。
- 边界:空目录不显示(导航只读 g_books,不额外 readdir);深层目录逐级进入;真实名为
  「未分类」的目录按普通目录处理,不与根层书合并。
- 测试:shelf_group_smoke 63→99 断言;M27 的按第一级分组断言改写为「逐级进入后可见该书」
  (同等强度),新增进入/返回/面包屑/计数/空目录/纯内存/rowcap/UTF-8 截断断言。
- 交付 0.1.7/code8(M32 已占用任务书里的 0.1.6/code7);真机 0.1.4 未被覆盖。
2026-11-10 19:26:44 +08:00

422 lines
16 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 模式 / 宫格 / M33 folder 导航视图)
* sub_folder = 相对 book.group 第一级之后的子路径 · sub(M33 起导航视图不再显示,保留兼容)
*/
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);
}
/* ---- 行序列 ---- */
/* format 模式:按格式分段(TXT/FB2/EPUB/MOBI/CBZ,空格式不出段头)——与 M27 前逐行一致。 */
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].dir = 0; rows[nrows].fmt = fmt;
rows[nrows].book = -1; rows[nrows].count = 0;
rows[nrows].name[0] = 0; rows[nrows].info[0] = 0;
rows[nrows].label = NULL;
nrows++;
have = 1;
}
if (nrows >= rowcap) break;
rows[nrows].section = 0; rows[nrows].dir = 0; rows[nrows].fmt = fmt;
rows[nrows].book = i; rows[nrows].count = 0;
rows[nrows].name[0] = 0; rows[nrows].info[0] = 0;
rows[nrows].label = NULL;
nrows++;
}
}
return nrows;
}
/* group 相对 cwd 的剩余路径:group==cwd → ""(书直接在当前层);group 在 cwd 下 → 去掉
* "cwd/" 前缀的剩余部分;不在 cwd 子树内 → NULL。只做组件边界匹配(a/b 不匹配 a/bc)。 */
static const char *rel_under_cwd(const char *group, const char *cwd)
{
if (!cwd || !cwd[0]) return group;
size_t n = strlen(cwd);
if (strncmp(group, cwd, n) != 0) return NULL;
if (group[n] == '/') return group + n + 1;
if (group[n] == 0) return group + n; /* 直接属于 cwd:剩余为空串 */
return NULL; /* "a/bc" 不属于 "a/b" */
}
/* 目录行排序:子目录名不区分大小写;同名前缀再按字节序(与 M27 的组名排序口径一致)。 */
static int nav_name_cmp(const void *pa, const void *pb)
{
const shelf_row *a = (const shelf_row *)pa, *b = (const shelf_row *)pb;
int c = strcasecmp(a->name, b->name);
if (c) return c;
return strcmp(a->name, b->name);
}
/* folder 模式:当前目录视图(纯内存,只读 books[].group)。
* 步骤:①把所有「更深的书」按第一段子目录名各记一行(count 先记 1)②按名排序
* ③相邻同名合并(count 累加 = 子树书数)④追加直接属于 cwd 的书(沿用 books[] 全局顺序)。
* 目录行借用 rows[] 自身做排序/合并,不额外占内存,也不读盘。 */
static int build_rows_folder_at(const shelf_book *books, int nbooks, const char *cwd,
shelf_row *rows, int rowcap)
{
int nd = 0;
for (int i = 0; i < nbooks && nd < rowcap; i++) {
const char *rel = rel_under_cwd(books[i].group, cwd);
if (!rel || !rel[0]) continue; /* 不在本层/直接属于本层的书后处理 */
const char *slash = strchr(rel, '/');
int len = slash ? (int)(slash - rel) : (int)strlen(rel);
if (len <= 0) continue;
if (len >= (int)sizeof rows[nd].name) len = (int)sizeof rows[nd].name - 1;
shelf_row *r = &rows[nd];
memcpy(r->name, rel, (size_t)len);
r->name[len] = 0;
r->section = 0; r->dir = 1; r->fmt = -1; r->book = -1;
r->count = 1; r->info[0] = 0; r->label = NULL;
nd++;
}
qsort(rows, (size_t)nd, sizeof rows[0], nav_name_cmp);
/* 合并同名(exFAT 大小写不敏感;扫描可能把同一目录的不同拼写带进来)。 */
int w = 0;
for (int i = 0; i < nd; i++) {
if (w > 0 && strcasecmp(rows[w - 1].name, rows[i].name) == 0) {
rows[w - 1].count += rows[i].count;
} else {
if (w != i) rows[w] = rows[i];
w++;
}
}
nd = w;
for (int i = 0; i < nd; i++)
snprintf(rows[i].info, sizeof rows[i].info, "%d 本", rows[i].count);
/* 本层直接书:book 下标指回 books[],path/进度键不受影响。 */
for (int i = 0; i < nbooks && nd < rowcap; i++) {
const char *rel = rel_under_cwd(books[i].group, cwd);
if (!rel || rel[0]) continue;
shelf_row *r = &rows[nd];
r->section = 0; r->dir = 0; r->fmt = books[i].fmt; r->book = i;
r->count = 0; r->name[0] = 0; r->info[0] = 0; r->label = NULL;
nd++;
}
return nd;
}
int shelf_build_rows_at(const shelf_book *books, int nbooks, int folder_mode,
const char *cwd, 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_at(books, nbooks, cwd ? cwd : "", rows, rowcap)
: build_rows_format(books, nbooks, rows, rowcap);
}
int shelf_build_rows(const shelf_book *books, int nbooks, int folder_mode,
shelf_row *rows, int rowcap)
{
return shelf_build_rows_at(books, nbooks, folder_mode, "", rows, rowcap);
}
/* ---- 目录导航工具 ---- */
int shelf_nav_count_under(const shelf_book *books, int nbooks, const char *cwd)
{
if (!books || nbooks <= 0) return 0;
if (nbooks > SHELF_MAX_BOOKS) nbooks = SHELF_MAX_BOOKS;
int n = 0;
for (int i = 0; i < nbooks; i++)
if (rel_under_cwd(books[i].group, cwd ? cwd : "")) n++;
return n;
}
int shelf_nav_enter(const char *cwd, const char *name, char *out, int outsz)
{
if (!out || outsz <= 0) return 0;
if (!name || !name[0]) return 0;
size_t lc = (cwd && cwd[0]) ? strlen(cwd) : 0;
size_t ln = strlen(name);
size_t need = lc + (lc ? 1 : 0) + ln + 1;
if (need > (size_t)outsz) return 0; /* 超长:不写 out(不产生半截路径) */
int n;
if (lc) n = snprintf(out, (size_t)outsz, "%s/%s", cwd, name);
else n = snprintf(out, (size_t)outsz, "%s", name);
return (n > 0 && n < outsz) ? 1 : 0;
}
int shelf_nav_up(const char *cwd, char *out, int outsz)
{
if (!out || outsz <= 0) return 0;
if (!cwd || !cwd[0]) { out[0] = 0; return 0; }
const char *slash = strrchr(cwd, '/');
int len = slash ? (int)(slash - cwd) : 0;
if (len >= outsz) len = outsz - 1;
memcpy(out, cwd, (size_t)len);
out[len] = 0;
return 1;
}
int shelf_breadcrumb(const char *cwd, char *out, int outsz)
{
if (!out || outsz <= 0) return 0;
char full[400];
int n;
if (cwd && cwd[0]) n = snprintf(full, sizeof full, "Books/%s", cwd);
else n = snprintf(full, sizeof full, "Books/");
if (n < 0) { out[0] = 0; return 0; }
if (n < outsz) {
memcpy(out, full, (size_t)n + 1);
return n;
}
if (outsz <= 4) { out[0] = 0; return 0; }
/* 超长:保留末尾(当前目录更重要),前缀 "…"(3 字节 UTF-8)。 */
int keep = outsz - 1 - 3;
int start = n - keep;
if (start < 0) start = 0;
while (start < n && ((unsigned char)full[start] & 0xC0) == 0x80) start++;
int len = n - start;
if (len > outsz - 1 - 3) len = outsz - 1 - 3;
memcpy(out, "…", 3);
memcpy(out + 3, full + start, (size_t)len);
out[3 + len] = 0;
return 3 + len;
}
int shelf_dir_name(const char *cwd, char *out, int outsz)
{
if (!out || outsz <= 0) return 0;
out[0] = 0;
if (!cwd || !cwd[0]) return 0;
const char *slash = strrchr(cwd, '/');
return shelf_copy_utf8(out, outsz, slash ? slash + 1 : cwd);
}