Files
MeowOS-SDK/tuku/logic/library.c
T
AskaEth 9f34417c49 图库: 单层浏览 + 懒判定与 M7/M11 整合;探测缓存 P2 修复(M13)
rebase 到 picbrowser(含 M7 条漫/续读、M11 CBZ 档案册)后的语义拼接:
- 保留单层扫描重写:一次 opendir/readdir+lstat 只列当前层(目录 + 注入 archive_pages
  时的 .cbz/.zip 档案文件),自然升序、隐藏项/符号链接跳过、2000 条截断标注;
  系统目录/空目录/无图目录一律列出;彻底删除递归「子树含图」探测
- 保留并复用 M11 档案册分支:add_dir_entry / add_archive_entry / entry_subtitle /
  entry_badge / archive_pages_at / sibling_is_book / mr_library_scan_ex 注入;
  档案册行同样先成行、页数由后台 probe(注入 zip 页数回调)懒算
- 保留 M7:mr_library_dir_has_images / mr_book_adjacent(_ex) 语义不变;为 adjacency
  恢复 ignored_dir_name(仅册间衔接跳过系统/隐藏目录,浏览本身不隐藏);
  mr_library_format_subtitle 在补位后拼「· 续读 Pn」角标
- mr_library_probe_path 统一探测目录/档案册:mtime(秒+纳秒)+size 失效的 256 项 LRU +
  互斥锁;评审 P2:只缓存「探测前后 stat 完全一致」的结果,期间变化则不写缓存
  (消除「不完整结果绑定新 mtime」的假新鲜;下次调用重新探测)
- UI:列表用 src2 每帧取行;后台线程探测优先于缩略图;档案册封面解压档内首图(M11);
  行语义=书页数/缩略图、空目录「空目录」、无图/坏档「无图片」;可上溯至文件系统 /、
  不可读目录提示并停留;TopAppBar「TF卡」回根;启动时根目录不可读也建空列表;
  顶栏回根命中加 g_tb_home_idx>=0 防御
- tuku_internal.h:library_go_up 注释校正(0=到达文件系统 /,app.c 据此退出)
- 测试:test_library 改单层语义;新增 browse_nav / browse_lazy / browse_perf /
  browse_ui_host(含评审 P2 的「探测期间变化不写缓存」用例);test_cont_adjacent A6
  改两段式;test_cbz T8/T10 改两段式(坏档/无图档案列出但非书);
  run_tests.sh 保留双方全部条目(browse 4 套 + run_zip_tests/run_cbz_tests)
- 复验:run_tests.sh 全 PASS(含 M11 zip/cbz)、run_webtoon_tests.sh 全 PASS;
  worktree 内 sdk/build_sdk.sh + build_tuku.sh 零警告静态 MIPS32r2 ELF
2026-11-05 12:23:31 +08:00

698 lines
23 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.
/* logic/library.c — 书库目录浏览:单层枚举 + 后台懒判定(纯 C)。
*
* 扫描(mr_library_scan / mr_library_scan_ex)与 os.meow.files 的 fsx_scan_dir 同构:
* - 一次 opendir/readdir,逐项 lstat(只认目录与档案文件;符号链接跳过防环);
* - 隐藏项("." 开头,含 . ..)跳过;系统目录/空目录/无图目录一律列出;
* - 目录名/档案文件名按 mr_natural_cmp 自然升序;条目到 MR_LIB_SCAN_MAX 后仍有条目 →
* 截断标注。不再对子目录做任何递归/探测——首帧成本只与本层条目数线性相关。
*
* 懒判定(probe):
* - mr_library_probe_dir 只读一层(图片项 lstat 校验普通文件),给出直接图片数与自然序首图;
* - mr_library_probe_path 按行类型探测:目录走上面的单层扫描;档案册调调用方注入的
* archive_pages(M11,logic 不依赖 zip)。两者共用 mtime(秒+纳秒)+size 失效的 256 项
* LRU 缓存 + 互斥锁,可被后台线程并发调用;探测期间目录/档案发生变化则不写缓存。
* - mr_library_apply_probe 把结果补回行;副标题由 mr_library_format_subtitle 统一生成,
* 书行再拼 M7 的「· 续读 Pn」角标。
*
* M11 档案册:注入 archive_pages 时 .cbz/.zip 直接子文件也成行(懒探测页数);
* 未注入时只列目录(M7 书库语义)。册间衔接的兄弟表 = 含图目录 ∪ 含图片页的档案册。
*/
#include "library.h"
#include "logic/reader.h" /* mr_cont_badge:书库行续读角标(M7) */
#include <ctype.h>
#include <dirent.h>
#include <pthread.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);
}
/* 档案册探测(UI 注入;NULL=纯目录书库,档案文件不成行)。 */
static int archive_pages_at(mr_lib_archive_fn probe, void *ud, const char *path)
{
if (!probe || !path || !path[0]) return 0;
int n = probe(path, ud);
return n > 0 ? n : 0;
}
/* ============================================================ */
/* 单层探测 + mtime/size 失效缓存 */
/* ============================================================ */
int mr_library_probe_dir(const char *dir, mr_library_probe *out)
{
if (!out) return -1;
memset(out, 0, sizeof *out);
if (!dir || !dir[0]) return -1;
IO_OPEN();
DIR *d = opendir(dir);
if (!d) return -1;
char best[MR_NAME_MAX];
best[0] = '\0';
out->empty = 1;
struct dirent *de;
while ((de = readdir(d)) != NULL) {
IO_READDIR();
const char *name = de->d_name;
if (name[0] == '.') continue; /* 隐藏项不算内容 */
out->empty = 0;
if (!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;
out->pages++;
if (best[0] == '\0' || mr_natural_cmp(name, best) < 0) {
copy_str(best, (int)sizeof best, name);
copy_str(out->cover, (int)sizeof out->cover, full);
}
}
closedir(d);
return 0;
}
#define MR_PROBE_CACHE_MAX 256
typedef struct probe_cache_entry {
char path[MR_PATH_MAX];
long long mtime_ns;
long long size;
mr_library_probe probe;
unsigned long long last_use;
} probe_cache_entry;
static probe_cache_entry *g_cache[MR_PROBE_CACHE_MAX];
static unsigned long long g_cache_clock;
static pthread_mutex_t g_cache_lock = PTHREAD_MUTEX_INITIALIZER;
static long long stat_mtime_ns(const struct stat *st)
{
#if defined(__APPLE__)
return (long long)st->st_mtimespec.tv_sec * 1000000000LL + st->st_mtimespec.tv_nsec;
#else
return (long long)st->st_mtim.tv_sec * 1000000000LL + st->st_mtim.tv_nsec;
#endif
}
static probe_cache_entry *cache_find_locked(const char *path)
{
for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) {
if (!g_cache[i]) continue;
if (strcmp(g_cache[i]->path, path) == 0) return g_cache[i];
}
return NULL;
}
static void cache_put_locked(const char *path, long long mtime_ns, long long size,
const mr_library_probe *p)
{
probe_cache_entry *e = cache_find_locked(path);
if (!e) {
int slot = -1;
for (int i = 0; i < MR_PROBE_CACHE_MAX; i++)
if (!g_cache[i]) { slot = i; break; }
if (slot < 0) { /* 满:淘汰最久未用项 */
int lru = 0;
unsigned long long oldest = g_cache[0]->last_use;
for (int i = 1; i < MR_PROBE_CACHE_MAX; i++) {
if (g_cache[i]->last_use < oldest) {
oldest = g_cache[i]->last_use;
lru = i;
}
}
free(g_cache[lru]);
g_cache[lru] = NULL;
slot = lru;
}
e = (probe_cache_entry *)malloc(sizeof *e);
if (!e) return; /* 缓存只是加速:失败就每次现算 */
if (copy_str(e->path, (int)sizeof e->path, path) != 0) {
free(e);
return;
}
g_cache[slot] = e;
}
e->mtime_ns = mtime_ns;
e->size = size;
e->probe = *p;
e->last_use = ++g_cache_clock;
}
void mr_library_probe_cache_clear(void)
{
pthread_mutex_lock(&g_cache_lock);
for (int i = 0; i < MR_PROBE_CACHE_MAX; i++) {
free(g_cache[i]);
g_cache[i] = NULL;
}
g_cache_clock = 0;
pthread_mutex_unlock(&g_cache_lock);
}
/* 无缓存探测:目录=单层目录扫描;档案册=注入回调取页数。 */
static int probe_path_uncached(const char *path, int is_archive,
mr_lib_archive_fn archive_pages, void *ud,
mr_library_probe *out)
{
struct stat st;
if (stat(path, &st) != 0) return -1;
if (is_archive) {
if (!archive_pages || !S_ISREG(st.st_mode)) return -1;
out->pages = archive_pages_at(archive_pages, ud, path);
out->empty = 0;
out->cover[0] = '\0';
return 0;
}
if (!S_ISDIR(st.st_mode)) return -1;
return mr_library_probe_dir(path, out);
}
int mr_library_probe_path(const char *path, int is_archive,
mr_lib_archive_fn archive_pages, void *ud,
mr_library_probe *out)
{
if (!out) return -1;
memset(out, 0, sizeof *out);
if (!path || !path[0]) return -1;
struct stat st;
if (stat(path, &st) != 0) return -1;
if (is_archive ? !S_ISREG(st.st_mode) : !S_ISDIR(st.st_mode)) return -1;
long long mtime_ns = stat_mtime_ns(&st);
long long size = (long long)st.st_size;
pthread_mutex_lock(&g_cache_lock);
probe_cache_entry *hit = cache_find_locked(path);
if (hit && hit->mtime_ns == mtime_ns && hit->size == size) {
*out = hit->probe;
hit->last_use = ++g_cache_clock;
pthread_mutex_unlock(&g_cache_lock);
return 0;
}
pthread_mutex_unlock(&g_cache_lock);
mr_library_probe p;
memset(&p, 0, sizeof p);
if (probe_path_uncached(path, is_archive, archive_pages, ud, &p) != 0) return -1;
/* 评审 P2:只把「探测前后完全一致」的结果写入缓存。
* 探测期间目录/档案的 mtime 或 size 变了(新增/替换文件),说明结果可能不完整,
* 不能绑定到新 mtime 上当「新鲜」——本次直接返回,下次调用重新探测。 */
struct stat st2;
if (stat(path, &st2) == 0 && stat_mtime_ns(&st2) == mtime_ns &&
(long long)st2.st_size == size) {
pthread_mutex_lock(&g_cache_lock);
cache_put_locked(path, mtime_ns, size, &p);
pthread_mutex_unlock(&g_cache_lock);
}
*out = p;
return 0;
}
int mr_library_probe_cached(const char *dir, mr_library_probe *out)
{
return mr_library_probe_path(dir, 0, NULL, NULL, out);
}
/* ============================================================ */
/* 行状态:副标题 / 补位 / 行合成 */
/* ============================================================ */
/* 基础副标题(不含 M7 续读角标)。 */
static void entry_subtitle(mr_library_entry *e)
{
switch (e->probe_state) {
case MR_LIB_READY:
if (e->pages > 0)
snprintf(e->subtitle, sizeof e->subtitle, "%d page%s", e->pages,
e->pages == 1 ? "" : "s");
else if (!e->is_archive && e->empty)
snprintf(e->subtitle, sizeof e->subtitle, "空目录");
else
snprintf(e->subtitle, sizeof e->subtitle, "无图片");
break;
case MR_LIB_FAILED:
snprintf(e->subtitle, sizeof e->subtitle, "无法读取");
break;
default:
snprintf(e->subtitle, sizeof e->subtitle, "…");
break;
}
}
/* 续读角标(M7):有 last page 记录的书行,在副标题后缀「· 续读 Pn」。
* 档案册以档案路径为册标识,同一套逻辑复用。
* config 读失败/无记录时 mr_cont_badge 返回 0,副标题保持原样。 */
static void entry_badge(mr_library_entry *e)
{
if (e->has_images != 1) return;
char badge[24];
if (!mr_cont_badge(e->path, badge, (int)sizeof badge)) return;
int used = (int)strlen(e->subtitle);
if (used < (int)sizeof e->subtitle - 2)
snprintf(e->subtitle + used, sizeof e->subtitle - (size_t)used, " · %s", badge);
}
void mr_library_format_subtitle(mr_library_entry *e)
{
if (!e) return;
entry_subtitle(e);
entry_badge(e);
}
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;
return mr_natural_cmp(a->name, b->name);
}
/* 文件夹册行(M13):一律列出、初始待探测;是否「书」由后台 probe 决定。 */
static int add_dir_entry(mr_library *lib, const char *name, const char *full)
{
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.pages = -1;
e.has_images = -1;
e.probe_state = MR_LIB_PENDING;
mr_library_format_subtitle(&e);
return lib_add(lib, &e);
}
/* 档案册(.cbz/.zip)行:文件即一本书,点入直接阅读。
* pages<0=待后台探测(M13 懒算);>=0=已知页数(扫描期注入的旧路径/测试用)。
* cover 留空——缩略图由 UI 解压档内首图(mr_zip_cover_path)生成。 */
static int add_archive_entry(mr_library *lib, const char *name, const char *full,
int pages)
{
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.is_archive = 1;
if (pages < 0) {
e.pages = -1;
e.has_images = -1;
e.probe_state = MR_LIB_PENDING;
} else {
e.pages = pages;
e.has_images = (pages > 0) ? 1 : 0;
e.probe_state = MR_LIB_READY;
}
mr_library_format_subtitle(&e);
return lib_add(lib, &e);
}
/* ============================================================ */
/* 单层扫描 */
/* ============================================================ */
int mr_library_scan_ex(mr_library *lib, const char *dir, int max_depth,
mr_lib_archive_fn archive_pages, void *ud)
{
(void)max_depth; /* 兼容旧签名:扫描不再递归 */
(void)ud;
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 is_dir = S_ISDIR(st.st_mode);
int is_arch = (!is_dir && archive_pages && S_ISREG(st.st_mode) &&
mr_path_is_archive(name));
if (!is_dir && !is_arch) continue;
if (lib->count >= MR_LIB_SCAN_MAX) { lib->truncated = 1; break; }
int rc = is_dir ? add_dir_entry(lib, name, full)
: add_archive_entry(lib, name, full, -1);
if (rc != 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_scan(mr_library *lib, const char *dir, int max_depth)
{
return mr_library_scan_ex(lib, dir, max_depth, NULL, NULL);
}
int mr_library_apply_probe(mr_library *lib, int idx, const mr_library_probe *p)
{
if (!lib || !p || idx < 0 || idx >= lib->count) return 0;
mr_library_entry *e = &lib->items[idx];
e->pages = p->pages;
e->empty = p->empty;
e->has_images = (p->pages > 0) ? 1 : 0;
e->probe_state = MR_LIB_READY;
copy_str(e->cover, (int)sizeof e->cover, p->cover);
mr_library_format_subtitle(e);
return 1;
}
int mr_library_mark_error(mr_library *lib, int idx)
{
if (!lib || idx < 0 || idx >= lib->count) return 0;
mr_library_entry *e = &lib->items[idx];
e->pages = -1;
e->empty = 0;
e->has_images = 0;
e->probe_state = MR_LIB_FAILED;
e->cover[0] = '\0';
mr_library_format_subtitle(e);
return 1;
}
int mr_library_row_at(const mr_library *lib, int idx, mr_library_row *out)
{
static char trunc_sub[48];
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->is_book = (e->has_images == 1);
out->is_archive = e->is_archive;
out->index = idx;
return 1;
}
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;
DIR *d = opendir(dir);
if (!d) return 0;
int found = 0;
struct dirent *de;
while ((de = readdir(d)) != NULL) {
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;
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;
return archive_pages_at(probe, ud, full) > 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(const char *dir, int delta, char *out, int outsz)
{
return mr_book_adjacent_ex(dir, delta, out, outsz, NULL, NULL);
}