/* app/shelf_model.c — 书架数据模型实现(见 app/shelf_model.h)。 */ #include "shelf_model.h" #include #include #include #include #include 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); }