diff --git a/patch_v2/reader_app/.gitignore b/patch_v2/reader_app/.gitignore index f79b20a..6bceb12 100644 --- a/patch_v2/reader_app/.gitignore +++ b/patch_v2/reader_app/.gitignore @@ -6,6 +6,9 @@ /test/loaders_smoke_host /test/seg_smoke_host /test/chapter_smoke_host +/test/shelf_group_smoke_host +/test/shelf_scan_bench_host +/test/shelf_scan_bench_mipsel /test/rdft_smoke_mipsel /test/book_smoke_mipsel /test/formats_smoke_mipsel diff --git a/patch_v2/reader_app/app/reader_conf.c b/patch_v2/reader_app/app/reader_conf.c index c12a640..9952454 100644 --- a/patch_v2/reader_app/app/reader_conf.c +++ b/patch_v2/reader_app/app/reader_conf.c @@ -44,6 +44,7 @@ static void conf_defaults(rdconf *c) strcpy(c->bg, "black"); /* B 定案:默认纯黑 */ strcpy(c->theme, "auto"); strcpy(c->shelf, "list"); /* A 定案:默认 list */ + strcpy(c->shelf_group, "format");/* M27:默认按格式,升级不改变现有用户书架 */ c->header = 1; c->footer = 1; c->font[0] = 0; @@ -72,6 +73,7 @@ rdconf *rdconf_get(void) *eq = 0; const char *k = line, *v = eq + 1; if (!strcmp(k, "ui.shelf_layout")) { strncpy(g.shelf, v, sizeof g.shelf - 1); g.shelf[sizeof g.shelf - 1] = 0; } + else if (!strcmp(k, "ui.shelf_group")) { strncpy(g.shelf_group, v, sizeof g.shelf_group - 1); g.shelf_group[sizeof g.shelf_group - 1] = 0; } else if (!strcmp(k, "rd.fs")) { int t = atoi(v); if (t >= 14 && t <= 40) g.fs = t; } else if (!strcmp(k, "rd.ls")) { int t = atoi(v); if (t >= 100 && t <= 200) g.ls = t; } else if (!strcmp(k, "rd.bg")) { strncpy(g.bg, v, sizeof g.bg - 1); g.bg[sizeof g.bg - 1] = 0; } @@ -84,11 +86,14 @@ rdconf *rdconf_get(void) } /* 校验合法取值(越界回默认/回退),避免脏档破坏 UI */ if (strcmp(g.shelf, "grid") && strcmp(g.shelf, "list")) strcpy(g.shelf, "list"); + if (strcmp(g.shelf_group, "folder") && strcmp(g.shelf_group, "format")) + strcpy(g.shelf_group, "format"); if (strcmp(g.bg, "black") && strcmp(g.bg, "white") && strcmp(g.bg, "sepia") && strcmp(g.bg, "auto")) strcpy(g.bg, "black"); if (strcmp(g.theme, "light") && strcmp(g.theme, "dark") && strcmp(g.theme, "auto")) strcpy(g.theme, "auto"); clamp_str(g.shelf, sizeof g.shelf); + clamp_str(g.shelf_group, sizeof g.shelf_group); clamp_str(g.bg, sizeof g.bg); clamp_str(g.theme, sizeof g.theme); clamp_str(g.font, sizeof g.font); @@ -105,6 +110,7 @@ int rdconf_persist(void) FILE *f = fopen(path, "wb"); if (!f) return -5; fprintf(f, "ui.shelf_layout=%s\n", c->shelf); + fprintf(f, "ui.shelf_group=%s\n", c->shelf_group); fprintf(f, "rd.fs=%d\n", c->fs); fprintf(f, "rd.ls=%d\n", c->ls); fprintf(f, "rd.bg=%s\n", c->bg); diff --git a/patch_v2/reader_app/app/reader_conf.h b/patch_v2/reader_app/app/reader_conf.h index 203ba5f..30e8fb0 100644 --- a/patch_v2/reader_app/app/reader_conf.h +++ b/patch_v2/reader_app/app/reader_conf.h @@ -5,6 +5,7 @@ * * 键(本模块常量): * ui.shelf_layout = list|grid 书架默认布局(shelf.c 读) + * ui.shelf_group = format|folder 书架分段:按格式(默认)或按目录(M27) * rd.fs = int (14..40) 全局默认字号(无进度书时 reader_open 用) * rd.ls = int (100..200) 全局默认行距 * rd.bg = auto|black|white|sepia 阅读页底色预设(默认 black;B 定案) @@ -28,6 +29,7 @@ typedef struct rdconf { char bg[8]; /* auto|black|white|sepia */ char theme[8]; /* auto|light|dark */ char shelf[8]; /* list|grid */ + char shelf_group[10]; /* format|folder(默认 format=旧行为) */ int header; /* 1=阅读页顶条 */ int footer; /* 1=阅读页底条 */ char font[400]; /* 字体路径(空=自动) */ diff --git a/patch_v2/reader_app/app/settings.c b/patch_v2/reader_app/app/settings.c index 84d38fd..dfd92d3 100644 --- a/patch_v2/reader_app/app/settings.c +++ b/patch_v2/reader_app/app/settings.c @@ -1,7 +1,7 @@ /* app/settings.c — 全局设置页(C,书架顶栏「设置」入口 → 独立页 ACT_SETTINGS)。 * * 配置项(MVP,见 READER_UI_DESIGN §4.3):默认字号/默认行距/默认背景(黑白米黄)/主题(亮暗跟随 - * 全局)/书架布局(list-grid)/页头条/页底条。全部存 App 私有 reader.conf(rdconf),不进系统 + * 全局)/书架布局(list-grid)/书架分组(格式-目录)/页头条/页底条。全部存 App 私有 reader.conf(rdconf),不进系统 * settings.conf。UI:meow_topappbar(返回+标题) + meow_listview 行(每行 label + 当前值尾部箭头, * 点行开 meow_dialog(SELECT) 单选;值即改即存)。 */ @@ -23,13 +23,14 @@ static int g_active; /* 行项枚举(顺序 = 列表顺序)。ITEM_BASE = 固定的全局项数;重解析本书章节仅在 * 已打开一本「支持章节解析」(TXT+seg 模式)的书时追加(见 settings_on_enter)。 */ enum { - ITEM_FS = 0, ITEM_LS, ITEM_BG, ITEM_THEME, ITEM_SHELF, ITEM_HDR, ITEM_FTR, + ITEM_FS = 0, ITEM_LS, ITEM_BG, ITEM_THEME, ITEM_SHELF, ITEM_GROUP, ITEM_HDR, ITEM_FTR, ITEM_REPARSE, ITEM_COUNT }; -#define ITEM_BASE ITEM_REPARSE /* 7:不含重解析的固定项数 */ +#define ITEM_BASE ITEM_REPARSE /* 8:不含重解析的固定项数 */ static int g_nitems = ITEM_BASE; static const char *const g_labels[ITEM_COUNT] = { - "默认字号", "默认行距", "默认背景", "主题", "书架布局", "页头条", "页底条", "重解析本书章节" + "默认字号", "默认行距", "默认背景", "主题", "书架布局", "书架分组", + "页头条", "页底条", "重解析本书章节" }; static void settings_redraw(void); @@ -53,6 +54,11 @@ static const char *shelf_label(const rdconf *c) if (!c) return "列表"; return (!strcmp(c->shelf, "grid")) ? "宫格" : "列表"; } +static const char *group_label(const rdconf *c) +{ + if (!c) return "格式"; + return (!strcmp(c->shelf_group, "folder")) ? "目录" : "格式"; +} /* 当前行值(subtitle)。 */ static void item_value(int item, char *out, int outsz) @@ -65,6 +71,7 @@ static void item_value(int item, char *out, int outsz) case ITEM_BG: snprintf(out, (size_t)outsz, "%s", bg_label(c)); break; case ITEM_THEME: snprintf(out, (size_t)outsz, "%s", theme_label(c)); break; case ITEM_SHELF: snprintf(out, (size_t)outsz, "%s", shelf_label(c)); break; + case ITEM_GROUP: snprintf(out, (size_t)outsz, "%s", group_label(c)); break; case ITEM_HDR: snprintf(out, (size_t)outsz, "%s", c->header ? "显示" : "隐藏"); break; case ITEM_FTR: snprintf(out, (size_t)outsz, "%s", c->footer ? "显示" : "隐藏"); break; case ITEM_REPARSE: snprintf(out, (size_t)outsz, "%s", "点按重新扫描章节"); break; @@ -105,6 +112,10 @@ static int idx_for(int item, const char *const *opts, int n, const rdconf *c) for (int i = 0; i < n; i++) if (!strcmp(opts[i], shelf_label(c))) return i; return 0; } + if (item == ITEM_GROUP) { + for (int i = 0; i < n; i++) if (!strcmp(opts[i], group_label(c))) return i; + return 0; + } if (item == ITEM_HDR) return c->header ? 0 : 1; if (item == ITEM_FTR) return c->footer ? 0 : 1; return 0; @@ -144,6 +155,9 @@ static void apply_choice(int item, int sel) case ITEM_SHELF: shelf_set_view(sel ? 1 : 0); /* 写 rdconf + 重建书架 */ return; + case ITEM_GROUP: + shelf_set_group(sel ? 1 : 0); /* 写 ui.shelf_group + 重建书架(不改 path) */ + return; case ITEM_HDR: c->header = sel ? 0 : 1; break; /* 0 显示 1 隐藏 */ case ITEM_FTR: c->footer = sel ? 0 : 1; break; default: return; @@ -175,6 +189,9 @@ static void settings_open_choice(int item) } else if (item == ITEM_SHELF) { static const char *s[2] = { "列表", "宫格" }; for (int i = 0; i < 2; i++) opts[n++] = s[i]; + } else if (item == ITEM_GROUP) { + static const char *g[2] = { "格式", "目录" }; + for (int i = 0; i < 2; i++) opts[n++] = g[i]; } else if (item == ITEM_HDR || item == ITEM_FTR) { static const char *h[2] = { "显示", "隐藏" }; for (int i = 0; i < 2; i++) opts[n++] = h[i]; diff --git a/patch_v2/reader_app/app/shelf.c b/patch_v2/reader_app/app/shelf.c index 3ff5dcb..1c0e729 100644 --- a/patch_v2/reader_app/app/shelf.c +++ b/patch_v2/reader_app/app/shelf.c @@ -4,151 +4,73 @@ * 布局(README §3.2,用户 2026-09-04 拍板): * - 默认 list(meow_listview_set_container(on) MD3 圆角卡),可切 grid(meow_lazygrid 4 列封面卡); * 布局存 reader.conf `ui.shelf_layout`(rdconf_get()->shelf); - * - 顶栏 meow_topappbar:标题「书架」+ 动作(布局切换 / 刷新 / App 设置入口); + * - 顶栏 meow_topappbar:标题「书架」+ 动作(布局切换 / 分组切换 / 刷新 / App 设置入口); * - 无书也渲染完整骨架(顶栏 + 内容区 + 居中空态卡 + 指引 + 支持格式); * - 封面:MVP 无封面提取(engine F1);无则圆角占位(surfaceContainerHigh + 格式徽标)。 + * + * M27 分组(`ui.shelf_group`,默认 format 保持旧行为): + * format = 按格式分段(TXT/FB2/EPUB/MOBI/CBZ),folder = 按 Books/ 下的目录分段(根下=未分类)。 + * 扫描/行序列在 app/shelf_model.c(纯 C,宿主可测);本文件只做控件装配与绘制。 */ #include "../reader_internal.h" #include "progress.h" #include "reader_conf.h" +#include "shelf_model.h" #include "meow_ui_internal.h" /* meow_gfx_fill_round(宫格卡圆角填充) */ -#include #include #include #include -#include #define SHELF_APPBAR_H 56 #define SHELF_LIST_X 0 #define SHELF_LIST_W 480 -typedef struct shelf_book { - char path[400]; - char title[128]; - char sub[160]; - int fmt; /* 0 txt,1 fb2,2 epub,3 mobi,4 cbz */ - long size; - uint64_t ts; /* 最近阅读 unix;无=0 */ -} shelf_book; - -static shelf_book g_books[256]; +static shelf_book g_books[SHELF_MAX_BOOKS]; static int g_nbooks; static int g_active; static meow_widget *g_list; /* listview(布局=list) */ static meow_lazygrid *g_grid; /* lazygrid(布局=grid) */ static meow_widget *g_appbar; /* meow_topappbar */ static int g_layout; /* 0=list 1=grid(读 rdconf->shelf) */ +static int g_group_folder;/* 0=format 1=folder(读 rdconf->shelf_group) */ static uint32_t g_bg, g_fg, g_dim; -static char g_notice[160]; /* 提示(opendir 失败等) */ +static char g_notice[160]; /* 提示(opendir 失败/超上限截断等) */ static int g_notice_ms; /* 剩余显示 ms(0=无) */ -/* 段头行(list 模式):把扫描结果排队为 [段头(fmt), 书, 书, ..., 段头, ...]。 - * 书量小用段头(README §3.2),格式分组(TXT/FB2/EPUB/MOBI/CBZ)。 */ -#define SHELF_MAX_ROWS 520 -typedef struct shelf_row { char section; int fmt; int book; } shelf_row; +/* 段头行(list 模式):[段头, 书, 书, ..., 段头, ...];SHELF_MAX_ROWS=2×书上限(见 shelf_model.h)。 */ static shelf_row g_rows[SHELF_MAX_ROWS]; static int g_nrows; -static const char *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 "?"; } -} -static int 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; -} -static void fmt_size(char *out, int outsz, long s) -{ - if (s >= 1 << 20) snprintf(out, outsz, "%.1fMB", (double)s / (1 << 20)); - else if (s >= 1 << 10) snprintf(out, outsz, "%.0fKB", (double)s / (1 << 10)); - else snprintf(out, outsz, "%ldB", s); -} +static const char *fmt_name(int f) { return shelf_fmt_name(f); } -static int cmp_recent_then_name(const void *a, const void *b) +/* 进度时间戳(分组/排序用;不改 path,故进度键不受分组影响)。 */ +static uint64_t shelf_ts_of(const char *path) { - 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); -} - -/* 递归收集 Books/ 下(含子目录,深度 ≤2)的书。只在 scan_books 里调用。 */ -static void scan_dir(const char *dir, int depth) -{ - if (depth > 2) return; /* 防深嵌套/循环 */ - DIR *dp = opendir(dir); - if (!dp) return; - struct dirent *de; - while ((de = readdir(dp)) && g_nbooks < 256) { - if (de->d_name[0] == '.') continue; - char path[256]; - 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)) { scan_dir(path, depth + 1); continue; } - int f = fmt_from_ext(de->d_name); - if (f < 0) continue; - shelf_book *b = &g_books[g_nbooks]; - snprintf(b->path, sizeof b->path, "%s", path); - strncpy(b->title, de->d_name, sizeof b->title - 1); b->title[sizeof b->title - 1] = 0; - char *dot = strrchr(b->title, '.'); - if (dot) *dot = 0; /* 标题去扩展名 */ - b->fmt = f; - b->size = (long)st.st_size; - rdprog p; - b->ts = (rdprog_load(b->path, &p) == 1) ? p.ts : 0; - char sz[24]; fmt_size(sz, sizeof sz, b->size); - snprintf(b->sub, sizeof b->sub, "%s · %s%s", fmt_name(f), sz, - b->ts ? " · 读" : ""); - g_nbooks++; - } - closedir(dp); + rdprog p; + return (rdprog_load(path, &p) == 1) ? p.ts : 0; } static void scan_books(void) { const char *dir = READER_SD_ROOT "/Books"; - g_nbooks = 0; - DIR *dp = opendir(dir); - if (!dp) { + g_notice[0] = 0; + g_notice_ms = 0; + shelf_scan_report rep; + g_nbooks = shelf_scan_books(dir, g_books, SHELF_MAX_BOOKS, shelf_ts_of, &rep); + if (rep.root_missing) { snprintf(g_notice, sizeof g_notice, "未找到 %s 目录,请放入书籍(TXT)", dir); g_notice_ms = 4000; - return; + } else if (rep.truncated) { + snprintf(g_notice, sizeof g_notice, "书籍超过 %d 本,已只显示前 %d 本", + SHELF_MAX_BOOKS, SHELF_MAX_BOOKS); + g_notice_ms = 4000; } - closedir(dp); - scan_dir(dir, 0); /* 递归:含 Books/<子目录>/ 下书籍(用户可能按目录组织) */ - qsort(g_books, (size_t)g_nbooks, sizeof(shelf_book), cmp_recent_then_name); } -/* 排队段头(按格式):为 list 模式生成 [段头(fmt) | 书] 序列。 */ +/* 排队行:folder 模式按目录分段,否则按格式(默认)。 */ static void build_rows(void) { - g_nrows = 0; - static const int fmts[] = { 0, 1, 2, 3, 4 }; - for (size_t fi = 0; fi < sizeof fmts / sizeof fmts[0]; fi++) { - int fmt = fmts[fi]; - int have = 0; - if (g_nrows >= SHELF_MAX_ROWS) break; - for (int i = 0; i < g_nbooks; i++) { - if (g_books[i].fmt != fmt) continue; - if (!have) { - if (g_nrows >= SHELF_MAX_ROWS) break; - g_rows[g_nrows].section = 1; g_rows[g_nrows].fmt = fmt; g_rows[g_nrows].book = -1; - g_nrows++; - have = 1; - } - if (g_nrows >= SHELF_MAX_ROWS) break; - g_rows[g_nrows].section = 0; g_rows[g_nrows].fmt = fmt; g_rows[g_nrows].book = i; - g_nrows++; - } - } + g_nrows = shelf_build_rows(g_books, g_nbooks, g_group_folder, g_rows, SHELF_MAX_ROWS); } /* ---- list 数据源(vsource + 段头;container on) ---- */ @@ -168,7 +90,7 @@ static void shelf_list_bind(int idx, meow_lv_item *item, void *ctx) if (idx < 0 || idx >= g_nrows) return; if (g_rows[idx].section) { item->section = 1; - item->title = fmt_name(g_rows[idx].fmt); + item->title = g_rows[idx].label ? g_rows[idx].label : fmt_name(g_rows[idx].fmt); item->enabled = 0; } else { int bi = g_rows[idx].book; @@ -283,18 +205,19 @@ static void shelf_build_content(void) (void)th; } -/* 顶栏动作:0=布局切换 1=刷新 2=设置。布局切换动作标签 = 将切换到的布局。 */ +/* 顶栏动作:0=布局切换 1=分组切换 2=刷新 3=设置。动作标签 = 将切换到的模式。 */ static void shelf_appbar_build(void) { int sbh = meow_ui_statusbar_height(); if (g_appbar) { meow_widget_free(g_appbar); g_appbar = NULL; } g_appbar = meow_topappbar_create(0, sbh, 480, SHELF_APPBAR_H, "书架"); if (!g_appbar) return; - meow_topappbar_add_action(g_appbar, g_layout ? "列表" : "宫格"); /* 0 布局切换 */ - meow_topappbar_add_action(g_appbar, "刷新"); /* 1 刷新 */ - meow_topappbar_add_action(g_appbar, "设置"); /* 2 设置 */ - meow_topappbar_set_item_icon(g_appbar, 1, MEOW_ICON_REFRESH); - meow_topappbar_set_item_icon(g_appbar, 2, MEOW_ICON_SETTINGS); + meow_topappbar_add_action(g_appbar, g_layout ? "列表" : "宫格"); /* 0 布局切换 */ + meow_topappbar_add_action(g_appbar, g_group_folder ? "格式" : "目录"); /* 1 分组切换 */ + meow_topappbar_add_action(g_appbar, "刷新"); /* 2 刷新 */ + meow_topappbar_add_action(g_appbar, "设置"); /* 3 设置 */ + meow_topappbar_set_item_icon(g_appbar, 2, MEOW_ICON_REFRESH); + meow_topappbar_set_item_icon(g_appbar, 3, MEOW_ICON_SETTINGS); } /* view 生命周期 */ @@ -303,15 +226,15 @@ static void shelf_on_enter(meow_view *v, void *ctx) { (void)v; (void)ctx; g_active = 1; + /* 先读配置(build_rows 依赖分组模式),再扫描/排队。 */ + const rdconf *c = rdconf_get(); + g_layout = (c && c->shelf[0] && strcmp(c->shelf, "grid") == 0) ? 1 : 0; + g_group_folder = (c && strcmp(c->shelf_group, "folder") == 0) ? 1 : 0; scan_books(); /* 回到书架即重扫(新增/最近读置顶刷新) */ build_rows(); const meow_theme *th = meow_theme_get(); g_bg = th->bg; g_fg = th->fg; g_dim = th->dim; - /* 布局:读 reader.conf ui.shelf_layout(A) */ - const rdconf *c = rdconf_get(); - g_layout = (c && c->shelf[0] && strcmp(c->shelf, "grid") == 0) ? 1 : 0; - shelf_appbar_build(); shelf_build_content(); shelf_redraw(); @@ -349,6 +272,23 @@ void shelf_set_view(int grid) } } +/* 切换 format/folder(顶栏「分组」动作 / 设置页「书架分组」;改 reader.conf 并重建)。 + * 只改分组模式:不碰 books[].path,故进度键 rdprog_hash(path) 不变。 */ +void shelf_set_group(int folder) +{ + rdconf *c = rdconf_get(); + if (!c) return; + strcpy(c->shelf_group, folder ? "folder" : "format"); + rdconf_persist(); + g_group_folder = folder ? 1 : 0; + if (g_active) { + build_rows(); + shelf_appbar_build(); + shelf_build_content(); + shelf_redraw(); + } +} + void shelf_redraw(void) { const meow_theme *th = meow_theme_get(); @@ -356,9 +296,12 @@ void shelf_redraw(void) meow_ui_clear(g_bg); if (g_appbar) meow_widget_draw(g_appbar); - /* 副题:Books/ · N 本(有书时显示格式说明;无书并入空态卡) */ + /* 副题:Books/ · N 本 [· 按目录](分组只作用于列表;宫格仍是平铺封面墙) */ if (g_nbooks > 0) { - char sub[96]; snprintf(sub, sizeof sub, "Books/ · %d 本", g_nbooks); + char sub[96]; + if (g_group_folder && g_layout == 0) + snprintf(sub, sizeof sub, "Books/ · %d 本 · 按目录", g_nbooks); + else snprintf(sub, sizeof sub, "Books/ · %d 本", g_nbooks); int sbh = meow_ui_statusbar_height(); meow_ui_text(24, sbh + SHELF_APPBAR_H + 3, sub, 16, g_dim); } @@ -397,12 +340,15 @@ int shelf_on_tap(int x, int y) if (bi == 0) { /* 布局切换 */ shelf_set_view(g_layout ? 0 : 1); return 1; - } else if (bi == 1) { /* 刷新:重扫 */ + } else if (bi == 1) { /* 分组切换:format ↔ folder */ + shelf_set_group(g_group_folder ? 0 : 1); + return 1; + } else if (bi == 2) { /* 刷新:重扫 */ scan_books(); build_rows(); shelf_build_content(); shelf_redraw(); return 1; - } else if (bi == 2) { /* App 设置入口 */ + } else if (bi == 3) { /* App 设置入口 */ meow_view_page_push(ACT_SETTINGS); return 1; } diff --git a/patch_v2/reader_app/app/shelf_model.c b/patch_v2/reader_app/app/shelf_model.c new file mode 100644 index 0000000..dd5b4d8 --- /dev/null +++ b/patch_v2/reader_app/app/shelf_model.c @@ -0,0 +1,222 @@ +/* app/shelf_model.c — 书架数据模型实现(见 app/shelf_model.h)。 */ +#include "shelf_model.h" + +#include +#include +#include +#include +#include + +#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); +} diff --git a/patch_v2/reader_app/app/shelf_model.h b/patch_v2/reader_app/app/shelf_model.h new file mode 100644 index 0000000..7e35fdc --- /dev/null +++ b/patch_v2/reader_app/app/shelf_model.h @@ -0,0 +1,73 @@ +/* app/shelf_model.h — 书架数据模型:扫描 Books/ + 生成行序列(纯 C,不依赖 SDK/设备)。 + * + * 从 shelf.c 拆出,供 shelf.c(控件装配)、宿主单测(test/shelf_group_smoke.c)与 + * 扫描计时(test/shelf_scan_bench.c)共用。 + * + * 目录归属(M27):folder 模式按「相对 Books/ 的目录路径」分组,如 "轻小说"、"科幻/长篇"; + * Books/ 根下的书 group="",显示段头 SHELF_GROUP_ROOT。分组键是相对路径(不是目录名), + * 故 Books/a/b 与 Books/b 不会混。 + * + * 硬约束:path 永远是书的完整路径(打开书 + 进度键 rdprog_hash(path)),分组只写 group, + * 绝不改写 path;两个分组模式间切换不影响进度。 + */ +#ifndef SHELF_MODEL_H +#define SHELF_MODEL_H + +#include + +/* 书籍上限:真实场景 180 本(Books/轻小说),2048 给 11× 余量。 + * 超限不再静默停扫:report->truncated=1,由 UI 出提示。 */ +#define SHELF_MAX_BOOKS 2048 +#define SHELF_GROUP_MAX 96 /* 目录字段容量(含 NUL):95 字节 ≈ 31 个汉字 */ +/* 行数上限:每本书最多贡献 1 行书 + 1 行段头(段头数 ≤ 书数),故 2N 必够。 */ +#define SHELF_MAX_ROWS (SHELF_MAX_BOOKS * 2) +#define SHELF_GROUP_ROOT "未分类" /* Books/ 根下书籍的段头 */ + +typedef struct shelf_book { + char path[400]; /* 完整路径:打开书 + 进度键;分组不得改写 */ + char title[128]; + char sub[160]; + char group[SHELF_GROUP_MAX]; /* 相对 Books/ 的目录路径;根下="" */ + int fmt; /* 0 txt,1 fb2,2 epub,3 mobi,4 cbz */ + long size; + uint64_t ts; /* 最近阅读 unix;无=0 */ +} shelf_book; + +typedef struct shelf_row { + int section; /* 1=段头行 */ + int fmt; /* 段头/书行格式;段头无 label 时显示格式名 */ + int book; /* 书行=books 下标;段头=-1 */ + const char *label; /* 段头标题;NULL=格式名(指向 books[].group 或静态串) */ +} shelf_row; + +/* 进度时间戳查询(生产=rdprog_load;测试/基线=NULL,全部 ts=0)。 */ +typedef uint64_t (*shelf_ts_fn)(const char *path); + +typedef struct shelf_scan_report { + int nbooks; + int truncated; /* 1=有书因 SHELF_MAX_BOOKS 上限被丢弃 */ + int root_missing; /* 1=root 打不开(无卡/无 Books 目录) */ +} shelf_scan_report; + +/* 递归扫描 root(子目录深度 ≤2,与旧行为一致),按 ts 降序/书名升序排序后写 books(容量 cap)。 + * 返回写入本数。目录为空不产生任何记录(段头由 build_rows 按书生成,天然无空段头)。 */ +int shelf_scan_books(const char *root, shelf_book *books, int cap, + shelf_ts_fn ts_fn, shelf_scan_report *rep); + +/* 生成行序列: + * folder_mode=0:按格式分段(TXT/FB2/EPUB/MOBI/CBZ,空格式不出段头)——与 M27 前逐行一致; + * folder_mode=1:先按目录分组,根目录组「未分类」置顶,其余组按目录名排序; + * 组内沿用 books[] 的全局顺序(最近阅读优先、其次书名)。 + * 返回行数;超过 rowcap 时只写 rowcap 行并返回 rowcap。 */ +int shelf_build_rows(const shelf_book *books, int nbooks, int folder_mode, + shelf_row *rows, int rowcap); + +/* 工具(单测直接调用)。 */ +int shelf_fmt_from_ext(const char *name); +const char *shelf_fmt_name(int fmt); +void shelf_fmt_size(char *out, int outsz, long size); +/* UTF-8 安全截断:最多写 cap-1 字节;若截断点落在多字节序列中间,整段丢弃该残缺字符。 + * 返回写入字节数(不含 NUL)。 */ +int shelf_copy_utf8(char *dst, int cap, const char *src); + +#endif /* SHELF_MODEL_H */ diff --git a/patch_v2/reader_app/build_reader.sh b/patch_v2/reader_app/build_reader.sh index 748659a..12033fc 100755 --- a/patch_v2/reader_app/build_reader.sh +++ b/patch_v2/reader_app/build_reader.sh @@ -21,6 +21,17 @@ SYSROOT="$ROOT/.tools/mipsel-linux-muslsf-cross/mipsel-linux-muslsf" HOST_CC="${HOST_CC:-gcc}" VENDOR="$PWD/vendor" OBJ="$VENDOR/build" +# qemu-mipsel 运行时冒烟:宿主没装 qemu(如 Termux 无 mipsel user-mode 包)时, +# 仍编译 mipsel 冒烟二进制,只跳过执行;设 QEMU_BIN=<路径> 可指定其它模拟器。 +QEMU_BIN="${QEMU_BIN:-qemu-mipsel}" +run_qemu() { + if [ "${SKIP_QEMU:-0}" = 1 ] || ! command -v "$QEMU_BIN" >/dev/null 2>&1; then + shift + echo " SKIP qemu 执行(未找到 $QEMU_BIN):$*" + return 0 + fi + timeout "$@" +} mkdir -p "$OBJ" # ---------- 1) vendor 拉取/对齐 ---------- @@ -101,6 +112,13 @@ echo "==> host chapter test (R3.3)" $HOST_CC -O2 -Wall -Wextra -Icore test/chapter_smoke.c core/rdchapter.c core/seg.c core/rdenc.c -o test/chapter_smoke_host -lm ./test/chapter_smoke_host +echo "==> host shelf group test (M27)" +$HOST_CC -O2 -Wall -Wextra -Iapp -I../sdk test/shelf_group_smoke.c app/shelf_model.c app/progress.c app/reader_conf.c -o test/shelf_group_smoke_host +./test/shelf_group_smoke_host + +echo "==> host shelf scan bench (M27,只编不跑;用法 ./test/shelf_scan_bench_host [progress|noop] [轮数])" +$HOST_CC -O2 -Wall -Wextra -Iapp -I../sdk test/shelf_scan_bench.c app/shelf_model.c app/progress.c -o test/shelf_scan_bench_host + echo "==> host loaders test" HOBJ="$OBJ/host"; mkdir -p "$HOBJ" for f in vendor/miniz/miniz_tinfl.c vendor/miniz/miniz_tdef.c vendor/miniz/miniz_zip.c vendor/miniz/miniz.c; do @@ -120,28 +138,30 @@ $HOST_CC -O2 -Wall -Wextra $COREINC test/loaders_smoke.c "$HOBJ"/*.o -lz -lm -o # ---------- 5) qemu-mipsel FreeType 冒烟 ---------- echo "==> mipsel ft smoke" $CC -Os -static -Wall -Wextra -Iengine $FT_INCS test/rdft_smoke.c engine/rdft.c "$FT_BUILD/libfreetype.a" -o test/rdft_smoke_mipsel -timeout 120 qemu-mipsel ./test/rdft_smoke_mipsel "${FONT:-$ROOT/font/src/MiSans-Regular.otf}" +run_qemu 120 "$QEMU_BIN" ./test/rdft_smoke_mipsel "${FONT:-$ROOT/font/src/MiSans-Regular.otf}" # ---------- 6) qemu-mipsel 书桥冒烟(TXT + 四格式) ---------- +# rdimg.o 引用 stbi_*,实现只在 sdk/meow_ui.a 里(见步骤 3 注释);冒烟不链 UI 主体, +# 但必须链该归档才能解析 stbi_*(归档按需只拉 stb_image.o)。 echo "==> mipsel book smoke (TXT)" $CC -Os -static -Wall -Wextra -Iapp -Icore -Iengine $FT_INCS $COREINC test/book_smoke.c \ app/book.c core/rdenc.c core/rdlayout.c core/rdtxt.c core/seg.c core/rdchapter.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c \ core/rdsrc.c core/rdepub.c core/rdfb2.c core/rdmobi.c core/rdcbz.c engine/rdft.c \ - "$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -o test/book_smoke_mipsel -timeout 120 qemu-mipsel ./test/book_smoke_mipsel "${FONT:-$ROOT/font/src/MiSans-Regular.otf}" + "$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o ../sdk/meow_ui.a "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -o test/book_smoke_mipsel +run_qemu 120 "$QEMU_BIN" ./test/book_smoke_mipsel "${FONT:-$ROOT/font/src/MiSans-Regular.otf}" echo "==> mipsel formats smoke" $CC -Os -static -Wall -Wextra -Iapp -Icore -Iengine $FT_INCS $COREINC test/formats_smoke.c \ app/book.c core/rdenc.c core/rdlayout.c core/rdtxt.c core/seg.c core/rdchapter.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c \ core/rdsrc.c core/rdepub.c core/rdfb2.c core/rdmobi.c core/rdcbz.c engine/rdft.c \ - "$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -o test/formats_smoke_mipsel -timeout 150 qemu-mipsel ./test/formats_smoke_mipsel "${FONT:-$ROOT/font/src/MiSans-Regular.otf}" /tmp/rd_samples + "$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o ../sdk/meow_ui.a "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -o test/formats_smoke_mipsel +run_qemu 150 "$QEMU_BIN" ./test/formats_smoke_mipsel "${FONT:-$ROOT/font/src/MiSans-Regular.otf}" /tmp/rd_samples # ---------- 7) reader 二进制(mipsel 静态) ---------- echo "==> reader binary" CORE_L="core/rdenc.c core/rdlayout.c core/rdtxt.c core/seg.c core/rdchapter.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c core/rdsrc.c core/rdepub.c core/rdfb2.c core/rdmobi.c core/rdcbz.c" ENGINE_L="engine/rdft.c" -APP_L="app.c app/shelf.c app/reader.c app/book.c app/progress.c app/settings.c app/reader_conf.c app/reader_ipc.c" +APP_L="app.c app/shelf.c app/shelf_model.c app/reader.c app/book.c app/progress.c app/settings.c app/reader_conf.c app/reader_ipc.c" $CC -Os -static -Wall -Wextra -I../sdk -I../meow_launcherd -I. -Icore -Iapp -Iengine $FT_INCS $COREINC \ $CORE_L $APP_L $ENGINE_L "$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o \ ../sdk/meow_ui.a "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -lpthread -o reader diff --git a/patch_v2/reader_app/reader_internal.h b/patch_v2/reader_app/reader_internal.h index dd25308..69fef53 100644 --- a/patch_v2/reader_app/reader_internal.h +++ b/patch_v2/reader_app/reader_internal.h @@ -20,6 +20,7 @@ int shelf_on_tap(int x, int y); void shelf_on_swipe(int dir, int dx, int dy); void shelf_redraw(void); void shelf_set_view(int grid); /* 1=grid 0=list;读 reader.conf ui.shelf_layout(A) */ +void shelf_set_group(int folder); /* 1=folder 0=format;写 ui.shelf_group(M27) */ /* ---- 阅读页(app/reader.c)---- */ meow_view *reader_view_create(void); diff --git a/patch_v2/reader_app/test/shelf_group_smoke.c b/patch_v2/reader_app/test/shelf_group_smoke.c new file mode 100644 index 0000000..43835dd --- /dev/null +++ b/patch_v2/reader_app/test/shelf_group_smoke.c @@ -0,0 +1,303 @@ +/* test/shelf_group_smoke.c — M27 书架目录分组宿主单测(纯 C,gcc 直接跑,不依赖设备/SD 卡)。 + * + * 覆盖: + * G1 扫描:格式识别 / 标题去扩展名 / 目录归属(相对 Books/ 的路径,根下="") + * G2 format 模式 = 改动前行为(格式段头 TXT/FB2/EPUB/MOBI/CBZ,最近阅读优先、其次书名) + * G3 folder 模式:目录段头顺序(未分类置顶,其余按目录名)、组内排序、嵌套目录 B/sub + * G4 空目录不产生段头;深度 >2 的目录不扫(与旧行为一致) + * G5 Books/ 根下的书归「未分类」;字面名为「未分类」的目录与根组是两个不同键 + * G6 UTF-8 安全截断(中文目录名不切出半个字符) + * G7 超上限不静默:truncated=1;行数上限(段头 ≤ 书数 ⇒ 2N 必够) + * G8 进度不受分组影响:path 不变、rdprog_load 前后同值、ts 两种模式一致 + */ +#include "shelf_model.h" +#include "progress.h" +#include "reader_conf.h" +#include +#include +#include +#include +#include + +static int g_pass = 0, g_fail = 0; +#define CHECK(cond, msg) do { \ + if (cond) g_pass++; \ + else { g_fail++; printf("FAIL %s:%d %s\n", __FILE__, __LINE__, msg); } \ +} while (0) + +/* progress.c 的目录来源:测试里指向临时目录(见 main)。 */ +static char g_cfgdir[400]; +const char *meow_config_dir(void) { return g_cfgdir; } + +static void mkdirs(const char *path) +{ + char t[400]; + snprintf(t, sizeof t, "%s", path); + for (char *p = t + 1; *p; p++) { + if (*p == '/') { *p = 0; mkdir(t, 0755); *p = '/'; } + } + mkdir(t, 0755); +} + +static void write_file(const char *path, const char *body) +{ + FILE *f = fopen(path, "wb"); + if (!f) { printf("无法创建 %s\n", path); exit(2); } + fputs(body, f); + fclose(f); +} + +/* 按文件名给阅读时间:1.txt=100, 2.epub=50, 其它=0(验证 ts 排序 + 组内排序)。 */ +static uint64_t ts_of(const char *path) +{ + const char *b = strrchr(path, '/'); + b = b ? b + 1 : path; + if (strcmp(b, "1.txt") == 0) return 100; + if (strcmp(b, "2.epub") == 0) return 50; + return 0; +} + +/* 把行序列打成一串便于整体比对:S:<段头>|B:<书名>(<目录>)| */ +static void dump_rows(const shelf_row *rows, int nrows, const shelf_book *books, + char *out, int outsz) +{ + int off = 0; + out[0] = 0; + for (int i = 0; i < nrows && off < outsz; i++) { + if (rows[i].section) { + const char *label = rows[i].label ? rows[i].label : shelf_fmt_name(rows[i].fmt); + off += snprintf(out + off, (size_t)(outsz - off), "S:%s|", label); + } else { + const shelf_book *b = &books[rows[i].book]; + off += snprintf(out + off, (size_t)(outsz - off), "B:%s(%s)|", b->title, b->group); + } + } +} + +static const shelf_book *find_book(const shelf_book *books, int n, const char *title) +{ + for (int i = 0; i < n; i++) if (strcmp(books[i].title, title) == 0) return &books[i]; + return NULL; +} + +static int utf8_complete(const char *s) +{ + const unsigned char *p = (const unsigned char *)s; + while (*p) { + int n; + if (*p < 0x80) n = 1; + else if ((*p & 0xE0) == 0xC0) n = 2; + else if ((*p & 0xF0) == 0xE0) n = 3; + else if ((*p & 0xF8) == 0xF0) n = 4; + else return 0; + for (int i = 1; i < n; i++) if (!p[i] || (p[i] & 0xC0) != 0x80) return 0; + p += n; + } + return 1; +} + +static void test_scan_and_modes(const char *root, const char *tmproot) +{ + /* ---- 造树 ---- + * Books/A/1.txt (ts100), Books/A/2.epub (ts50), Books/B/3.txt, + * Books/4.txt(根), Books/B/sub/5.mobi(深度 2), Books/C/(空), + * Books/A/notes.md(不支持), Books/D/E/F/6.txt(深度 3,不扫) */ + char p[400]; + snprintf(p, sizeof p, "%s/A", root); mkdirs(p); + snprintf(p, sizeof p, "%s/B/sub", root); mkdirs(p); + snprintf(p, sizeof p, "%s/C", root); mkdirs(p); + snprintf(p, sizeof p, "%s/D/E/F", root); mkdirs(p); + snprintf(p, sizeof p, "%s/A/1.txt", root); write_file(p, "one"); + snprintf(p, sizeof p, "%s/A/2.epub", root); write_file(p, "two"); + snprintf(p, sizeof p, "%s/B/3.txt", root); write_file(p, "three"); + snprintf(p, sizeof p, "%s/4.txt", root); write_file(p, "four"); + snprintf(p, sizeof p, "%s/B/sub/5.mobi", root); write_file(p, "five"); + snprintf(p, sizeof p, "%s/A/notes.md", root); write_file(p, "ignored"); + snprintf(p, sizeof p, "%s/D/E/F/6.txt", root); write_file(p, "too-deep"); + + shelf_book books[64]; + shelf_scan_report rep; + int n = shelf_scan_books(root, books, 64, ts_of, &rep); + CHECK(n == 5, "扫描到 5 本(.md 忽略、深度 3 不扫)"); + CHECK(rep.truncated == 0, "未触发上限"); + CHECK(rep.root_missing == 0, "root 存在"); + + /* G1 目录归属与 path */ + const shelf_book *b1 = find_book(books, n, "1"); + const shelf_book *b2 = find_book(books, n, "2"); + const shelf_book *b3 = find_book(books, n, "3"); + const shelf_book *b4 = find_book(books, n, "4"); + const shelf_book *b5 = find_book(books, n, "5"); + CHECK(b1 && strcmp(b1->group, "A") == 0, "A/1.txt 归属 A"); + CHECK(b2 && strcmp(b2->group, "A") == 0, "A/2.epub 归属 A"); + CHECK(b3 && strcmp(b3->group, "B") == 0, "B/3.txt 归属 B"); + CHECK(b4 && b4->group[0] == 0, "根下 4.txt 目录字段为空"); + CHECK(b5 && strcmp(b5->group, "B/sub") == 0, "嵌套 B/sub/5.mobi 归属 B/sub(相对路径)"); + CHECK(find_book(books, n, "6") == NULL, "深度 3 的 6.txt 不扫"); + if (b1) { + char want[400]; + snprintf(want, sizeof want, "%s/A/1.txt", root); + CHECK(strcmp(b1->path, want) == 0, "path 仍是完整绝对路径"); + } + + /* G2 format 模式(改动前行为):格式段头 + 最近阅读优先 + 组内/全局书名排序 */ + shelf_row rows[128]; + int nf = shelf_build_rows(books, n, 0, rows, 128); + char got[2048]; + dump_rows(rows, nf, books, got, sizeof got); + CHECK(strcmp(got, "S:TXT|B:1(A)|B:3(B)|B:4()|S:EPUB|B:2(A)|S:MOBI|B:5(B/sub)|") == 0, + "format 模式段头/顺序与旧行为一致"); + CHECK(nf == 8, "format 行数 = 3 段头 + 5 书"); + + /* G3 folder 模式:未分类置顶 → A → B → B/sub;组内 ts/书名序 */ + int nd = shelf_build_rows(books, n, 1, rows, 128); + dump_rows(rows, nd, books, got, sizeof got); + CHECK(strcmp(got, "S:未分类|B:4()|S:A|B:1(A)|B:2(A)|S:B|B:3(B)|S:B/sub|B:5(B/sub)|") == 0, + "folder 模式段头顺序/归属正确"); + CHECK(nd == 9, "folder 行数 = 4 段头 + 5 书"); + + /* G4 空目录不产生段头(C 不在序列里);也无 6(深度 3) */ + CHECK(strstr(got, "S:C|") == NULL, "空目录 C 无段头"); + CHECK(strstr(got, "B:6(") == NULL, "深度 3 的书未出现"); + + /* G7 行数上限:段头 ≤ 书数,2N 足够;rowcap 小时只写 rowcap 行 */ + CHECK(SHELF_MAX_ROWS >= SHELF_MAX_BOOKS * 2, "SHELF_MAX_ROWS ≥ 2×书上限"); + int nc = shelf_build_rows(books, n, 1, rows, 3); + CHECK(nc == 3, "rowcap=3 时返回 3(不越界写)"); + + /* G7 超上限可见:cap=2 时 truncated=1 */ + shelf_book small[2]; + shelf_scan_report rep2; + int ns = shelf_scan_books(root, small, 2, ts_of, &rep2); + CHECK(ns == 2 && rep2.truncated == 1, "超上限时 truncated=1(不静默)"); + + /* 不存在的根:root_missing=1 */ + shelf_scan_report rep3; + char nope[400]; snprintf(nope, sizeof nope, "%s/no_such_dir", tmproot); + CHECK(shelf_scan_books(nope, small, 2, NULL, &rep3) == 0 && rep3.root_missing == 1, + "根不存在时 root_missing=1"); + + /* G5 字面名「未分类」的目录与根组是两个独立键(按相对路径分组,不按显示名合并) */ + char root2[400], mini[420]; + snprintf(root2, sizeof root2, "%s/mini", tmproot); + snprintf(mini, sizeof mini, "%s/未分类", root2); + mkdirs(mini); + snprintf(p, sizeof p, "%s/x.txt", root2); write_file(p, "x"); + snprintf(p, sizeof p, "%s/未分类/7.txt", root2); write_file(p, "seven"); + shelf_book mb[8]; + shelf_scan_report rep4; + int nm = shelf_scan_books(root2, mb, 8, NULL, &rep4); + int ndm = shelf_build_rows(mb, nm, 1, rows, 128); + dump_rows(rows, ndm, mb, got, sizeof got); + CHECK(strcmp(got, "S:未分类|B:x()|S:未分类|B:7(未分类)|") == 0, + "根组与名为「未分类」的目录不合并(两个段头)"); + + /* G6 UTF-8 安全截断 */ + char src[200], dst[96]; + int k = 0; + for (int i = 0; i < 40; i++) { memcpy(src + k, "轻", 3); k += 3; } + src[k] = 0; /* 120 字节 */ + int ncpy = shelf_copy_utf8(dst, 96, src); + CHECK(ncpy == 93 && strlen(dst) == 93, "长中文目录名截到 93 字节(31 字,整字丢弃)"); + CHECK(strncmp(dst, src, 93) == 0 && utf8_complete(dst), "截断后仍是合法 UTF-8 前缀"); + CHECK(shelf_copy_utf8(dst, 3, "轻x") == 0 && dst[0] == 0, "cap=3 放不下 3 字节汉字时整段丢弃"); + CHECK(shelf_copy_utf8(dst, 4, "轻x") == 3 && strcmp(dst, "轻") == 0, "cap=4 恰好放下一个汉字"); + char ascii[128]; memset(ascii, 'a', 100); ascii[100] = 0; + CHECK(shelf_copy_utf8(dst, 96, ascii) == 95, "ASCII 90+ 字节按 cap-1 截断"); +} + +static void test_progress_stability(const char *root) +{ + /* G8:写一份进度,切换分组模式前后 rdprog_load 必须同值,path 不变。 */ + char path[400]; + snprintf(path, sizeof path, "%s/A/1.txt", root); + + rdprog p; + memset(&p, 0, sizeof p); + p.valid = 1; p.seg = 7; p.ch = 123; p.fs = 22; p.ls = 140; p.enc = 1; p.ts = 100; + CHECK(rdprog_save(path, &p) == 0, "写入测试进度"); + + shelf_book books[64]; + shelf_scan_report rep; + int n = shelf_scan_books(root, books, 64, ts_of, &rep); + const shelf_book *b = find_book(books, n, "1"); + CHECK(b && strcmp(b->path, path) == 0, "扫描后 path 未变"); + CHECK(b && b->ts == 100, "扫描用 rdprog_load 的时间戳"); + + /* 快照所有 path,跑两种模式的 build_rows,再逐条比对。 */ + char before[64][400]; + for (int i = 0; i < n; i++) snprintf(before[i], sizeof before[i], "%s", books[i].path); + + shelf_row rows[128]; + shelf_build_rows(books, n, 0, rows, 128); + rdprog after_f; + int rf = rdprog_load(path, &after_f); + shelf_build_rows(books, n, 1, rows, 128); + rdprog after_d; + int rd = rdprog_load(path, &after_d); + + CHECK(rf == 1 && rd == 1, "两种模式下进度都存在"); + CHECK(after_f.seg == p.seg && after_f.ch == p.ch && after_f.fs == p.fs && + after_f.ls == p.ls && after_f.enc == p.enc && after_f.ts == p.ts && + after_d.seg == p.seg && after_d.ch == p.ch && after_d.ts == p.ts, + "切换 format/folder 后 rdprog_load 同值"); + int same = 1; + for (int i = 0; i < n; i++) if (strcmp(before[i], books[i].path) != 0) same = 0; + CHECK(same, "build_rows 前后所有 path 不变(进度键稳定)"); + + /* 两种模式下 ts 一致(排序输入相同) */ + shelf_book books2[64]; + int n2 = shelf_scan_books(root, books2, 64, ts_of, &rep); + const shelf_book *b2 = find_book(books2, n2, "1"); + CHECK(n2 == n && b2 && b2->ts == 100, "重扫(另一模式无差别)ts 一致"); +} + +static void test_conf_default(const char *tmproot) +{ + /* 旧用户 reader.conf 没有 ui.shelf_group:必须默认 format(书架不变样),persist 后补写 format。 */ + char cpath[400]; + snprintf(cpath, sizeof cpath, "%s/reader.conf", tmproot); /* g_cfgdir=/data → base= */ + write_file(cpath, "ui.shelf_layout=list\nrd.fs=20\nrd.ls=130\n"); + + const rdconf *c = rdconf_get(); + CHECK(c && strcmp(c->shelf_group, "format") == 0, "无 ui.shelf_group 键 → 默认 format"); + CHECK(c && strcmp(c->shelf, "list") == 0 && c->fs == 20, "其它配置项照常读取"); + + CHECK(rdconf_persist() == 0, "reader.conf 写回成功"); + FILE *f = fopen(cpath, "rb"); + CHECK(f != NULL, "写回后可读"); + if (f) { + char buf[1024]; + size_t n = fread(buf, 1, sizeof buf - 1, f); + fclose(f); + buf[n] = 0; + CHECK(strstr(buf, "ui.shelf_group=format\n") != NULL, "写回文件含 ui.shelf_group=format"); + } +} + +int main(void) +{ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "/tmp"; + char tmpl[400]; + snprintf(tmpl, sizeof tmpl, "%s/shelf_group_XXXXXX", base); + char *tmp = mkdtemp(tmpl); + if (!tmp) { printf("mkdtemp 失败\n"); return 2; } + snprintf(g_cfgdir, sizeof g_cfgdir, "%s/data", tmp); /* progress/reader.conf 落临时目录 */ + mkdirs(g_cfgdir); + + char root[400]; + snprintf(root, sizeof root, "%s/Books", tmp); + mkdirs(root); + + test_scan_and_modes(root, tmp); + test_progress_stability(root); + test_conf_default(tmp); + + printf("shelf_group_smoke: %d pass, %d fail\n", g_pass, g_fail); + /* 清理临时树(保留失败现场意义不大,测试可重复) */ + char cmd[512]; + snprintf(cmd, sizeof cmd, "rm -rf '%s'", tmp); + if (system(cmd) != 0) { /* 忽略 */ } + return g_fail ? 1 : 0; +} diff --git a/patch_v2/reader_app/test/shelf_scan_bench.c b/patch_v2/reader_app/test/shelf_scan_bench.c new file mode 100644 index 0000000..55b4a15 --- /dev/null +++ b/patch_v2/reader_app/test/shelf_scan_bench.c @@ -0,0 +1,78 @@ +/* test/shelf_scan_bench.c — M27 扫描/排队计时(宿主 + 设备通用,纯 C)。 + * + * 用法:./shelf_scan_bench [progress|noop] [轮数] + * progress = 每本书调 rdprog_load(生产路径,含 rdprog_dir 的 mkdir 循环 + fopen); + * noop = 不读进度(基线,只测 readdir/stat/快照)。 + * 进度目录由 MEOW_DATA_DIR 决定(同 App:剥 /data → .../progress);未设则 /tmp/m27bench_data。 + * 输出每轮 scan / build_rows(format+folder) 毫秒,末行 RESULT 汇总 min/avg。 + */ +#include "shelf_model.h" +#include "progress.h" +#include +#include +#include +#include + +static char g_cfgdir[400]; +const char *meow_config_dir(void) { return g_cfgdir; } + +static shelf_book g_books[SHELF_MAX_BOOKS]; +static shelf_row g_rows[SHELF_MAX_ROWS]; + +static uint64_t ts_of(const char *path) +{ + rdprog p; + return rdprog_load(path, &p) == 1 ? p.ts : 0; +} + +/* 取时用 /proc/uptime(整数、10ms 分辨率):zig mipsel musl 的 time64 ABI 在真机上 + * 会让 clock_gettime/gettimeofday 的 timespec/timeval 布局失配(见 sdk/CLOCK_ABI.md), + * 且静态链接下浮点打印也不可靠 —— 全程只用整数微秒。 */ +static int64_t now_us(void) +{ + FILE *f = fopen("/proc/uptime", "r"); + if (!f) return 0; + long sec = 0, cs = 0; + if (fscanf(f, "%ld.%ld", &sec, &cs) != 2) cs = 0; + fclose(f); + return (int64_t)sec * 1000000 + (int64_t)cs * 10000; +} + +int main(int argc, char **argv) +{ + if (argc < 2) { + fprintf(stderr, "用法: %s [progress|noop] [轮数]\n", argv[0]); + return 2; + } + const char *root = argv[1]; + int use_prog = (argc > 2 && strcmp(argv[2], "progress") == 0); + int reps = (argc > 3) ? atoi(argv[3]) : 5; + if (reps < 1) reps = 1; + + const char *env = getenv("MEOW_DATA_DIR"); + snprintf(g_cfgdir, sizeof g_cfgdir, "%s", (env && env[0]) ? env : "/tmp/m27bench_data"); + + int n = 0, rows_f = 0, rows_d = 0, trunc = 0; + int64_t best_scan = INT64_MAX, best_build = INT64_MAX, sum_scan = 0; + for (int r = 0; r < reps; r++) { + shelf_scan_report rep; + int64_t t0 = now_us(); + n = shelf_scan_books(root, g_books, SHELF_MAX_BOOKS, use_prog ? ts_of : NULL, &rep); + int64_t t1 = now_us(); + rows_f = shelf_build_rows(g_books, n, 0, g_rows, SHELF_MAX_ROWS); + rows_d = shelf_build_rows(g_books, n, 1, g_rows, SHELF_MAX_ROWS); + int64_t t2 = now_us(); + int64_t s = t1 - t0, b = t2 - t1; + trunc = rep.truncated; + sum_scan += s; + if (s < best_scan) best_scan = s; + if (b < best_build) best_build = b; + printf("run %d: scan %lld us, build_rows(format+folder) %lld us, books=%d rows_f=%d rows_d=%d trunc=%d\n", + r + 1, (long long)s, (long long)b, n, rows_f, rows_d, rep.truncated); + } + printf("RESULT root=%s mode=%s reps=%d books=%d trunc=%d rows_format=%d rows_folder=%d " + "scan_min=%lldus scan_avg=%lldus build_min=%lldus\n", + root, use_prog ? "progress" : "noop", reps, n, trunc, rows_f, rows_d, + (long long)best_scan, (long long)(sum_scan / reps), (long long)best_build); + return 0; +}