- 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 未被覆盖。
422 lines
16 KiB
C
422 lines
16 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 模式 / 宫格 / 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);
|
||
}
|