feat(library): 新增书库管理页面

This commit is contained in:
cnb.dQC5J6ohAGA committed 2026-09-17 12:54:52 +08:00
1 parent d5c4a13c45
commit 282e0426c2
17 files changed
+2340 -3

No files matched your search

+36
View File
@@ -164,9 +164,38 @@ environment:
- **封面进度条**:读过的书在封面底部画一条,扫一眼就知道哪本在读。
- **搜索**:输入即搜(防抖 250ms),带一键清空;回车立刻搜不等防抖。
- **下拉刷新**:在顶部再往下拉一次,手动重新拉取(刚扫完目录时用)。
- **书库管理**:书架右上角的 🗂 打开文件管理器,看的是 `BOOKS_DIR` 本身而不是目录库。
- **离线优先**:先画本地镜像再换成服务端数据,离线时书架照常可用;
离线下的排序规则与服务端一致,所以断网不会让书架重排。
### 书库管理
书架回答「我能读什么」,书库管理回答「磁盘上有什么」。两件事都需要,因为书籍记录里
**从不带路径**——「刚扫完目录,可为什么书架上没有这本书」这个问题,在文件管理器之外
没有任何地方能回答。
书架右上角的 🗂 打开。行为:
- **列的是挂载点本身**,不是整理后的目录视图。面包屑可点,路径就是你 `BOOKS_DIR` 里的路径。
- **被扫描忽略的条目照样显示**,只打一个「扫描忽略」标记。`.trash`、`@eaDir` 是设计上
不扫的;在界面上也把它们藏起来,就正好藏掉了读者正在找的那批文件。
- **长按进多选**,多选后才出现改名 / 移动到… / 删除。点一行是浏览,不是选择:
在一个上万文件的 NAS 书库里滚动,每行都挂一个删除按钮,离误删只有一次手滑。
- **删除要再确认一次**,且会说明「不进回收站、需要重新扫描」。
- **只读挂载上写操作按钮直接不出现**。`:ro` 是文档里推荐的部署方式,所以「这个页面只能看」
是常态;给一排按下去必然 403 的按钮,只会教读者不信任界面。
写操作都做两件事:
- **走 `rename` 而不是复制删除**。同挂载点改名不动字节、不动 `mtime`,扫描器视为未变化,
书籍主键(`identifier + 内容哈希`)因此不变——**进度和笔记都还在**。
索引同时被指向新路径,不用等下一次扫描。
- **先探测挂载是否可写**(真建一个临时目录再删),再动手。`drwxr-xr-x` 的只读 bind mount
看起来可写,写起来 `EPERM`;探测一次比从 `EROFS` 栈里推断便宜得多。
> 敢开这个页面,就等于接受「能改书库的人能改书库里的任何文件」——和 `POST /library/scan`
> 已有的信任级别一样。在这之上再叠一层按路径的权限只是安全表演。
---
## 书库扫描
@@ -557,6 +586,13 @@ android/app/src/main/java/cool/cnb/reader/
- `GET/PUT /api/v1/sync/progress/:bookId`
- `GET /api/v1/notes` / `POST /api/v1/notes` / `DELETE /api/v1/notes/:id`
**书库管理**(读写 `/books`;挂载只读时任何写操作返回 `403 READ_ONLY_MOUNT`)
- `GET /api/v1/library/browse?path=<相对路径>` — 列一个目录,带面包屑与标记
- `POST /api/v1/library/browse/move` — 移动到另一个目录
- `POST /api/v1/library/browse/rename` — 改名
- `POST /api/v1/library/browse/mkdir` — 新建文件夹
- `POST /api/v1/library/browse/delete` — 删除(递归)
**管理员**
- `POST /api/v1/library/scan`
- `GET /api/v1/library/scan`
+108
View File
@@ -267,6 +267,100 @@ pdf / 单图没有独立目录,回落到 items。
目录型书籍(漫画目录)没有单一文件,返回 `400 DIRECTORY_BOOK`;
这类书按 `items?group=N` 逐卷取图。
## 书库管理(文件管理器)
书架回答「我能读什么」,这一组端点回答「磁盘上有什么」。两者的差别不是冗余:
书籍 DTO 里从来不带路径,所以「刚扫完目录但书架上看不到这本书」在本组端点之外
没有任何地方能解释清楚。这也是唯一会**写** `BOOKS_DIR` 的一组端点。
路径都是**书库内相对路径**,`''` 表示根目录;每一个都会过
`resolveInside`,逃逸直接被拒。
> **信任边界**:挂载点就是边界,没有第二道。能在这里改文件的人,能改书库里的
> 任何文件——和 `POST /library/scan` 已有的信任级别一致。在这之上再叠一层按路径的
> ACL 只是安全表演。
### `GET /library/browse?path=<相对路径>`
```json
{ "path": "科幻/已读",
"crumbs": [ { "name": "书库", "path": "" }, { "name": "科幻", "path": "科幻" } ],
"parent": "科幻",
"entries": [
{ "name": "三体.epub", "path": "科幻/已读/三体.epub", "type": "file",
"size": 1234567, "mtime": 1789537864820, "mode": 420,
"hidden": false, "hiddenByRule": false, "scanned": true,
"ext": "epub", "indexed": true }
],
"total": 1, "dirs": 0, "files": 1, "size": 1234567,
"writable": true, "name": "已读" }
```
`hidden` 是「以点开头」,`hiddenByRule` 是「落在扫描器跳过的目录里」,
`scanned` 是「扫描器会把它当一本书」。
**被跳过的条目照样列出来。** `.trash`、`@eaDir` 这些目录是设计上不被扫描的,
在这里过滤掉就正好藏住了读者要找的那批文件——在磁盘上、但不在书架上。标记出来,
让界面能解释,而不是替读者决定不用知道。
`writable` 是实际探测出来的(真的建一个临时目录再删),不是读 mode 位:
`drwxr-xr-x` 的只读 bind mount 看起来可写,写起来 `EPERM`。
符号链接按链接本身显示(`lstat`),不跟随——扫描器也不跟随,跟随等于描述一棵
索引里并不存在的树,还会顺着链接走出挂载点。
### `POST /library/browse/move`
```json
{ "paths": ["科幻/三体.epub"], "target": "科幻/已读" }
```
→ `{ "moved": 1, "target": "科幻/已读" }`
同挂载点内 `rename`,不复制删除:文件字节、`mtime`、`inode` 都不变,扫描器视为
无变化,书籍身份(`identifier + 内容哈希`)因此保持,进度和笔记都还在。
同时把索引指向新路径,不等下一次扫描——文件管理器关掉时就该是一致的。
整批一起校验、一起拒绝:一半成功的移动比拒绝难推理得多。
目标已有同名条目 → `409 DESTINATION_EXISTS`。合并还是报错只有调用方知道,
猜错就是数据丢失。把目录移进它自己 → `400 MOVE_INTO_SELF`。
### `POST /library/browse/rename`
```json
{ "path": "科幻/三体.epub", "name": "三体(修订版).epub" }
```
`name` 是单个路径段,不含斜杠。→ `{ "path": "科幻/三体(修订版).epub" }`
### `POST /library/browse/mkdir`
```json
{ "path": "科幻", "name": "待整理" }
```
→ `{ "path": "科幻/待整理" }`
### `POST /library/browse/delete`
```json
{ "paths": ["科幻/其他/球状闪电.epub"] }
```
→ `{ "removed": 1 }`
递归删除,**不进回收站**。索引行不动,留给下一次扫描标记 `missing`:
删除本身已经是危险的那一半,再绑一次全量扫描会让界面等好几分钟才报告成功。
勾选过的那一行也会一直留着,和「共享目录没挂上」是同一种状态——
「文件没了但书还记着」,比书架突然少一本好。
`paths` 必须是**非空字符串数组**:`"a"` 和 `["a"]` 只差一个键,
但把字符串悄悄包成数组,会让「删了 1 项」看起来像「删了很多项」。
只读挂载上任何写操作 → `403 READ_ONLY_MOUNT`。
### `GET /books/:id/file?path=<rel_path>`
流式返回这本书的**某一个**文件(二进制流,不是 JSON)。
@@ -516,6 +610,15 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
| `UNSUPPORTED_FORMAT` | 400 | 该格式不提供可寻址结构 |
| `EMPTY_ASSET` | 400 | 资源既没有数据也没有流 |
| `ADMIN_REQUIRED` | 403 | 需要管理员权限 |
| `READ_ONLY_MOUNT` | 403 | 书库是只读挂载,无法改名 / 移动 / 删除 |
| `DIR_NOT_FOUND` | 404 | 目录不存在 |
| `NOT_A_DIRECTORY` | 400 | 目标不是目录 |
| `PATH_NOT_FOUND` | 404 | 路径不存在 |
| `DESTINATION_EXISTS` | 409 | 目标位置已有同名条目 |
| `MOVE_INTO_SELF` | 400 | 不能把目录移动到它自己里面 |
| `BAD_NAME` | 400 | 名称含斜杠或非法 |
| `NAME_REQUIRED` | 400 | 名称为空 |
| `NO_PATHS` | 400 | 没有给出路径 |
| `NOT_FOUND` | 404 | 资源不存在 |
| `NO_COVER` | 404 | 这本书没有封面 |
| `FILE_MISSING` | 404 | 文件已从磁盘消失 |
@@ -537,3 +640,8 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
这样换字号、换屏幕宽度、换主题都不会跳位置。
3. **拉取回来的 `deleted: true` 是墓碑**,本地也要删;不要因为它「看起来是空的」就丢弃。
4. **推送是合并而不是覆盖**,按 `updatedAt` 做 LWW。所以离线队列可以放心重放。
5. **403 不等于会话失效**。服务端用 403 表达两类事:「你的账号/令牌不行了」
(`ACCOUNT_DISABLED`、`TOKEN_INVALID`),和「这个操作被拒绝」
(`ADMIN_REQUIRED`、`PATH_TRAVERSAL`、`READ_ONLY_MOUNT`)。
只有前者该清掉凭据跳登录页;把后者也当会话失效,会把读者在读只读挂载上
改个文件名时踢下线,顺手把他存的令牌也删了。按 `error.code` 判断,不要只看状态码。
+9
View File
@@ -6,6 +6,7 @@ import type { UserService, PublicUser } from '../services/users.ts';
import type { ShelfService } from '../services/shelf.ts';
import type { SyncService } from '../services/sync.ts';
import type { TtsService } from '../services/tts.ts';
import type { BrowseService } from '../services/browse.ts';
export interface AppContext {
config: AppConfig;
@@ -15,6 +16,14 @@ export interface AppContext {
shelf: ShelfService;
sync: SyncService;
tts: TtsService;
/**
* The library tree, for the file-manager screen.
*
* Separate from `shelf` on purpose: the shelf serves *books*, keyed by a
* stable identity, and cannot answer "what is on the disk" — a path is not
* part of a book's DTO anywhere in the API.
*/
browse: BrowseService;
log: FastifyBaseLogger;
}
+63
View File
@@ -84,6 +84,22 @@ function groupOffset(groups: Array<{ count: number }>, index: number): number {
return offset;
}
/**
* Validates a `string[]` body field.
*
* Refused rather than coerced: `paths: "a"` and `paths: ["a"]` are one keystroke
* apart, and a silently wrapped string would make a batch delete of one entry
* look like a successful delete of many.
*/
function stringList(value: unknown, field: string): string[] {
if (!Array.isArray(value) || value.length === 0) throw badRequest(`${field} must be a non-empty array`);
if (value.length > 500) throw badRequest(`${field} is too long`);
for (const entry of value) {
if (typeof entry !== 'string' || entry.length === 0) throw badRequest(`${field} must contain strings`);
}
return value as string[];
}
export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): void {
const auth = authenticate(ctx);
@@ -487,6 +503,53 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
return { book: ctx.shelf.get(user.id, id) };
});
// ---- file manager ----
/**
* The library as a tree.
*
* Every path here is library-relative and resolved through `resolveInside`, so
* a traversal attempt is refused before it reaches the disk. Authentication is
* the same as everywhere else; the mount is the only boundary, and it is the
* deployment's own.
*/
app.get('/api/v1/library/browse', { preHandler: auth }, async (request) => {
currentUser(request);
const query = request.query as Record<string, string | undefined>;
return ctx.browse.list(query.path ?? '');
});
app.post('/api/v1/library/browse/move', { preHandler: auth }, async (request) => {
currentUser(request);
const body = (request.body ?? {}) as { paths?: unknown; target?: unknown };
const paths = stringList(body.paths, 'paths');
const target = typeof body.target === 'string' ? body.target : '';
return ctx.browse.move(paths, target);
});
app.post('/api/v1/library/browse/rename', { preHandler: auth }, async (request) => {
currentUser(request);
const body = (request.body ?? {}) as { path?: unknown; name?: unknown };
if (typeof body.path !== 'string' || typeof body.name !== 'string') {
throw badRequest('path and name are required');
}
return ctx.browse.renamePath(body.path, body.name);
});
app.post('/api/v1/library/browse/mkdir', { preHandler: auth }, async (request) => {
currentUser(request);
const body = (request.body ?? {}) as { path?: unknown; name?: unknown };
const parent = typeof body.path === 'string' ? body.path : '';
if (typeof body.name !== 'string') throw badRequest('name is required');
return ctx.browse.createDirectory(parent, body.name);
});
app.post('/api/v1/library/browse/delete', { preHandler: auth }, async (request) => {
currentUser(request);
const body = (request.body ?? {}) as { paths?: unknown };
return ctx.browse.remove(stringList(body.paths, 'paths'));
});
// ---- instance administration ----
app.post('/api/v1/library/scan', { preHandler: auth }, async (request) => {
+2
View File
@@ -5,6 +5,7 @@ import { UserService } from './services/users.ts';
import { ShelfService } from './services/shelf.ts';
import { SyncService } from './services/sync.ts';
import { TtsService } from './services/tts.ts';
import { BrowseService } from './services/browse.ts';
import { buildApp } from './http/app.ts';
import { setLogger } from './lib/log.ts';
import type { AppContext } from './http/context.ts';
@@ -47,6 +48,7 @@ async function main(): Promise<void> {
ctx.shelf = new ShelfService(db);
ctx.sync = new SyncService(db);
ctx.tts = new TtsService(config);
ctx.browse = new BrowseService(db, config);
app.log.info({ booksDir: config.booksDir, dataDir: config.dataDir }, 'starting reader server');
+409
View File
@@ -0,0 +1,409 @@
import { randomUUID } from 'node:crypto';
import { lstat, mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import type { Db } from '../db/index.ts';
import type { AppConfig } from '../config/index.ts';
import { assertSafeRel, resolveInside } from '../lib/paths.ts';
import { badRequest, conflict, forbidden, notFound } from '../lib/errors.ts';
import { isPageExtension, supportedExtensions } from '../indexer/formats/index.ts';
/**
* The library as a *tree*, for the file-manager screen.
*
* The shelf answers "what can I read". This answers "what is on the disk" — the
* question a reader has when a book did not show up after a rescan, and the
* question no other part of the product can answer: a scanned book has no path
* in its DTO, so a library that is silently missing a title is otherwise
* unexplainable from inside the app.
*
* Two rules make that possible, and both are deliberate:
*
* 1. **Hidden entries are listed, not filtered out.** A book inside `.trash` or
* `@eaDir` is invisible to the scanner by design. It is also invisible here
* if this layer drops everything starting with a dot, which is exactly the
* case where the screen needs to say "it is here, and here is why the shelf
* cannot see it". The flags are reported instead (`hidden`, `hiddenByRule`,
* `scanned`), so the UI can explain rather than hide.
* 2. **Writes go only where the mount allows them.** Every mutating call
* resolves the target through `resolveInside` and probes write permission
* before doing anything, so a read-only mount (the documented deployment)
* answers a clean 403 instead of an `EROFS` stack trace.
*
* The mount is the security boundary, and there is no second one: a user who can
* move a file in their library can move any file in it. That is the same trust
* level the admin scan endpoint already assumes, and inventing a per-path ACL on
* top of it would be security theatre.
*/
/** Directories the scanner skips. Reported to the UI rather than filtered. */
export const SKIPPED_DIRS = new Set(['.git', '@eaDir', '#recycle', '.DS_Store', 'lost+found', '__MACOSX']);
/** Never offered for listing: they stay reachable by name, never by discovery. */
const VCS_DIRS = new Set(['.git', '.svn', '.hg']);
export interface BrowseEntry {
name: string;
/** Library-relative path; the id the UI passes back for every operation. */
path: string;
type: 'dir' | 'file' | 'other';
size: number;
mtime: number;
/** Unix mode bits, so the UI can show `-r--r--r--` for a read-only mount. */
mode: number;
/** Leading dot: a convention, and the reason a book can be missing. */
hidden: boolean;
/** Inside a directory the scanner never descends into. */
hiddenByRule: boolean;
/** The scanner indexes this path as a book of its own. */
scanned: boolean;
/** Suffix after the last dot, lowercased, without the dot. */
ext: string;
/** True when this path is what the index actually holds for that book. */
indexed: boolean;
}
export interface BrowseListing {
/** Library-relative path of the directory listed; `''` for the root. */
path: string;
/** Breadcrumb segments, each with the path that reaches it. */
crumbs: Array<{ name: string; path: string }>;
parent: string | null;
entries: BrowseEntry[];
/** Totals for the *directory*, not for this page of it. */
total: number;
dirs: number;
files: number;
size: number;
/** Whether the process may create, rename and delete inside it. */
writable: boolean;
/** `''` for the root, otherwise the folder's own name. */
name: string;
}
export interface BrowseMoveResult {
moved: number;
/** Rows rewritten because the target path was already indexed under another book. */
merged: number;
target: string;
}
export class BrowseService {
constructor(
private readonly db: Db,
private readonly config: AppConfig,
) {}
// ---- reads ----
list(relPathInput: string): Promise<BrowseListing> {
return this.listSync(relPathInput);
}
private async listSync(relPathInput: string): Promise<BrowseListing> {
const relPath = relPathInput === '' ? '' : assertSafeRel(relPathInput);
const abs = resolveInside(this.config.booksDir, relPath);
const info = await stat(abs).catch(() => null);
if (!info) throw notFound('no such directory', 'DIR_NOT_FOUND');
if (!info.isDirectory()) throw badRequest('not a directory', 'NOT_A_DIRECTORY');
const dirEntries = await readdir(abs, { withFileTypes: true });
const entries: BrowseEntry[] = [];
for (const entry of dirEntries.sort((a, b) => a.name.localeCompare(b.name))) {
const childRel = relPath === '' ? entry.name : `${relPath}/${entry.name}`;
if (VCS_DIRS.has(entry.name)) continue;
const childAbs = resolveInside(this.config.booksDir, childRel);
// `lstat`: a symlink is shown as a link, never followed. The scanner does
// not follow them either, so following one here would describe a tree the
// index does not have — and would let a link out of the mount be walked.
const child = await lstat(childAbs).catch(() => null);
if (!child) continue;
entries.push({
name: entry.name,
path: childRel,
type: entry.isDirectory() ? 'dir' : entry.isFile() ? 'file' : 'other',
size: child.isDirectory() ? 0 : child.size,
mtime: Math.floor(child.mtimeMs),
mode: child.mode & 0o777,
hidden: entry.name.startsWith('.'),
hiddenByRule: entry.isDirectory()
? SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')
: SKIPPED_DIRS.has(entry.name),
scanned: entry.isFile() && this.isBookFile(entry.name),
ext: extensionOf(entry.name),
indexed: Boolean(this.fileRow(childRel)),
});
}
const stats = {
total: entries.length,
dirs: entries.filter((entry) => entry.type === 'dir').length,
files: entries.filter((entry) => entry.type === 'file').length,
size: entries.reduce((sum, entry) => sum + entry.size, 0),
};
return {
path: relPath,
crumbs: crumbsFor(relPath),
parent: relPath === '' ? null : dirname(relPath).replace(/^\.$/, ''),
entries,
...stats,
writable: await this.writable(abs),
name: relPath === '' ? '' : basename(relPath),
};
}
/**
* Whether the mount can be written at all.
*
* Answered by actually creating a file rather than by looking at the mode
* bits, because the two disagree in every deployment that matters: a
* read-only bind mount shows `drwxr-xr-x` and still refuses `EPERM`. Probe on
* demand rather than cache: a mount is remounted far more often than this is
* called, and a stale "not writable" would disable the whole screen.
*/
async writable(abs = this.config.booksDir): Promise<boolean> {
const probe = join(abs, `.reader-write-probe-${randomUUID().slice(0, 8)}`);
try {
await mkdir(probe);
await rm(probe, { recursive: true, force: true });
return true;
} catch {
return false;
}
}
private isBookFile(name: string): boolean {
const ext = extensionOf(name);
if (!ext) return false;
if (!supportedExtensions().has(`.${ext}`)) return false;
return !isPageExtension(ext);
}
private fileRow(relPath: string): { id: string; book_id: string; missing: number } | undefined {
return this.db.get<{ id: string; book_id: string; missing: number }>(
'SELECT id, book_id, missing FROM book_files WHERE rel_path = ?',
relPath,
);
}
// ---- writes ----
/**
* Moves or renames a batch of paths into a directory.
*
* A rename inside the same mount keeps `mtime`, `inode` and — crucially —
* the file's bytes unchanged, so the scanner sees an unchanged file and the
* book keeps its identity, its progress and its notes. That is the whole
* reason this is a `rename` rather than a copy-and-delete, and it is also why
* a move onto an already-indexed path can be resolved as a merge: two rows,
* one set of bytes.
*/
async move(paths: string[], targetInput: string): Promise<BrowseMoveResult> {
if (paths.length === 0) throw badRequest('no paths given', 'NO_PATHS');
const target = targetInput === '' ? '' : assertSafeRel(targetInput);
const targetAbs = resolveInside(this.config.booksDir, target);
const targetInfo = await stat(targetAbs).catch(() => null);
if (!targetInfo?.isDirectory()) throw badRequest('target is not a directory', 'NOT_A_DIRECTORY');
// Refuse the whole batch rather than half of it: a partially applied move is
// far harder to reason about than a refused one.
for (const input of paths) {
const source = assertSafeRel(input);
// `target` inside `source` is the one arrangement a rename cannot express:
// the destination is inside the thing being moved. Asked as "is the target
// under the source", which is the opposite question from containment.
if (source !== '' && this.isInside(source, target)) throw badRequest('cannot move a directory into itself', 'MOVE_INTO_SELF');
const abs = resolveInside(this.config.booksDir, source);
if (!(await stat(abs).catch(() => null))) throw notFound(`no such path: ${source}`, 'PATH_NOT_FOUND');
}
let moved = 0;
let merged = 0;
for (const input of paths) {
const source = assertSafeRel(input);
const destination = target === '' ? basename(source) : `${target}/${basename(source)}`;
if (destination === source) continue;
const destinationAbs = resolveInside(this.config.booksDir, destination);
const existing = await stat(destinationAbs).catch(() => null);
if (existing) {
// Two outcomes are both correct here and only the caller knows which was
// meant, so it is refused instead of guessed: a merge would be
// indistinguishable from data loss if the reader expected an error.
throw conflict(`target already has an entry named ${basename(source)}`, 'DESTINATION_EXISTS');
}
await this.guardWrite(resolveInside(this.config.booksDir, source));
await mkdir(dirname(destinationAbs), { recursive: true });
await rename(resolveInside(this.config.booksDir, source), destinationAbs);
this.rewritePaths(source, destination);
merged += 1;
moved += 1;
}
return { moved, merged, target };
}
/** Renames one path in place. `newName` is a single segment, not a path. */
async renamePath(pathInput: string, newName: string): Promise<{ path: string }> {
const source = assertSafeRel(pathInput);
const name = newName.trim();
if (!name) throw badRequest('name is required', 'NAME_REQUIRED');
if (name.includes('/') || name.includes('\\')) throw badRequest('name must not contain a slash', 'BAD_NAME');
if (name === '.' || name === '..') throw badRequest('invalid name', 'BAD_NAME');
const parent = dirname(source).replace(/^\.$/, '');
const destination = parent === '' ? name : `${parent}/${name}`;
if (destination === source) return { path: source };
const sourceAbs = resolveInside(this.config.booksDir, source);
const destinationAbs = resolveInside(this.config.booksDir, destination);
if (!(await lstat(sourceAbs).catch(() => null))) throw notFound('no such path', 'PATH_NOT_FOUND');
if (await lstat(destinationAbs).catch(() => null)) throw conflict('target name is taken', 'DESTINATION_EXISTS');
await this.guardWrite(sourceAbs);
await rename(sourceAbs, destinationAbs);
this.rewritePaths(source, destination);
return { path: destination };
}
async createDirectory(parentInput: string, name: string): Promise<{ path: string }> {
const parent = parentInput === '' ? '' : assertSafeRel(parentInput);
const trimmed = name.trim();
if (!trimmed) throw badRequest('name is required', 'NAME_REQUIRED');
if (trimmed.includes('/') || trimmed.includes('\\')) throw badRequest('name must not contain a slash', 'BAD_NAME');
const relPath = parent === '' ? trimmed : `${parent}/${trimmed}`;
const abs = resolveInside(this.config.booksDir, relPath);
if (await lstat(abs).catch(() => null)) throw conflict('already exists', 'DESTINATION_EXISTS');
await this.guardWrite(resolveInside(this.config.booksDir, parent));
await mkdir(abs, { recursive: false });
return { path: relPath };
}
/**
* Deletes paths, recursively, without invoking the scanner.
*
* The index rows stay until the next scan. That is not laziness: the deletion
* is already the risky half, and coupling it to a full rescan would make the
* UI wait minutes to report success. A marked-missing row also keeps the count
* honest — the same "file is gone but the book is remembered" state a book on
* an unmounted share is in.
*/
async remove(paths: string[]): Promise<{ removed: number }> {
if (paths.length === 0) throw badRequest('no paths given', 'NO_PATHS');
for (const input of paths) {
const relPath = assertSafeRel(input);
const abs = resolveInside(this.config.booksDir, relPath);
if (!(await lstat(abs).catch(() => null))) throw notFound(`no such path: ${relPath}`, 'PATH_NOT_FOUND');
}
let removed = 0;
for (const input of paths) {
const relPath = assertSafeRel(input);
const abs = resolveInside(this.config.booksDir, relPath);
await this.guardWrite(abs);
await rm(abs, { recursive: true, force: true });
removed += 1;
}
return { removed };
}
/**
* Turns an `EPERM`/`EROFS`/`EACCES` from the filesystem into a clean 403.
*
* A probe is cheaper to reason about than inferring from the error alone: the
* permission that matters is on the *parent* (unlink needs the directory), and
* that is not the path the caller passed.
*/
private async guardWrite(absPath: string): Promise<void> {
const parent = dirname(absPath);
const parentOk = await this.writable(parent === absPath ? absPath : parent);
if (parentOk) return;
throw forbidden(
'the library mount is read-only; remount it without `:ro` to manage files here',
'READ_ONLY_MOUNT',
);
}
/** Whether `child` is `parent` itself or lives underneath it. */
private isInside(parent: string, child: string): boolean {
return child === parent || child.startsWith(`${parent}/`);
}
/**
* Repoints the index at a path that moved, in one transaction.
*
* Nothing else has to run: the book's identity is `identifier + content hash`,
* so a renamed file *is* the same book — the row only has to stop pointing at
* a path that no longer exists. Doing it here rather than waiting for a scan
* is what keeps progress and notes attached while the file manager is open.
*
* The one case that needs care is a move onto a path the index already holds
* under a *different* book — which happens whenever a library was reorganised
* outside the app and scanned in between. The two rows then name one file, and
* `rel_path` is UNIQUE, so the stale row is dropped and its user state is
* carried over rather than left pointing at a book with no file.
*/
private rewritePaths(from: string, to: string): void {
const rows = this.db.all<{ id: string; book_id: string; rel_path: string }>(
'SELECT id, book_id, rel_path FROM book_files WHERE rel_path = ? OR rel_path LIKE ?',
from,
`${escapeLike(from)}/%`,
);
for (const row of rows) {
const next = row.rel_path === from ? to : `${to}${row.rel_path.slice(from.length)}`;
const clash = this.db.get<{ id: string; book_id: string }>(
'SELECT id, book_id FROM book_files WHERE rel_path = ? AND id <> ? AND missing = 0',
next,
row.id,
);
if (clash && clash.book_id !== row.book_id) {
this.adoptUserState(clash.book_id, row.book_id);
this.db.run('DELETE FROM book_files WHERE id = ?', clash.id);
}
this.db.run('UPDATE book_files SET rel_path = ?, missing = 0 WHERE id = ?', next, row.id);
}
}
/** Moves per-user state from a duplicate book onto the one that survives. */
private adoptUserState(fromBookId: string, toBookId: string): void {
this.db.run(
`INSERT OR IGNORE INTO user_books (user_id, book_id, added_at)
SELECT user_id, ?, added_at FROM user_books WHERE book_id = ?`,
toBookId,
fromBookId,
);
// Newest progress wins, which is the same last-writer-wins rule sync applies.
this.db.run(
`INSERT INTO reading_progress (user_id, book_id, locator, percentage, chapter_title, device, updated_at)
SELECT user_id, ?, locator, percentage, chapter_title, device, updated_at
FROM reading_progress WHERE book_id = ?
ON CONFLICT(user_id, book_id) DO UPDATE SET
locator = excluded.locator, percentage = excluded.percentage,
chapter_title = excluded.chapter_title, device = excluded.device,
updated_at = excluded.updated_at
WHERE excluded.updated_at > reading_progress.updated_at`,
toBookId,
fromBookId,
);
this.db.run('UPDATE notes SET book_id = ? WHERE book_id = ?', toBookId, fromBookId);
}
}
function extensionOf(name: string): string {
const dot = name.lastIndexOf('.');
if (dot <= 0) return '';
return name.slice(dot + 1).toLowerCase();
}
function crumbsFor(relPath: string): Array<{ name: string; path: string }> {
const crumbs: Array<{ name: string; path: string }> = [{ name: '书库', path: '' }];
if (!relPath) return crumbs;
const parts = relPath.split('/');
let acc = '';
for (const part of parts) {
acc = acc === '' ? part : `${acc}/${part}`;
crumbs.push({ name: part, path: acc });
}
return crumbs;
}
/** LIKE-escape, so a folder named `100%` cannot widen the prefix match. */
function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
}
+3
View File
@@ -20,6 +20,7 @@ import { UserService } from '../src/services/users.ts';
import { ShelfService } from '../src/services/shelf.ts';
import { SyncService } from '../src/services/sync.ts';
import { TtsService } from '../src/services/tts.ts';
import { BrowseService } from '../src/services/browse.ts';
import { buildApp } from '../src/http/app.ts';
import type { AppContext } from '../src/http/context.ts';
@@ -68,6 +69,7 @@ before(async () => {
shelf: undefined as never,
sync: undefined as never,
tts: undefined as never,
browse: undefined as never,
log: undefined as never,
};
const silent = Fastify({ logger: false });
@@ -77,6 +79,7 @@ before(async () => {
ctx.shelf = new ShelfService(db);
ctx.sync = new SyncService(db);
ctx.tts = new TtsService(config);
ctx.browse = new BrowseService(db, config);
app = buildApp(ctx);
await app.ready();
});
+405
View File
@@ -0,0 +1,405 @@
/**
* File-manager API tests.
*
* The screen these back is the only place in the product that *writes* to the
* library mount, so the behaviours asserted here are the ones where a mistake
* costs the reader their files or their reading position: path containment,
* refusing a write on a read-only mount, and keeping progress attached to a book
* whose file was moved.
*/
import { test, before, after, describe } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import JSZip from 'jszip';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { loadConfig } from '../src/config/index.ts';
import { openDatabase } from '../src/db/index.ts';
import { Scanner } from '../src/indexer/scanner.ts';
import { UserService } from '../src/services/users.ts';
import { ShelfService } from '../src/services/shelf.ts';
import { SyncService } from '../src/services/sync.ts';
import { TtsService } from '../src/services/tts.ts';
import { BrowseService } from '../src/services/browse.ts';
import { buildApp } from '../src/http/app.ts';
import type { AppContext } from '../src/http/context.ts';
let app: FastifyInstance;
let ctx: AppContext;
let root: string;
let booksDir: string;
let token: string;
let userId: string;
const OPF = (title: string, id: string) => `<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="bookid">${id}</dc:identifier>
<dc:title>${title}</dc:title><dc:creator>作者</dc:creator><dc:language>zh</dc:language>
</metadata>
<manifest><item id="c" href="c.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine><itemref idref="c"/></spine></package>`;
async function makeEpub(title: string, id: string): Promise<Buffer> {
const zip = new JSZip();
zip.file('mimetype', 'application/epub+zip');
zip.file(
'META-INF/container.xml',
'<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>',
);
zip.file('OEBPS/content.opf', OPF(title, id));
zip.file('OEBPS/c.xhtml', `<html><body><p>${title}</p></body></html>`);
return zip.generateAsync({ type: 'nodebuffer' });
}
function auth() {
return { authorization: `Bearer ${token}` };
}
before(async () => {
root = await mkdtemp(join(tmpdir(), 'reader-browse-'));
booksDir = join(root, 'books');
await mkdir(booksDir, { recursive: true });
process.env.BOOKS_DIR = booksDir;
process.env.DATA_DIR = join(root, 'data');
process.env.SCAN_INTERVAL = '0';
process.env.WATCH_INTERVAL = '0';
process.env.READER_TOKEN_SECRET = 'test-secret-that-is-long-enough';
const config = loadConfig();
const db = openDatabase(config);
ctx = {
config,
db,
scanner: undefined as never,
users: undefined as never,
shelf: undefined as never,
sync: undefined as never,
tts: undefined as never,
browse: undefined as never,
log: undefined as never,
};
const silent = Fastify({ logger: false });
ctx.log = silent.log;
ctx.scanner = new Scanner(db, config, { info: () => {}, warn: () => {} });
ctx.users = new UserService(db, config);
ctx.shelf = new ShelfService(db);
ctx.sync = new SyncService(db);
ctx.tts = new TtsService(config);
ctx.browse = new BrowseService(db, config);
app = buildApp(ctx);
await app.ready();
const created = await app.inject({
method: 'POST',
url: '/api/v1/auth/register',
payload: { username: 'owner', password: 'password123' },
});
assert.equal(created.statusCode, 201, created.body);
const body = created.json() as { session: { accessToken: string }; user: { id: string } };
token = body.session.accessToken;
userId = body.user.id;
await mkdir(join(booksDir, '科幻', '已读'), { recursive: true });
await writeFile(join(booksDir, '科幻', '三体.epub'), await makeEpub('三体', 'urn:one'));
await writeFile(join(booksDir, '科幻', '已读', '球状闪电.epub'), await makeEpub('球状闪电', 'urn:two'));
await writeFile(join(booksDir, '说明.txt'), Buffer.from('hello', 'utf8'));
await mkdir(join(booksDir, '.trash'), { recursive: true });
await writeFile(join(booksDir, '.trash', '旧书.epub'), await makeEpub('旧书', 'urn:three'));
});
after(async () => {
await app.close();
await rm(root, { recursive: true, force: true });
});
describe('library browsing', () => {
test('the root lists directories, book files and rule-hidden folders alike', async () => {
const res = await app.inject({ method: 'GET', url: '/api/v1/library/browse', headers: auth() });
assert.equal(res.statusCode, 200);
const listing = res.json() as {
path: string;
parent: string | null;
crumbs: Array<{ name: string; path: string }>;
entries: Array<{ name: string; type: string; scanned: boolean; hidden: boolean; hiddenByRule: boolean }>;
writable: boolean;
};
assert.equal(listing.path, '');
assert.equal(listing.parent, null);
assert.deepEqual(listing.crumbs, [{ name: '书库', path: '' }]);
// A writable mount is the only state in which the UI offers to rename or
// delete anything, so the flag is part of the contract rather than a nicety.
assert.equal(listing.writable, true);
const byName = new Map(listing.entries.map((entry) => [entry.name, entry]));
assert.equal(byName.get('科幻')?.type, 'dir');
assert.equal(byName.get('说明.txt')?.scanned, true, 'a .txt is a book the scanner indexes');
// The hidden folder is listed, not filtered: "my book is missing from the
// shelf" is answerable only if the screen can show where it actually is.
assert.equal(byName.get('.trash')?.hidden, true);
assert.equal(byName.get('.trash')?.hiddenByRule, true);
});
test('a nested directory reports its breadcrumb and its own entries', async () => {
const res = await app.inject({
method: 'GET',
url: `/api/v1/library/browse?path=${encodeURIComponent('科幻/已读')}`,
headers: auth(),
});
assert.equal(res.statusCode, 200);
const listing = res.json() as {
path: string;
parent: string;
crumbs: Array<{ name: string; path: string }>;
entries: Array<{ name: string; indexed: boolean }>;
dirs: number;
files: number;
};
assert.equal(listing.path, '科幻/已读');
assert.equal(listing.parent, '科幻');
assert.deepEqual(listing.crumbs, [
{ name: '书库', path: '' },
{ name: '科幻', path: '科幻' },
{ name: '已读', path: '科幻/已读' },
]);
assert.equal(listing.files, 1);
assert.equal(listing.dirs, 0);
});
test('a traversal attempt is refused, not resolved', async () => {
for (const path of ['../../etc', '..', '/etc', '科幻/../../..']) {
const res = await app.inject({
method: 'GET',
url: `/api/v1/library/browse?path=${encodeURIComponent(path)}`,
headers: auth(),
});
assert.ok(res.statusCode >= 400, `${path} answered ${res.statusCode}`);
}
});
test('the API requires a token', async () => {
const res = await app.inject({ method: 'GET', url: '/api/v1/library/browse' });
assert.equal(res.statusCode, 401);
});
});
describe('library file management', () => {
test('renaming a book keeps its identity, its progress and its notes', async () => {
await ctx.scanner.scan();
const book = ctx.db.get<{ id: string; title: string }>('SELECT id, title FROM books WHERE title = ?', '三体');
assert.ok(book, 'the book should be indexed');
ctx.sync.push(userId, {
progress: [
{
bookId: book.id,
locator: 'epubcfi(/6/4!/4/2)',
percentage: 0.42,
chapterTitle: '第七章',
device: 'test',
updatedAt: Date.now(),
},
],
});
const renamed = await app.inject({
method: 'POST',
url: '/api/v1/library/browse/rename',
headers: auth(),
payload: { path: '科幻/三体.epub', name: '三体(修订版).epub' },
});
assert.equal(renamed.statusCode, 200, renamed.body);
assert.equal((renamed.json() as { path: string }).path, '科幻/三体(修订版).epub');
// The index is repointed immediately rather than at the next scan: the file
// manager has to leave the library consistent with itself when it closes.
const row = ctx.db.get<{ book_id: string; rel_path: string }>(
'SELECT book_id, rel_path FROM book_files WHERE rel_path = ?',
'科幻/三体(修订版).epub',
);
assert.equal(row?.book_id, book.id, 'the book must keep its identity across a rename');
// A rescan must agree: identity is `identifier + content hash`, not a path.
await ctx.scanner.scan();
const after = ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM books WHERE title = ?', '三体');
assert.equal(after?.n, 1);
const progress = ctx.db.get<{ percentage: number }>(
'SELECT percentage FROM reading_progress WHERE book_id = ?',
book.id,
);
assert.equal(progress?.percentage, 0.42, 'progress must survive a rename');
});
test('moving a book into a folder keeps it on the shelf, once', async () => {
const moved = await app.inject({
method: 'POST',
url: '/api/v1/library/browse/move',
headers: auth(),
payload: { paths: ['科幻/三体(修订版).epub'], target: '科幻/已读' },
});
assert.equal(moved.statusCode, 200, moved.body);
assert.equal((moved.json() as { moved: number }).moved, 1);
await ctx.scanner.scan();
const count = ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM books WHERE title = ?', '三体');
assert.equal(count?.n, 1, 'a move must not leave a duplicate behind');
const listing = await app.inject({
method: 'GET',
url: `/api/v1/library/browse?path=${encodeURIComponent('科幻/已读')}`,
headers: auth(),
});
const names = (listing.json() as { entries: Array<{ name: string }> }).entries.map((entry) => entry.name);
assert.ok(names.includes('三体(修订版).epub'));
});
test('a move onto an existing name is refused rather than silently merged', async () => {
await mkdir(join(booksDir, '科幻', '其他'), { recursive: true });
await writeFile(join(booksDir, '科幻', '其他', '球状闪电.epub'), await makeEpub('球状闪电(另一份)', 'urn:four'));
const res = await app.inject({
method: 'POST',
url: '/api/v1/library/browse/move',
headers: auth(),
payload: { paths: ['科幻/已读/球状闪电.epub'], target: '科幻/其他' },
});
assert.equal(res.statusCode, 409);
assert.equal((res.json() as { error: { code: string } }).error.code, 'DESTINATION_EXISTS');
// The source must still be there: refusing a batch means refusing all of it.
await stat(join(booksDir, '科幻', '已读', '球状闪电.epub'));
});
test('a move into itself is refused', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/library/browse/move',
headers: auth(),
payload: { paths: ['科幻'], target: '科幻/已读' },
});
assert.equal(res.statusCode, 400);
assert.equal((res.json() as { error: { code: string } }).error.code, 'MOVE_INTO_SELF');
});
test('a new folder can be created and deleted, but a traversal cannot', async () => {
const created = await app.inject({
method: 'POST',
url: '/api/v1/library/browse/mkdir',
headers: auth(),
payload: { path: '', name: '漫画' },
});
assert.equal(created.statusCode, 200, created.body);
const escaped = await app.inject({
method: 'POST',
url: '/api/v1/library/browse/mkdir',
headers: auth(),
payload: { path: '', name: '../外面' },
});
assert.equal(escaped.statusCode, 400);
const listed = await app.inject({ method: 'GET', url: '/api/v1/library/browse', headers: auth() });
const names = (listed.json() as { entries: Array<{ name: string }> }).entries.map((entry) => entry.name);
assert.ok(names.includes('漫画'));
const deleted = await app.inject({
method: 'POST',
url: '/api/v1/library/browse/delete',
headers: auth(),
payload: { paths: ['漫画'] },
});
assert.equal(deleted.statusCode, 200);
assert.equal((deleted.json() as { removed: number }).removed, 1);
await assert.rejects(stat(join(booksDir, '漫画')));
});
test('deleting a book removes the bytes and leaves the index to the scanner', async () => {
// A file that is actually indexed: `科幻/已读/三体(修订版).epub` moved here by
// an earlier test. A path the scanner never saw would leave the interesting
// half of this behaviour — what happens to an indexed row — untested.
await ctx.scanner.scan();
const relPath = '科幻/已读/三体(修订版).epub';
const target = join(booksDir, relPath);
await readFile(target);
const before = ctx.db.get<{ book_id: string; missing: number }>(
'SELECT book_id, missing FROM book_files WHERE rel_path = ?',
relPath,
);
assert.ok(before, 'the book should be indexed before it is deleted');
assert.equal(before.missing, 0);
const res = await app.inject({
method: 'POST',
url: '/api/v1/library/browse/delete',
headers: auth(),
payload: { paths: [relPath] },
});
assert.equal(res.statusCode, 200, res.body);
await assert.rejects(stat(target));
// The row stays live until a scan notices, on purpose: the deletion itself is
// the risky half, and coupling it to a full rescan would make the UI wait
// minutes to report success.
const row = ctx.db.get<{ missing: number }>(
'SELECT missing FROM book_files WHERE rel_path = ?',
relPath,
);
assert.equal(row?.missing, 0, 'a delete does not rewrite the index');
await ctx.scanner.scan();
const after = ctx.db.get<{ missing: number }>(
'SELECT missing FROM book_files WHERE rel_path = ?',
relPath,
);
assert.equal(after?.missing, 1, 'a scan marks the file missing rather than dropping the book');
// And the book is remembered: a book on an unmounted share must not vanish
// from the reader's shelf the moment its file goes away.
assert.ok(
ctx.db.get<{ id: string }>('SELECT id FROM books WHERE id = ?', before.book_id),
'the book row survives a deleted file',
);
});
test('deleting several entries at once is all or nothing', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/library/browse/delete',
headers: auth(),
payload: { paths: ['说明.txt', '科幻/不存在的.epub'] },
});
assert.equal(res.statusCode, 404);
// The existing entry must survive a batch that named a missing one.
await stat(join(booksDir, '说明.txt'));
});
test('a malformed batch is rejected rather than coerced', async () => {
for (const payload of [{}, { paths: [] }, { paths: '科幻' }, { paths: [1] }]) {
const res = await app.inject({
method: 'POST',
url: '/api/v1/library/browse/delete',
headers: auth(),
payload,
});
assert.equal(res.statusCode, 400, JSON.stringify(payload));
}
});
});
describe('a read-only mount', () => {
test('reports itself as not writable and refuses writes with 403', async () => {
const service = new BrowseService(ctx.db, ctx.config);
const original = service.writable.bind(service);
// Stands in for a read-only bind mount, which looks writable by mode bits and
// refuses every write. Simulated on the probe because the alternative is
// chmod-ing the test's own mount, which CI runs as root and would ignore.
service.writable = async () => false;
// The routes hold their own reference to the service, so this only exercises
// the guard's contract: a probe that says "no" must become a 403 rather than
// an EROFS stack trace.
await assert.rejects(
service.renamePath('科幻/已读/球状闪电.epub', '球状闪电-改名.epub'),
(err: unknown) => (err as { statusCode?: number }).statusCode === 403,
);
service.writable = original;
});
});
+3
View File
@@ -32,6 +32,7 @@ import { UserService } from '../src/services/users.ts';
import { ShelfService } from '../src/services/shelf.ts';
import { SyncService } from '../src/services/sync.ts';
import { TtsService } from '../src/services/tts.ts';
import { BrowseService } from '../src/services/browse.ts';
import { buildApp } from '../src/http/app.ts';
import type { AppContext } from '../src/http/context.ts';
@@ -95,6 +96,7 @@ async function buildInstance(): Promise<void> {
shelf: undefined as never,
sync: undefined as never,
tts: undefined as never,
browse: undefined as never,
log: undefined as never,
};
const silent = Fastify({ logger: false });
@@ -104,6 +106,7 @@ async function buildInstance(): Promise<void> {
ctx.shelf = new ShelfService(db);
ctx.sync = new SyncService(db);
ctx.tts = new TtsService(config);
ctx.browse = new BrowseService(db, config);
app = buildApp(ctx);
await app.ready();
}
+30
View File
@@ -2,6 +2,7 @@ import { ApiError, errorForStatus, parseErrorBody } from './errors.ts';
import type {
Book,
BookContent,
BrowseListing,
BookListPage,
ContinueReadingItem,
Facets,
@@ -282,6 +283,35 @@ export class ReaderApi {
return result.items;
}
// ---- library file manager ----
/**
* One directory of the library tree.
*
* `path` is library-relative and `''` means the root; the server resolves it
* through the same containment check every other filesystem access uses.
*/
async browse(path = '', options: RequestOptions = {}): Promise<BrowseListing> {
const suffix = path ? `?path=${encodeURIComponent(path)}` : '';
return this.get<BrowseListing>(`/api/v1/library/browse${suffix}`, options);
}
async browseMove(paths: string[], target: string, options: RequestOptions = {}): Promise<{ moved: number; target: string }> {
return this.call('/api/v1/library/browse/move', 'POST', { paths, target }, options);
}
async browseRename(path: string, name: string, options: RequestOptions = {}): Promise<{ path: string }> {
return this.call('/api/v1/library/browse/rename', 'POST', { path, name }, options);
}
async browseMkdir(path: string, name: string, options: RequestOptions = {}): Promise<{ path: string }> {
return this.call('/api/v1/library/browse/mkdir', 'POST', { path, name }, options);
}
async browseDelete(paths: string[], options: RequestOptions = {}): Promise<{ removed: number }> {
return this.call('/api/v1/library/browse/delete', 'POST', { paths }, options);
}
// ---- content ----
/** Authenticated book body. Not cacheable by the service worker: it needs a token. */
+21 -2
View File
@@ -35,12 +35,31 @@ export class ApiError extends Error {
return this.kind === 'offline';
}
/** True when the failure means the stored credentials are no longer usable. */
/**
* True when the failure means the stored credentials are no longer usable.
*
* A 403 is ambiguous on this API and the distinction matters: the server uses it
* both for "your account is gone" (`ACCOUNT_DISABLED`, which has to sign the
* reader out) and for "that operation is refused" (`ADMIN_REQUIRED`,
* `PATH_TRAVERSAL`, `READ_ONLY_MOUNT` — none of which say anything about the
* session). Treating every 403 as a dead session meant a reader who tried to
* rename a file on a read-only mount was thrown back to the login screen, with
* their credentials silently discarded, for an error that had nothing to do
* with signing in.
*
* So the code decides, and the status is only the fallback for a 403 whose code
* the client cannot classify.
*/
get isAuthFailure(): boolean {
return this.kind === 'unauthorized' || this.kind === 'forbidden';
if (this.kind === 'unauthorized') return true;
if (this.kind !== 'forbidden') return false;
return AUTH_FAILURE_CODES.has(this.code);
}
}
/** The 403 codes that mean the session itself is no longer valid. */
const AUTH_FAILURE_CODES = new Set(['ACCOUNT_DISABLED', 'TOKEN_INVALID', 'NO_TOKEN']);
const KIND_BY_STATUS: Record<number, ApiErrorKind> = {
400: 'bad_request',
401: 'unauthorized',
+39
View File
@@ -197,6 +197,45 @@ export interface ContinueReadingItem extends Book {
lastReadAt: number | null;
}
/**
* One entry in the library tree, as the file-manager screen sees it.
*
* Carries the flags rather than the decisions: `hidden`, `hiddenByRule` and
* `scanned` together let the UI explain *why* a book on disk is not on the shelf,
* which is the one question no other screen can answer.
*/
export interface BrowseEntry {
name: string;
/** Library-relative path; the id every operation passes back. */
path: string;
type: 'dir' | 'file' | 'other';
size: number;
mtime: number;
mode: number;
hidden: boolean;
hiddenByRule: boolean;
/** The scanner indexes this path as a book of its own. */
scanned: boolean;
ext: string;
/** The index currently holds a row for this exact path. */
indexed: boolean;
}
export interface BrowseListing {
/** `''` is the library root. */
path: string;
crumbs: Array<{ name: string; path: string }>;
parent: string | null;
entries: BrowseEntry[];
total: number;
dirs: number;
files: number;
size: number;
/** Whether the mount accepts writes; the UI hides its write controls when not. */
writable: boolean;
name: string;
}
export interface ApiErrorBody {
error: { code: string; message: string };
}
+25
View File
@@ -17,6 +17,7 @@ import { SettingsStore, DEFAULT_APP_SETTINGS, type AppSettings } from './store/s
import { ShelfScreen } from './ui/shelf-screen.ts';
import { ReaderScreen } from './ui/reader-screen.ts';
import { LoginScreen } from './ui/login-screen.ts';
import { ManagerScreen } from './ui/manager-screen.ts';
import { el } from './ui/dom.ts';
/**
@@ -30,6 +31,7 @@ import { el } from './ui/dom.ts';
const SCREEN_SHELF = 'shelf';
const SCREEN_READER = 'reader';
const SCREEN_MANAGER = 'manager';
export interface AppOptions {
/** Baked-in server URL, used when nothing is stored yet. */
@@ -49,6 +51,14 @@ export class App {
private shelf: ShelfScreen | null = null;
private reader: ReaderScreen | null = null;
/**
* The library file manager, opened from the shelf.
*
* A screen rather than a panel because it navigates: it has its own path, its
* own back action and its own selection state, and a panel over the shelf would
* have to reimplement all three badly.
*/
private manager: ManagerScreen | null = null;
/**
* Native fixed-layout renderer, present only in the Android shell.
*
@@ -136,6 +146,8 @@ export class App {
this.reader = null;
this.shelf?.dispose();
this.shelf = null;
this.manager?.dispose();
this.manager = null;
this.root.replaceChildren();
this.screen = screen;
}
@@ -165,6 +177,7 @@ export class App {
// the reader's, so the two cannot disagree about where a preference lives.
settings: this.settings,
onOpenBook: (book) => void this.openBook(book),
onOpenManager: () => void this.showManager(),
onSignedOut: () => this.handleSignedOut(),
onSettingsChange: (patch) => {
void this.settingsStore.update(patch);
@@ -177,6 +190,18 @@ export class App {
this.pendingBook = null;
}
private async showManager(): Promise<void> {
this.setScreen(SCREEN_MANAGER);
const manager = new ManagerScreen({
api: this.api,
onClose: () => this.showShelf(),
onSignedOut: () => this.handleSignedOut(),
});
this.manager = manager;
this.root.append(manager.element);
await manager.open();
}
private async openBook(book: Book): Promise<void> {
this.setScreen(SCREEN_READER);
const reader = new ReaderScreen({
+299
View File
@@ -1238,3 +1238,302 @@ book-content ::selection {
background: var(--reader-accent);
color: #fff;
}
/* ------------------------------------------------------------------ *
* 书库管理 — the library as a file manager
*
* The one screen that shows the mount rather than the catalog, so its job is to
* stay out of the way: a path you can read at the top, rows that navigate, and
* the destructive actions behind an explicit selection.
* ------------------------------------------------------------------ */
/*
* The screen fills the app and scrolls internally, like every other screen.
* `#app` is `overflow: hidden`, so a screen that does not claim `min-height: 0`
* on the flex chain pushes its own content off the bottom instead of scrolling.
* `position: relative` is what the dialogs and the selection pill are placed
* against.
*/
.manager-screen {
position: relative;
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.manager-crumbs {
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 0.15rem;
overflow-x: auto;
scrollbar-width: none;
min-width: 0;
}
.manager-crumbs::-webkit-scrollbar {
display: none;
}
.manager-crumb {
appearance: none;
border: 0;
background: transparent;
color: var(--reader-ink);
font-size: 0.9rem;
font-family: inherit;
padding: 0.35rem 0.25rem;
white-space: nowrap;
cursor: pointer;
}
.manager-crumb[aria-current='true'] {
font-weight: 600;
}
.manager-crumb-sep {
color: var(--reader-muted);
}
.manager-body {
flex: 1 1 auto;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
min-height: 0;
}
.manager-list {
padding: 0;
}
.manager-row {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--reader-line);
cursor: pointer;
touch-action: pan-y;
}
.manager-row:active {
background: color-mix(in srgb, var(--reader-ink) 6%, transparent);
}
.manager-row[data-selected='true'] {
background: color-mix(in srgb, var(--reader-accent) 14%, transparent);
}
/*
* The checkbox is always laid out and only made visible in selection mode.
*
* Toggling `display` instead would shift every row's indent the moment a
* selection begins, which reads as the list jumping under the finger that just
* started the selection.
*/
.manager-check {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
border: 1.5px solid var(--reader-line);
box-sizing: border-box;
/* Always laid out, only visible while something is selected: toggling
`display` would shift every row's indent the moment a selection begins,
which reads as the list jumping under the finger that started it. */
opacity: 0;
}
.manager-row[data-selected] .manager-check {
opacity: 1;
}
.manager-row[data-selected='true'] .manager-check {
border-color: var(--reader-accent);
background: var(--reader-accent);
}
.manager-name {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.manager-icon {
font-size: 1rem;
flex: 0 0 auto;
}
.manager-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9rem;
}
.manager-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.72rem;
white-space: nowrap;
}
.manager-badge {
border: 1px solid var(--reader-line);
border-radius: 4px;
padding: 0 0.25rem;
font-size: 0.65rem;
color: var(--reader-muted);
}
/* The badge a reader needs to see is the one explaining a missing book. */
.manager-badge.warn {
border-color: color-mix(in srgb, var(--reader-accent) 60%, transparent);
color: var(--reader-accent);
}
.manager-more {
width: 2rem;
height: 2rem;
font-size: 1.1rem;
}
/*
* On a read-only mount the row menu offers nothing but "选择", so it is dimmed
* rather than removed: the row still has to line up with the others, and a
* column that appears and disappears with the deployment reads as a bug.
*/
.manager-screen[data-writable='false'] .manager-more {
opacity: 0.35;
}
/*
* The counts sit *inside* the scroller, not after it.
*
* Pinned below a scrolling list they were the first thing pushed off-screen by a
* directory of forty files — which is exactly when a reader wants to know how
* many there are, and exactly when "删除完成" would have gone unread.
*/
.manager-status {
position: sticky;
inset-block-end: 0;
font-size: 0.75rem;
text-align: center;
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--reader-surface) 92%, transparent);
backdrop-filter: blur(6px);
border-top: 1px solid transparent;
}
.manager-status:not(:empty) {
border-top-color: var(--reader-line);
}
.manager-actions {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.6rem 0.75rem;
padding-bottom: calc(0.6rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--reader-line);
background: var(--reader-surface);
}
.manager-actions[hidden] {
display: none;
}
.manager-actions .button {
flex: 1 1 auto;
}
.manager-selection {
position: absolute;
inset-block-start: calc(env(safe-area-inset-top) + 0.25rem);
inset-inline-start: 50%;
transform: translateX(-50%);
background: var(--reader-ink);
color: var(--reader-paper);
border-radius: 999px;
padding: 0.2rem 0.7rem;
font-size: 0.72rem;
pointer-events: none;
z-index: 25;
}
.manager-selection[hidden] {
display: none;
}
.button.danger {
border-color: color-mix(in srgb, #c0392b 55%, transparent);
color: #c0392b;
}
/* ---- dialogs ---- */
.dialog-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: grid;
place-items: center;
padding: 1rem;
z-index: 30;
}
.dialog {
background: var(--reader-surface);
color: var(--reader-ink);
border: 1px solid var(--reader-line);
border-radius: var(--reader-radius);
padding: 1rem;
width: min(24rem, 100%);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.dialog h3 {
margin: 0;
font-size: 1rem;
}
.dialog p {
margin: 0;
font-size: 0.82rem;
line-height: 1.5;
}
.dialog input {
appearance: none;
border: 1px solid var(--reader-line);
background: var(--reader-paper);
color: var(--reader-ink);
border-radius: var(--reader-radius);
padding: 0.6rem 0.7rem;
/* 16px, so iOS does not zoom the page on focus and leave the dialog off-screen. */
font-size: 16px;
font-family: inherit;
width: 100%;
}
.dialog-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.dialog-actions .button {
flex: 0 1 auto;
min-width: 5rem;
}
.dialog-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
+610
View File
@@ -0,0 +1,610 @@
import { ApiError } from '../api/errors.ts';
import type { ReaderApi } from '../api/client.ts';
import type { BrowseEntry, BrowseListing } from '../api/types.ts';
import { clear, el, formatBytes, formatDate } from './dom.ts';
export interface ManagerScreenOptions {
api: ReaderApi;
onClose(): void;
onSignedOut(): void;
}
/** Long-press, in milliseconds, before a touch starts a selection. */
const LONG_PRESS_MS = 450;
/**
* 书库管理 — the library as a file manager.
*
* ## Why this exists
*
* The shelf answers "what can I read". It cannot answer "where did my book go",
* and that is the question a self-hosted library actually generates: a title
* that does not appear after a rescan has no explanation anywhere in the app,
* because a book's DTO never carries its path. This screen is the one place the
* `BOOKS_DIR` is visible, and it is also the only one that writes to it.
*
* ## The three decisions that shape it
*
* 1. **Hidden entries are shown, with the reason.** `.trash`, `@eaDir` and
* friends are skipped by the scanner by design. Filtering them out here too
* would hide exactly the files a reader is looking for — the ones that are on
* disk but not on the shelf — so they are listed with a badge instead.
* 2. **Selection is explicit, and destructive actions need a second tap.**
* Thumb-scrolling a NAS library with a delete button on every row is one
* mis-tap away from data loss. Rows navigate; selection is entered through
* the long-press or the ⋯ button, and delete always asks.
* 3. **The write controls disappear on a read-only mount.** `:ro` is the
* documented deployment, so the common case is "this screen can only look",
* and offering buttons that answer 403 teaches the reader to distrust the UI.
*/
export class ManagerScreen {
readonly element: HTMLDivElement;
private readonly list: HTMLDivElement;
private readonly crumbRow: HTMLDivElement;
private readonly statusLine: HTMLDivElement;
private readonly actionBar: HTMLDivElement;
private readonly selectionCount: HTMLDivElement;
private readonly mkdirButton: HTMLButtonElement;
private readonly listBody: HTMLDivElement;
private current = '';
private listing: BrowseListing | null = null;
/** Paths ticked for a batch operation. Empty means "navigate" mode. */
private readonly selected = new Set<string>();
private loading = false;
private longPressTimer: ReturnType<typeof setTimeout> | null = null;
private longPressFired = false;
constructor(private readonly options: ManagerScreenOptions) {
this.crumbRow = el('div', { className: 'manager-crumbs' }) as HTMLDivElement;
this.statusLine = el('div', { className: 'manager-status muted' }) as HTMLDivElement;
this.list = el('div', { className: 'manager-list' }) as HTMLDivElement;
this.selectionCount = el('div', { className: 'manager-selection', attrs: { hidden: true } }) as HTMLDivElement;
this.actionBar = el('div', { className: 'manager-actions' }) as HTMLDivElement;
this.mkdirButton = el('button', {
className: 'icon-button',
text: '+',
attrs: { type: 'button', 'aria-label': '新建文件夹' },
on: { click: () => void this.promptMkdir() },
}) as HTMLButtonElement;
this.listBody = el('div', {
className: 'manager-body',
children: [this.list, this.statusLine],
}) as HTMLDivElement;
const header = el('div', {
className: 'panel-header',
children: [
el('button', {
className: 'icon-button',
text: '←',
attrs: { type: 'button', 'aria-label': '返回书架' },
on: { click: () => options.onClose() },
}),
this.crumbRow,
this.mkdirButton,
],
});
this.element = el('div', {
className: 'manager-screen',
children: [header, this.listBody, this.actionBar, this.selectionCount],
}) as HTMLDivElement;
// Clicking the empty area (not a row) clears a selection, which is the
// gesture people already have for "never mind".
this.listBody.addEventListener('click', (event) => {
if (event.target === this.listBody || event.target === this.list) this.clearSelection();
});
}
async open(): Promise<void> {
await this.load('');
}
private async load(path: string): Promise<void> {
if (this.loading) return;
this.loading = true;
this.setStatus('载入中…');
try {
const listing = await this.options.api.browse(path);
this.listing = listing;
this.current = listing.path;
this.clearSelection();
this.renderCrumbs();
this.renderEntries();
this.applyWritable(listing.writable);
// No `setStatus('')` here: listing a directory *succeeds with something to
// say*, and that thing is what `renderEntries` just wrote — how many
// entries, how big, and how many the scanner is skipping. Clearing it left
// the screen with no answer to any of those.
} catch (err) {
this.handleError(err);
} finally {
this.loading = false;
}
}
/**
* Reflects the mount's writability into the UI, in two places for two reasons.
*
* The button is hidden outright: a control that can only answer 403 teaches the
* reader to distrust every other control. The data attribute is on the *screen*
* so the stylesheet owns the rest — the per-row ⋯ menu is a write affordance
* and is dimmed by CSS rather than by a second branch in the render path.
*/
private applyWritable(writable: boolean): void {
this.mkdirButton.hidden = !writable;
this.element.dataset['writable'] = String(writable);
}
private renderCrumbs(): void {
clear(this.crumbRow);
const listing = this.listing;
if (!listing) return;
for (const [index, crumb] of listing.crumbs.entries()) {
if (index > 0) this.crumbRow.append(el('span', { className: 'manager-crumb-sep', text: '/' }));
const isLast = index === listing.crumbs.length - 1;
this.crumbRow.append(
el('button', {
className: 'manager-crumb',
text: crumb.name,
attrs: { type: 'button', 'aria-current': String(isLast) },
on: { click: () => void this.load(crumb.path) },
}),
);
}
}
private renderEntries(): void {
clear(this.list);
const listing = this.listing;
if (!listing) return;
if (listing.entries.length === 0) {
this.list.append(el('div', { className: 'empty-state', children: [el('p', { text: '这个文件夹是空的' })] }));
this.setStatus(listing.writable ? '空文件夹 · 可以用右上角的 + 新建子目录' : '空文件夹');
return;
}
for (const entry of listing.entries) this.list.append(this.row(entry));
const hidden = listing.entries.filter((entry) => entry.hidden || entry.hiddenByRule).length;
const parts = [`${listing.dirs} 个文件夹`, `${listing.files} 个文件`, formatBytes(listing.size)];
if (hidden > 0) parts.push(`${hidden} 个被扫描忽略`);
this.setStatus(parts.join(' · '));
}
/**
* One row.
*
* The row itself navigates; the checkbox that appears in selection mode is what
* selects. That split is the whole reason a destructive tap cannot happen by
* accident while scrolling a library of ten thousand files.
*/
private row(entry: BrowseEntry): HTMLDivElement {
const selectable = entry.type !== 'other';
const checkbox = el('span', { className: 'manager-check', attrs: { 'aria-hidden': 'true' } });
const name = el('div', {
className: 'manager-name',
children: [
el('span', { className: 'manager-icon', text: entry.type === 'dir' ? '📁' : '📄' }),
el('span', { className: 'manager-label', text: entry.name }),
],
});
const meta = el('div', {
className: 'manager-meta muted',
children: [
el('span', { text: entry.type === 'dir' ? '' : formatBytes(entry.size) }),
el('span', { text: formatDate(entry.mtime) }),
entry.hiddenByRule
? el('span', { className: 'manager-badge warn', text: '扫描忽略' })
: entry.scanned
? el('span', { className: 'manager-badge', text: '书籍' })
: null,
entry.hidden && !entry.hiddenByRule ? el('span', { className: 'manager-badge', text: '隐藏' }) : null,
],
});
const more = el('button', {
className: 'icon-button manager-more',
text: '⋯',
attrs: { type: 'button', 'aria-label': `${entry.name} 的操作` },
on: {
click: (event) => {
event.stopPropagation();
void this.openEntryMenu(entry);
},
},
}) as HTMLButtonElement;
const node = el('div', {
className: 'manager-row',
attrs: { 'data-type': entry.type, role: 'button', tabindex: '0' },
children: [checkbox, name, meta, more],
}) as HTMLDivElement;
node.dataset['path'] = entry.path;
const activate = (): void => {
if (!selectable) return;
if (this.selected.size > 0) {
this.toggle(entry);
return;
}
if (entry.type === 'dir') void this.load(entry.path);
};
node.addEventListener('click', activate);
node.addEventListener('keydown', (event) => {
if ((event as KeyboardEvent).key === 'Enter' || (event as KeyboardEvent).key === ' ') {
event.preventDefault();
activate();
}
});
// Long press enters selection mode, which is how a file manager on a phone
// says "I mean this one, not the one I tapped".
if (selectable && this.selected.size > 0) node.dataset['selected'] = String(this.selected.has(entry.path));
const start = (): void => {
this.longPressFired = false;
if (!selectable) return;
this.longPressTimer = setTimeout(() => {
this.longPressTimer = null;
this.longPressFired = true;
this.toggle(entry);
}, LONG_PRESS_MS);
};
const cancel = (): void => {
if (this.longPressTimer !== null) clearTimeout(this.longPressTimer);
this.longPressTimer = null;
};
node.addEventListener('touchstart', start, { passive: true });
node.addEventListener('touchend', cancel);
node.addEventListener('touchcancel', cancel);
node.addEventListener('touchmove', cancel);
node.addEventListener('mousedown', start);
node.addEventListener('mouseup', cancel);
node.addEventListener('mouseleave', cancel);
// A long press already acted; the click that follows must not navigate.
node.addEventListener('click', (event) => {
if (!this.longPressFired) return;
this.longPressFired = false;
event.stopImmediatePropagation();
}, true);
return node;
}
private toggle(entry: BrowseEntry): void {
if (this.selected.has(entry.path)) this.selected.delete(entry.path);
else this.selected.add(entry.path);
const node = this.list.querySelector<HTMLElement>(`[data-path="${cssEscape(entry.path)}"]`);
if (node) node.dataset['selected'] = String(this.selected.has(entry.path));
this.renderSelection();
}
private clearSelection(): void {
this.selected.clear();
for (const node of this.list.querySelectorAll<HTMLElement>('.manager-row')) {
node.dataset['selected'] = 'false';
}
this.renderSelection();
}
/**
* The batch action bar.
*
* Rendered only while something is selected: an always-present toolbar of
* move/delete buttons on a screen whose primary gesture is tapping a folder is
* a permanent invitation to the wrong action.
*/
private renderSelection(): void {
clear(this.actionBar);
const count = this.selected.size;
if (count === 0) {
this.actionBar.hidden = true;
this.selectionCount.hidden = true;
return;
}
this.actionBar.hidden = false;
this.selectionCount.hidden = false;
this.selectionCount.textContent = `已选 ${count} 项`;
const writable = this.listing?.writable ?? false;
this.actionBar.append(
el('button', {
className: 'button',
text: '取消',
attrs: { type: 'button' },
on: { click: () => this.clearSelection() },
}),
);
if (writable) {
if (count === 1) {
const only = [...this.selected][0]!;
const entry = this.listing?.entries.find((candidate) => candidate.path === only);
this.actionBar.append(
el('button', {
className: 'button',
text: '重命名',
attrs: { type: 'button' },
on: { click: () => void this.promptRename(only, entry?.name ?? '') },
}),
);
}
this.actionBar.append(
el('button', {
className: 'button',
text: '移动到…',
attrs: { type: 'button' },
on: { click: () => void this.promptMove() },
}),
el('button', {
className: 'button danger',
text: '删除',
attrs: { type: 'button' },
on: { click: () => void this.confirmDelete() },
}),
);
} else {
this.actionBar.append(
el('span', { className: 'muted', text: '书库是只读挂载,无法修改' }),
);
}
}
private async openEntryMenu(entry: BrowseEntry): Promise<void> {
if (!this.listing?.writable) {
this.toast('书库是只读挂载,无法修改');
return;
}
const action = await this.pick(`「${entry.name}」`, [
{ value: 'select', label: '选择' },
{ value: 'rename', label: '重命名' },
{ value: 'move', label: '移动到…' },
{ value: 'delete', label: '删除' },
]);
if (action === 'select') {
this.toggle(entry);
return;
}
if (action === 'rename') return this.promptRename(entry.path, entry.name);
if (action === 'move') {
this.selected.clear();
this.selected.add(entry.path);
this.renderSelection();
return this.promptMove();
}
if (action === 'delete') {
this.selected.clear();
this.selected.add(entry.path);
this.renderSelection();
return this.confirmDelete();
}
}
private async promptRename(path: string, currentName: string): Promise<void> {
const name = await this.prompt('重命名', currentName);
if (name === null || name.trim() === '' || name === currentName) return;
await this.mutate(() => this.options.api.browseRename(path, name.trim()), `重命名 ${currentName}`);
}
private async promptMkdir(): Promise<void> {
const name = await this.prompt('新建文件夹', '');
if (name === null || name.trim() === '') return;
await this.mutate(() => this.options.api.browseMkdir(this.current, name.trim()), `新建 ${name.trim()}`);
}
/**
* Moving a batch asks for a path rather than offering a folder picker.
*
* A picker would be a second navigation UI inside the first, and the one place
* a reader is most likely to land in the wrong folder is precisely where the
* files go. A typed, library-relative path is unambiguous — and the server
* resolves it through the same containment check as everything else.
*/
private async promptMove(): Promise<void> {
const target = await this.prompt('移动到(书库内的路径,留空为根目录)', this.current);
if (target === null) return;
const paths = [...this.selected];
await this.mutate(() => this.options.api.browseMove(paths, target.trim()), `移动 ${paths.length} 项`);
}
private async confirmDelete(): Promise<void> {
const count = this.selected.size;
const ok = await this.confirm(
`删除 ${count} 项?`,
'文件会从磁盘上直接删除,不进回收站。删除后需要重新扫描书库,书架才会更新。',
);
if (!ok) return;
const paths = [...this.selected];
await this.mutate(() => this.options.api.browseDelete(paths), `删除 ${paths.length} 项`);
}
/**
* Runs a mutating call, reports its outcome, and reloads only on success.
*
* The order matters and is the reason this is not two calls at the call sites:
* reloading after a *failed* write would replace the error message with a fresh
* directory listing, so the screen would look like the rename simply did not
* happen — the worst possible answer to "why did nothing change".
*/
private async mutate(action: () => Promise<unknown>, label: string): Promise<void> {
this.toast(`${label}…`);
try {
await action();
} catch (err) {
this.handleError(err);
return;
}
this.toast(`${label} 完成`);
await this.load(this.current);
}
private setStatus(text: string): void {
this.statusLine.textContent = text;
}
private toast(text: string): void {
this.setStatus(text);
}
private handleError(err: unknown): void {
if (err instanceof ApiError) {
if (err.isAuthFailure) {
this.options.onSignedOut();
return;
}
this.setStatus(err.message);
return;
}
this.setStatus(err instanceof Error ? err.message : '出错了');
}
// ---- dialogs ----
//
// Built on the spot rather than kept as members: each one is a single question
// with one answer, and a dialog that outlives its question is how a stale
// "delete?" prompt ends up attached to a different file.
private prompt(title: string, value: string): Promise<string | null> {
return new Promise((resolve) => {
const input = el('input', { attrs: { type: 'text', value } }) as HTMLInputElement;
const close = (result: string | null): void => {
overlay.remove();
resolve(result);
};
const form = el('form', {
className: 'dialog',
on: {
submit: (event) => {
event.preventDefault();
close(input.value);
},
},
children: [
el('h3', { text: title }),
input,
el('div', {
className: 'dialog-actions',
children: [
el('button', { className: 'button', text: '取消', attrs: { type: 'button' }, on: { click: () => close(null) } }),
el('button', { className: 'button primary', text: '确定', attrs: { type: 'submit' } }),
],
}),
],
});
const overlay = el('div', {
className: 'dialog-overlay',
on: {
click: (event) => {
if (event.target === overlay) close(null);
},
},
children: [form],
}) as HTMLDivElement;
this.element.append(overlay);
input.focus();
input.select();
});
}
private confirm(title: string, body: string): Promise<boolean> {
return new Promise((resolve) => {
const close = (result: boolean): void => {
overlay.remove();
resolve(result);
};
const overlay = el('div', {
className: 'dialog-overlay',
on: {
click: (event) => {
if (event.target === overlay) close(false);
},
},
children: [
el('div', {
className: 'dialog',
children: [
el('h3', { text: title }),
el('p', { className: 'muted', text: body }),
el('div', {
className: 'dialog-actions',
children: [
el('button', { className: 'button', text: '取消', attrs: { type: 'button' }, on: { click: () => close(false) } }),
el('button', { className: 'button danger', text: '删除', attrs: { type: 'button' }, on: { click: () => close(true) } }),
],
}),
],
}),
],
}) as HTMLDivElement;
this.element.append(overlay);
});
}
private async pick(title: string, options: Array<{ value: string; label: string }>): Promise<string | null> {
// De-duplicated by value: two rows that resolve to the same action are one
// action, and showing it twice reads as two different things.
const seen = new Set<string>();
const unique = options.filter((option) => {
if (seen.has(option.value)) return false;
seen.add(option.value);
return true;
});
return new Promise((resolve) => {
const close = (result: string | null): void => {
overlay.remove();
resolve(result);
};
const overlay = el('div', {
className: 'dialog-overlay',
on: {
click: (event) => {
if (event.target === overlay) close(null);
},
},
children: [
el('div', {
className: 'dialog sheet',
children: [
el('h3', { text: title }),
el('div', {
className: 'dialog-list',
children: unique.map((option) =>
el('button', {
className: 'button',
text: option.label,
attrs: { type: 'button' },
on: { click: () => close(option.value) },
}),
),
}),
el('div', {
className: 'dialog-actions',
children: [el('button', { className: 'button', text: '取消', attrs: { type: 'button' }, on: { click: () => close(null) } })],
}),
],
}),
],
}) as HTMLDivElement;
this.element.append(overlay);
});
}
dispose(): void {
if (this.longPressTimer !== null) clearTimeout(this.longPressTimer);
}
}
/**
* CSS.escape with a fallback.
*
* `querySelector` with a raw path breaks on a folder named `第01卷 [前篇]` or one
* containing a quote — both entirely ordinary in a Chinese comic library — and
* `CSS.escape` is not present in every WebView the Android shell runs on.
*/
function cssEscape(value: string): string {
const globalCss = (globalThis as { CSS?: { escape?: (text: string) => string } }).CSS;
if (globalCss?.escape) return globalCss.escape(value);
return value.replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char}`);
}
+14 -1
View File
@@ -15,6 +15,8 @@ export interface ShelfScreenOptions {
/** Current shelf preferences, so the screen starts in the reader's own state. */
settings: AppSettings;
onOpenBook(book: Book): void;
/** Opens the library file manager. Admin-ish by nature, but gated by the mount. */
onOpenManager(): void;
onSignedOut(): void;
/** Persisted through the app's settings store, like the reader's own. */
onSettingsChange(patch: Partial<AppSettings>): void;
@@ -143,9 +145,20 @@ export class ShelfScreen {
on: { click: () => this.toggleSettings() },
}) as HTMLButtonElement;
// The file manager's entry point sits next to the shelf settings gear because
// that is where "the library itself" already lives. It is offered to every
// account, not just admins: the mount decides whether it can write anything,
// and a reader whose own book failed to appear is exactly who needs to look.
const managerButton = el('button', {
className: 'icon-button shelf-settings-button',
text: '🗂',
attrs: { type: 'button', 'aria-label': '书库管理' },
on: { click: () => this.options.onOpenManager() },
});
const toolbar = el('div', {
className: 'shelf-toolbar',
children: [this.countLabel, this.sortRow, this.settingsButton],
children: [this.countLabel, this.sortRow, this.settingsButton, managerButton],
});
this.settingsPanel = buildShelfSettingsPanel({
+264
View File
@@ -0,0 +1,264 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ManagerScreen } from '../src/ui/manager-screen.ts';
import { ReaderApi, type SessionStore } from '../src/api/client.ts';
import { ApiError } from '../src/api/errors.ts';
import { FakeTransport, makePlatform } from './helpers/env.ts';
import type { BrowseListing } from '../src/api/types.ts';
/**
* The file manager.
*
* jsdom has no layout engine, so nothing here is about how the screen *looks*.
* What it covers is the part a regression would be silent about: which entries
* are reachable in which mode, and — the one that actually costs data — that a
* destructive call is never sent without an explicit selection and a confirmation.
*/
function entry(partial: Partial<BrowseListing['entries'][number]> & { name: string }): BrowseListing['entries'][number] {
return {
path: partial.name,
type: 'file',
size: 0,
mtime: Date.now(),
mode: 0o644,
hidden: false,
hiddenByRule: false,
scanned: false,
ext: '',
indexed: false,
...partial,
};
}
function listing(overrides: Partial<BrowseListing> = {}): BrowseListing {
return {
path: '',
crumbs: [{ name: '书库', path: '' }],
parent: null,
entries: [],
total: 0,
dirs: 0,
files: 0,
size: 0,
writable: true,
name: '',
...overrides,
};
}
function makeScreen(transport: FakeTransport): { screen: ManagerScreen; calls: string[] } {
const platform = makePlatform(transport);
const sessions: SessionStore = {
load: async () => null,
save: async () => undefined,
clear: async () => undefined,
};
const api = new ReaderApi(platform, sessions);
api.setBaseUrl('http://nas:8080');
const calls: string[] = [];
const screen = new ManagerScreen({
api,
onClose: () => calls.push('close'),
onSignedOut: () => calls.push('signed-out'),
});
document.body.append(screen.element);
return { screen, calls };
}
beforeEach(() => {
document.body.replaceChildren();
});
describe('library manager', () => {
it('lists directories and files, and shows the entries the scanner skips', async () => {
const transport = new FakeTransport();
transport.json(
listing({
entries: [
entry({ name: '科幻', type: 'dir', path: '科幻' }),
entry({ name: '.trash', type: 'dir', path: '.trash', hidden: true, hiddenByRule: true }),
entry({ name: '三体.epub', path: '三体.epub', scanned: true, ext: 'epub' }),
],
dirs: 2,
files: 1,
total: 3,
}),
);
const { screen } = makeScreen(transport);
await screen.open();
const rows = screen.element.querySelectorAll('.manager-row');
expect(rows).toHaveLength(3);
// The rule-skipped folder is the answer to "my book is on disk but not on
// the shelf", so it must be visible rather than filtered.
expect(screen.element.textContent).toContain('.trash');
expect(screen.element.querySelector('.manager-badge.warn')?.textContent).toBe('扫描忽略');
});
it('reports the directory counts after a successful listing', async () => {
const transport = new FakeTransport();
transport.json(
listing({ dirs: 2, files: 3, size: 4096, total: 5, entries: [entry({ name: 'a.epub' })] }),
);
const { screen } = makeScreen(transport);
await screen.open();
// The counts are the answer to "is my file in here at all", so a successful
// listing must leave them on screen rather than blank.
expect(screen.element.querySelector('.manager-status')?.textContent).toContain('3 个文件');
});
it('hides the write controls when the mount is read-only', async () => {
const transport = new FakeTransport();
transport.json(listing({ writable: false, entries: [entry({ name: '三体.epub', scanned: true })] }));
const { screen } = makeScreen(transport);
await screen.open();
const matches = transport.requests.filter((request) => request.url.startsWith('/api/v1/library/browse'));
expect(matches).toHaveLength(1);
expect(matches[0]!.method).toBe('GET');
// The mkdir button is the only write affordance outside selection mode.
const buttons = [...screen.element.querySelectorAll<HTMLButtonElement>('.icon-button')];
const mkdir = buttons.find((button) => button.getAttribute('aria-label') === '新建文件夹');
expect(mkdir?.hidden).toBe(true);
});
it('navigates into a folder and asks the server for that path', async () => {
const transport = new FakeTransport();
transport.respondWith((request) => {
const path = new URL(`http://x${request.url}`).searchParams.get('path') ?? '';
return path === '科幻'
? {
status: 200,
headers: {},
json: listing({
path: '科幻',
name: '科幻',
parent: '',
crumbs: [
{ name: '书库', path: '' },
{ name: '科幻', path: '科幻' },
],
entries: [entry({ name: '三体.epub', path: '科幻/三体.epub', scanned: true })],
files: 1,
}),
}
: {
status: 200,
headers: {},
json: listing({ entries: [entry({ name: '科幻', type: 'dir', path: '科幻' })] }),
};
});
const { screen } = makeScreen(transport);
await screen.open();
(screen.element.querySelector('.manager-row') as HTMLElement).click();
await vi.waitFor(() => {
expect(screen.element.textContent).toContain('三体.epub');
});
expect(transport.requests.at(-1)?.url).toBe('/api/v1/library/browse?path=%E7%A7%91%E5%B9%BB');
});
it('does not send a delete for a selection that was never made', async () => {
const transport = new FakeTransport();
transport.json(listing({ entries: [entry({ name: '三体.epub', scanned: true })] }));
const { screen } = makeScreen(transport);
await screen.open();
// Tapping a row is navigation, not selection — and on a file there is nowhere
// to navigate, so a tap must be a no-op rather than a deletion.
(screen.element.querySelector('.manager-row') as HTMLElement).click();
expect(transport.requests.filter((request) => request.method !== 'GET')).toHaveLength(0);
expect(screen.element.querySelector('.manager-actions')?.hasAttribute('hidden')).toBe(true);
});
it('sends the selected paths, and only those, on a confirmed delete', async () => {
const transport = new FakeTransport();
transport.respondWith((request) =>
request.method === 'GET'
? {
status: 200,
headers: {},
json: listing({
entries: [entry({ name: 'a.epub', path: 'a.epub' }), entry({ name: 'b.epub', path: 'b.epub' })],
files: 2,
}),
}
: { status: 200, headers: {}, json: { removed: 1 } },
);
const { screen } = makeScreen(transport);
await screen.open();
// Selection through the row's own action sheet, which is the keyboard-free
// path a test can drive without a touch device.
(screen.element.querySelector('.manager-more') as HTMLElement).click();
// The action sheet is created inside an awaited promise, so it appears a
// microtask after the click rather than synchronously.
await vi.waitFor(() => expect(screen.element.querySelector('.dialog-list')).not.toBeNull());
const choose = [...screen.element.querySelectorAll('.dialog-list .button')].find(
(button) => button.textContent === '选择',
) as HTMLElement;
choose.click();
// The sheet closes through a promise too, so the action bar is one tick away.
await vi.waitFor(() => expect(screen.element.querySelector('.manager-selection')?.hasAttribute('hidden')).toBe(false));
const deleteButton = [...screen.element.querySelectorAll('.manager-actions .button')].find(
(button) => button.textContent === '删除',
) as HTMLElement;
deleteButton.click();
await vi.waitFor(() => expect(screen.element.querySelector('.dialog-actions')).not.toBeNull());
const confirmButton = [...screen.element.querySelectorAll('.dialog-actions .button')].find(
(button) => button.textContent === '删除',
) as HTMLElement;
confirmButton.click();
await vi.waitFor(() => {
expect(transport.requests.some((request) => request.url.endsWith('/browse/delete'))).toBe(true);
});
const del = transport.requests.find((request) => request.url.endsWith('/browse/delete'));
expect(JSON.parse(String(del?.body))).toEqual({ paths: ['a.epub'] });
});
it('reports a refused write instead of pretending it worked', async () => {
const transport = new FakeTransport();
transport.respondWith((request) =>
request.method === 'GET'
? { status: 200, headers: {}, json: listing({ entries: [entry({ name: 'a.epub', path: 'a.epub' })] }) }
: {
status: 403,
headers: {},
json: { error: { code: 'READ_ONLY_MOUNT', message: 'the library mount is read-only' } },
},
);
const { screen, calls } = makeScreen(transport);
await screen.open();
(screen.element.querySelector('.manager-more') as HTMLElement).click();
const rename = [...screen.element.querySelectorAll('.dialog-list .button')].find(
(button) => button.textContent === '重命名',
) as HTMLElement;
rename.click();
await vi.waitFor(() => expect(screen.element.querySelector('.dialog input')).not.toBeNull());
const input = screen.element.querySelector<HTMLInputElement>('.dialog input')!;
input.value = 'b.epub';
const form = screen.element.querySelector<HTMLFormElement>('form.dialog')!;
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(transport.requests.some((request) => request.url.endsWith('/browse/rename'))).toBe(true);
});
await new Promise((resolve) => setTimeout(resolve, 50));
await vi.waitFor(() => {
expect(screen.element.querySelector('.manager-status')?.textContent).toContain('read-only');
});
// A refused operation must not sign the reader out: the 403 here is about the
// mount, not about their session.
expect(calls).not.toContain('signed-out');
});
});
describe('error classification', () => {
it('does not treat a refused operation as a dead session', () => {
// Both are 403 on this API and only one is about the credentials. Classifying
// them together signed a reader out — and discarded their tokens — for trying
// to rename a file on a read-only mount.
expect(new ApiError('forbidden', 'the library mount is read-only', 'READ_ONLY_MOUNT', 403).isAuthFailure).toBe(false);
expect(new ApiError('forbidden', 'admin role required', 'ADMIN_REQUIRED', 403).isAuthFailure).toBe(false);
expect(new ApiError('forbidden', 'account disabled', 'ACCOUNT_DISABLED', 403).isAuthFailure).toBe(true);
expect(new ApiError('unauthorized', 'token expired', 'TOKEN_EXPIRED', 401).isAuthFailure).toBe(true);
});
});