P1-1(会静默开错图):取消「书库从列表推导图片下标」。 - 新增 mr_reader_open_dir_at_name(dir, name):阅读器打开目录册后,在**自己刚枚举出的 全量目录册**(mr_dir_book,与翻页同一份枚举)里按 basename 定位起始页(先精确, 再大小写不敏感兜底;找不到退回续读/首页)。书库点击只传文件名。 - 删除 mr_library_image_index(不再有跨枚举的隐式契约)。 - 截断提示文案改为「仅显示前 2000 项(图片可在阅读器中翻到)」。 - 修复前(cb02722 探针,2100 张 4 位名夹具):rows=2000/truncated=1/ library_images=2000/reader_pages=2100/**mismatched=590**(首行 p1412 → 打开 p1411); 评审夹具为 1995/2000。修复后:2000 个可见图片行按名定位 **0 mismatch**, 2100 页全量可翻;26 次真实 UI 点开(首尾各 3 + 20 个确定性伪随机)逐张命中。 连带处理(评审 r1): 1. 删掉 reader_page_enter 的 c->view 死写(reopen 判定用 activity active/depth); 顺手删掉 library_page_create 里同款死写。 2. 阅读器册间衔接根锁定:新增 mr_path_within_root + mr_book_adjacent_rooted, book_switch 用浏览根(默认 MR_ROOT_DIR,测试可注入)钳制相邻册; 根外的兄弟册按「没有相邻册」处理。test_cont_adjacent A7 验证「明文 _ex 会越根、 rooted 拒绝、根内翻册不受影响」;test_mode_switch_host H6 验证条漫末页不越根。 3. test_reader_perf 不再依赖顶栏 tap 命中时序:模式切换改走测试钩子 mr_reader_test_toggle_mode()(与顶栏动作同一个 mode_set),负载下稳定。 4. page_cache 注释去掉「缩略图缓存/缩略图线程」过时描述(只改文字)。 回归:run_tests.sh(library 81 / nav 74 / lazy 57 / perf 25 / … / open_book 含 2100 用例) 与 run_webtoon_tests.sh(含 A7/H6)全 PASS;交叉构建零警告。
463 lines
15 KiB
C
463 lines
15 KiB
C
/* logic/library.c — 书库文件管理器模型:单层枚举 + 纯内存行合成(纯 C)。
|
||
*
|
||
* 扫描(mr_library_scan)与 os.meow.files 的 fsx_scan_dir 同构:
|
||
* - 一次 opendir/readdir,逐项 lstat(只认目录、受支持图片、.cbz/.zip;符号链接跳过防环);
|
||
* - 隐藏项("." 开头,含 . ..)跳过;空目录/无图目录/系统目录照常列出;
|
||
* - 排序:目录优先,组内按 mr_natural_cmp 自然升序;
|
||
* - 副标题(类型 · 大小)在扫描期生成一次,渲染期不再做任何格式化/IO;
|
||
* - 条目到 MR_LIB_SCAN_MAX 后仍有条目 → 截断标注。
|
||
*
|
||
* 阅读器辅助(与列表模型解耦):mr_library_dir_has_images 单层判据 +
|
||
* mr_book_adjacent(_ex) 兄弟册衔接(M7/M11 阅读器翻到相邻册用)。
|
||
*/
|
||
#include "library.h"
|
||
|
||
#include <ctype.h>
|
||
#include <dirent.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <strings.h>
|
||
#include <sys/stat.h>
|
||
|
||
#ifdef MR_LIBRARY_TEST_HOOKS
|
||
static mr_library_io_stats g_io;
|
||
|
||
void mr_library_test_io_reset(void)
|
||
{
|
||
memset(&g_io, 0, sizeof g_io);
|
||
}
|
||
|
||
void mr_library_test_io_get(mr_library_io_stats *out)
|
||
{
|
||
if (out) *out = g_io;
|
||
}
|
||
|
||
#define IO_OPEN() (g_io.opendirs++)
|
||
#define IO_READDIR() (g_io.readdirs++)
|
||
#define IO_LSTAT() (g_io.lstats++)
|
||
#else
|
||
#define IO_OPEN() ((void)0)
|
||
#define IO_READDIR() ((void)0)
|
||
#define IO_LSTAT() ((void)0)
|
||
#endif
|
||
|
||
/* 系统目录不参与**册间衔接**(M7 兄弟册表);浏览本身已放开(不隐藏)。 */
|
||
static int ignored_dir_name(const char *name)
|
||
{
|
||
static const char *const skip[] = { "MeowAPPs", "MeowData", "MeowOS", "lost+found" };
|
||
if (!name || name[0] == '.') return 1;
|
||
for (unsigned i = 0; i < sizeof skip / sizeof skip[0]; i++)
|
||
if (strcmp(name, skip[i]) == 0) return 1;
|
||
return 0;
|
||
}
|
||
|
||
static int copy_str(char *dst, int dstsz, const char *src)
|
||
{
|
||
if (!dst || dstsz <= 0) return -1;
|
||
if (!src) { dst[0] = '\0'; return 0; }
|
||
int n = (int)strlen(src);
|
||
if (n >= dstsz) return -1;
|
||
memcpy(dst, src, (size_t)n + 1);
|
||
return 0;
|
||
}
|
||
|
||
static int join_path(char *out, int outsz, const char *dir, const char *name)
|
||
{
|
||
if (!out || outsz <= 0 || !dir || !name) return -1;
|
||
int n;
|
||
size_t dl = strlen(dir);
|
||
if (dl > 0 && dir[dl - 1] == '/')
|
||
n = snprintf(out, (size_t)outsz, "%s%s", dir, name);
|
||
else
|
||
n = snprintf(out, (size_t)outsz, "%s/%s", dir, name);
|
||
return (n > 0 && n < outsz) ? 0 : -1;
|
||
}
|
||
|
||
int mr_path_is_image(const char *name)
|
||
{
|
||
if (!name) return 0;
|
||
const char *dot = strrchr(name, '.');
|
||
if (!dot || dot == name) return 0;
|
||
dot++;
|
||
return strcasecmp(dot, "png") == 0 || strcasecmp(dot, "jpg") == 0 ||
|
||
strcasecmp(dot, "jpeg") == 0 || strcasecmp(dot, "bmp") == 0;
|
||
}
|
||
|
||
int mr_path_is_archive(const char *name)
|
||
{
|
||
if (!name) return 0;
|
||
const char *dot = strrchr(name, '.');
|
||
if (!dot || dot == name) return 0;
|
||
dot++;
|
||
return strcasecmp(dot, "cbz") == 0 || strcasecmp(dot, "zip") == 0;
|
||
}
|
||
|
||
int mr_natural_cmp(const char *a, const char *b)
|
||
{
|
||
const unsigned char *pa = (const unsigned char *)a;
|
||
const unsigned char *pb = (const unsigned char *)b;
|
||
if (!a || !b) return (a == b) ? 0 : (a ? 1 : -1);
|
||
|
||
while (*pa && *pb) {
|
||
if (isdigit(*pa) && isdigit(*pb)) {
|
||
const unsigned char *za = pa, *zb = pb;
|
||
while (*pa == '0') pa++;
|
||
while (*pb == '0') pb++;
|
||
const unsigned char *da = pa, *db = pb;
|
||
while (isdigit(*pa)) pa++;
|
||
while (isdigit(*pb)) pb++;
|
||
|
||
size_t la = (size_t)(pa - da), lb = (size_t)(pb - db);
|
||
if (la != lb) return la < lb ? -1 : 1;
|
||
if (la > 0) {
|
||
int c = memcmp(da, db, la);
|
||
if (c != 0) return c < 0 ? -1 : 1;
|
||
}
|
||
size_t oa = (size_t)(da - za), ob = (size_t)(db - zb);
|
||
if (oa != ob) return oa < ob ? -1 : 1;
|
||
continue;
|
||
}
|
||
|
||
int ca = tolower(*pa), cb = tolower(*pb);
|
||
if (ca != cb) return ca < cb ? -1 : 1;
|
||
pa++;
|
||
pb++;
|
||
}
|
||
if (*pa) return 1;
|
||
if (*pb) return -1;
|
||
return strcmp(a, b) < 0 ? -1 : (strcmp(a, b) > 0 ? 1 : 0);
|
||
}
|
||
|
||
/* ============================================================ */
|
||
/* 扫描 / 排序 / 行合成 */
|
||
/* ============================================================ */
|
||
static void fmt_size(long long size, char *out, int outsz)
|
||
{
|
||
if (size < 0) size = 0;
|
||
static const char *const unit[] = { "B", "KB", "MB", "GB" };
|
||
double v = (double)size;
|
||
int i = 0;
|
||
while (v >= 1024.0 && i < 3) { v /= 1024.0; i++; }
|
||
if (i == 0) snprintf(out, (size_t)outsz, "%lld B", size);
|
||
else snprintf(out, (size_t)outsz, "%.1f %s", v, unit[i]);
|
||
}
|
||
|
||
/* 副标题只在扫描期生成一次(渲染期零格式化)。 */
|
||
static void format_subtitle(mr_library_entry *e)
|
||
{
|
||
char sz[32];
|
||
switch (e->kind) {
|
||
case MR_LIB_KIND_DIR:
|
||
snprintf(e->subtitle, sizeof e->subtitle, "文件夹");
|
||
break;
|
||
case MR_LIB_KIND_ARCHIVE:
|
||
fmt_size(e->size, sz, (int)sizeof sz);
|
||
snprintf(e->subtitle, sizeof e->subtitle, "档案册 · %s", sz);
|
||
break;
|
||
default:
|
||
fmt_size(e->size, sz, (int)sizeof sz);
|
||
snprintf(e->subtitle, sizeof e->subtitle, "图片 · %s", sz);
|
||
break;
|
||
}
|
||
}
|
||
|
||
void mr_library_free(mr_library *lib)
|
||
{
|
||
if (!lib) return;
|
||
free(lib->items);
|
||
lib->items = NULL;
|
||
lib->count = 0;
|
||
lib->cap = 0;
|
||
lib->truncated = 0;
|
||
}
|
||
|
||
static int lib_add(mr_library *lib, const mr_library_entry *e)
|
||
{
|
||
if (lib->count >= lib->cap) {
|
||
int cap = lib->cap ? lib->cap * 2 : 16;
|
||
mr_library_entry *ni = (mr_library_entry *)realloc(lib->items,
|
||
(size_t)cap * sizeof *ni);
|
||
if (!ni) return -1;
|
||
lib->items = ni;
|
||
lib->cap = cap;
|
||
}
|
||
lib->items[lib->count++] = *e;
|
||
return 0;
|
||
}
|
||
|
||
/* 目录优先;组内自然序(大小写不敏感 + 原始字节兜底,结果确定)。 */
|
||
static int entry_cmp(const void *pa, const void *pb)
|
||
{
|
||
const mr_library_entry *a = (const mr_library_entry *)pa;
|
||
const mr_library_entry *b = (const mr_library_entry *)pb;
|
||
int ad = (a->kind == MR_LIB_KIND_DIR), bd = (b->kind == MR_LIB_KIND_DIR);
|
||
if (ad != bd) return ad ? -1 : 1;
|
||
return mr_natural_cmp(a->name, b->name);
|
||
}
|
||
|
||
static int add_entry(mr_library *lib, const char *name, const char *full,
|
||
int kind, const struct stat *st)
|
||
{
|
||
mr_library_entry e;
|
||
memset(&e, 0, sizeof e);
|
||
if (copy_str(e.name, (int)sizeof e.name, name) != 0) return 0;
|
||
if (copy_str(e.path, (int)sizeof e.path, full) != 0) return 0;
|
||
e.kind = kind;
|
||
e.size = (kind == MR_LIB_KIND_DIR) ? 0 : (long long)st->st_size;
|
||
format_subtitle(&e);
|
||
return lib_add(lib, &e);
|
||
}
|
||
|
||
int mr_library_scan(mr_library *lib, const char *dir, int max_depth)
|
||
{
|
||
(void)max_depth; /* 兼容旧签名:扫描不递归 */
|
||
if (!lib) return -1;
|
||
mr_library_free(lib);
|
||
if (!dir || !dir[0]) return -1;
|
||
|
||
IO_OPEN();
|
||
DIR *d = opendir(dir);
|
||
if (!d) return -1;
|
||
|
||
struct dirent *de;
|
||
while ((de = readdir(d)) != NULL) {
|
||
IO_READDIR();
|
||
const char *name = de->d_name;
|
||
if (name[0] == '.') continue; /* . .. 与隐藏项 */
|
||
|
||
char full[MR_PATH_MAX];
|
||
if (join_path(full, (int)sizeof full, dir, name) != 0) continue;
|
||
|
||
IO_LSTAT();
|
||
struct stat st;
|
||
if (lstat(full, &st) != 0) continue;
|
||
if (S_ISLNK(st.st_mode)) continue;
|
||
|
||
int kind;
|
||
if (S_ISDIR(st.st_mode)) kind = MR_LIB_KIND_DIR;
|
||
else if (!S_ISREG(st.st_mode)) continue;
|
||
else if (mr_path_is_image(name)) kind = MR_LIB_KIND_IMAGE;
|
||
else if (mr_path_is_archive(name)) kind = MR_LIB_KIND_ARCHIVE;
|
||
else continue;
|
||
|
||
if (lib->count >= MR_LIB_SCAN_MAX) { lib->truncated = 1; break; }
|
||
if (add_entry(lib, name, full, kind, &st) != 0) {
|
||
closedir(d);
|
||
mr_library_free(lib);
|
||
return -1;
|
||
}
|
||
}
|
||
closedir(d);
|
||
|
||
if (lib->count > 1)
|
||
qsort(lib->items, (size_t)lib->count, sizeof *lib->items, entry_cmp);
|
||
return lib->count;
|
||
}
|
||
|
||
int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out)
|
||
{
|
||
static char trunc_sub[96];
|
||
static int trunc_ready;
|
||
|
||
if (!lib || !out) return 0;
|
||
if (idx == lib->count && lib->truncated) {
|
||
if (!trunc_ready) {
|
||
/* 截断只是列表显示上限:图片仍能从任一可见图片进入阅读器后翻到,
|
||
* 文案不要让人误以为「没有更多图片」。 */
|
||
snprintf(trunc_sub, sizeof trunc_sub,
|
||
"仅显示前 %d 项(图片可在阅读器中翻到)", MR_LIB_SCAN_MAX);
|
||
trunc_ready = 1;
|
||
}
|
||
memset(out, 0, sizeof *out);
|
||
out->title = "已截断";
|
||
out->subtitle = trunc_sub;
|
||
out->disabled = 1;
|
||
out->index = -1;
|
||
return 1;
|
||
}
|
||
if (idx < 0 || idx >= lib->count) return 0;
|
||
|
||
const mr_library_entry *e = &lib->items[idx];
|
||
memset(out, 0, sizeof *out);
|
||
out->title = e->name;
|
||
out->subtitle = e->subtitle;
|
||
out->kind = e->kind;
|
||
out->index = idx;
|
||
return 1;
|
||
}
|
||
|
||
int mr_path_within_root(const char *path, const char *root)
|
||
{
|
||
if (!path || !path[0] || !root || !root[0]) return 0;
|
||
size_t rl = strlen(root);
|
||
while (rl > 1 && root[rl - 1] == '/') rl--;
|
||
if (rl == 1 && root[0] == '/') return path[0] == '/';
|
||
if (strncmp(path, root, rl) != 0) return 0;
|
||
char c = path[rl];
|
||
return c == '\0' || c == '/';
|
||
}
|
||
|
||
int mr_path_parent(const char *path, char *out, int outsz)
|
||
{
|
||
if (!path || !out || outsz <= 0) return -1;
|
||
size_t n = strlen(path);
|
||
while (n > 1 && path[n - 1] == '/') n--;
|
||
if (n <= 1) {
|
||
if (outsz < 2) return -1;
|
||
out[0] = '/';
|
||
out[1] = '\0';
|
||
return 0;
|
||
}
|
||
while (n > 0 && path[n - 1] != '/') n--;
|
||
while (n > 1 && path[n - 1] == '/') n--;
|
||
if (n >= (size_t)outsz) return -1;
|
||
memcpy(out, path, n);
|
||
out[n] = '\0';
|
||
return 0;
|
||
}
|
||
|
||
/* ============================================================ */
|
||
/* 册间衔接(M7 / M11) */
|
||
/* ============================================================ */
|
||
int mr_library_dir_has_images(const char *dir)
|
||
{
|
||
if (!dir || !dir[0]) return 0;
|
||
IO_OPEN();
|
||
DIR *d = opendir(dir);
|
||
if (!d) return 0;
|
||
|
||
int found = 0;
|
||
struct dirent *de;
|
||
while ((de = readdir(d)) != NULL) {
|
||
IO_READDIR();
|
||
const char *name = de->d_name;
|
||
if (name[0] == '.' || !mr_path_is_image(name)) continue;
|
||
char full[MR_PATH_MAX];
|
||
if (join_path(full, (int)sizeof full, dir, name) != 0) continue;
|
||
IO_LSTAT();
|
||
struct stat st;
|
||
if (lstat(full, &st) != 0 || !S_ISREG(st.st_mode)) continue;
|
||
found = 1;
|
||
break;
|
||
}
|
||
closedir(d);
|
||
return found;
|
||
}
|
||
|
||
static int name_ptr_cmp(const void *pa, const void *pb)
|
||
{
|
||
const char *a = *(const char *const *)pa;
|
||
const char *b = *(const char *const *)pb;
|
||
return mr_natural_cmp(a, b);
|
||
}
|
||
|
||
/* 兄弟册判据(M11):目录=直接含图;普通文件=档案册且有图片页。
|
||
* 两者同列,交给 name_ptr_cmp 自然序混排。 */
|
||
static int sibling_is_book(const char *full, const struct stat *st,
|
||
mr_lib_archive_fn probe, void *ud)
|
||
{
|
||
if (S_ISDIR(st->st_mode)) return mr_library_dir_has_images(full);
|
||
if (S_ISREG(st->st_mode)) {
|
||
const char *slash = strrchr(full, '/');
|
||
const char *name = slash ? slash + 1 : full;
|
||
if (!mr_path_is_archive(name)) return 0;
|
||
if (!probe) return 0;
|
||
int n = probe(full, ud);
|
||
return n > 0;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
int mr_book_adjacent_ex(const char *dir, int delta, char *out, int outsz,
|
||
mr_lib_archive_fn archive_pages, void *ud)
|
||
{
|
||
if (!out || outsz <= 0) return -1;
|
||
out[0] = '\0';
|
||
if (!dir || !dir[0] || (delta != 1 && delta != -1)) return -1;
|
||
|
||
char norm[MR_PATH_MAX];
|
||
size_t dl = strlen(dir);
|
||
while (dl > 1 && dir[dl - 1] == '/') dl--;
|
||
if (dl >= sizeof norm) return -1;
|
||
memcpy(norm, dir, dl);
|
||
norm[dl] = '\0';
|
||
|
||
const char *slash = strrchr(norm, '/');
|
||
if (!slash || !slash[1]) return -1;
|
||
const char *cur = slash + 1;
|
||
|
||
char parent[MR_PATH_MAX];
|
||
if (mr_path_parent(norm, parent, (int)sizeof parent) != 0) return -1;
|
||
|
||
DIR *d = opendir(parent);
|
||
if (!d) return 0;
|
||
|
||
char **names = NULL;
|
||
int n = 0, cap = 0;
|
||
struct dirent *de;
|
||
while ((de = readdir(d)) != NULL) {
|
||
const char *name = de->d_name;
|
||
if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) continue;
|
||
if (ignored_dir_name(name)) continue;
|
||
|
||
char full[MR_PATH_MAX];
|
||
if (join_path(full, (int)sizeof full, parent, name) != 0) continue;
|
||
struct stat st;
|
||
if (lstat(full, &st) != 0) continue;
|
||
if (S_ISLNK(st.st_mode)) continue;
|
||
if (!sibling_is_book(full, &st, archive_pages, ud)) continue;
|
||
|
||
if (n >= cap) {
|
||
int nc = cap ? cap * 2 : 16;
|
||
char **nn = (char **)realloc(names, (size_t)nc * sizeof *nn);
|
||
if (!nn) break;
|
||
names = nn;
|
||
cap = nc;
|
||
}
|
||
size_t len = strlen(name);
|
||
names[n] = (char *)malloc(len + 1);
|
||
if (!names[n]) break;
|
||
memcpy(names[n], name, len + 1);
|
||
n++;
|
||
}
|
||
closedir(d);
|
||
|
||
int rc = 0;
|
||
if (n > 1) qsort(names, (size_t)n, sizeof *names, name_ptr_cmp);
|
||
int idx = -1;
|
||
for (int i = 0; i < n; i++)
|
||
if (strcmp(names[i], cur) == 0) { idx = i; break; }
|
||
int target = idx + delta;
|
||
if (idx >= 0 && target >= 0 && target < n) {
|
||
if (join_path(out, outsz, parent, names[target]) == 0) rc = 1;
|
||
}
|
||
for (int i = 0; i < n; i++) free(names[i]);
|
||
free(names);
|
||
return rc;
|
||
}
|
||
|
||
int mr_book_adjacent_rooted(const char *dir, int delta, const char *root,
|
||
mr_lib_archive_fn archive_pages, void *ud,
|
||
char *out, int outsz)
|
||
{
|
||
if (!out || outsz <= 0) return -1;
|
||
out[0] = '\0';
|
||
if (!dir || !dir[0] || !root || !root[0]) return -1;
|
||
/* 只允许在浏览根内翻册:当前册或相邻册越根一律当「没有相邻册」。 */
|
||
if (!mr_path_within_root(dir, root)) return 0;
|
||
|
||
int r = mr_book_adjacent_ex(dir, delta, out, outsz, archive_pages, ud);
|
||
if (r != 1) return r;
|
||
if (!mr_path_within_root(out, root)) {
|
||
out[0] = '\0';
|
||
return 0;
|
||
}
|
||
return 1;
|
||
}
|
||
|
||
int mr_book_adjacent(const char *dir, int delta, char *out, int outsz)
|
||
{
|
||
return mr_book_adjacent_ex(dir, delta, out, outsz, NULL, NULL);
|
||
}
|