mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 14:31:11 +08:00
feat(library): 新增书库管理页面
This commit is contained in:
17 files changed
+2340
-3
No files matched your search
@@ -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
@@ -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` 判断,不要只看状态码。
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user