feat: source covers, volume headings and chapter notes for chapter books

Server: catalog entries may carry `coverRef`, read through the source as
a validated raster image (`GET /sources/:id/cover`), saved as the book's
cover on acquisition (and adopted on a manual refresh for older books),
and replaceable from another edition (`POST /books/:id/cover-source`,
`GET /books/:id/cover-candidate`). Cover URLs carry a version so a
changed cover is not served from the immutable cache. Directory items
keep optional `volume` and `note`.

Web: the directory draws a heading above each volume's first chapter and
a quiet note under a chapter title; search results show a lazily loaded
cover; shelf books from a source get 更换封面, which lists the covers
other sources have for the same book.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hectorqinandClaude committed 2026-11-13 01:27:32 +00:00
1 parent b9901fb0c5
commit 485ab38c2e
22 files changed
+371 -21

No files matched your search

+9 -1
View File
@@ -893,7 +893,8 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用**
| POST /sources/:id/search/cancel | body `{sessionId}`,取消进行中的搜索 → `{ok:true}` |
| GET /sources/:id/search-filters | 来源声明的搜索筛选项 |
| GET /sources/:id/entries?ref=条目引用 | 查询条目详情 |
| POST /sources/:id/acquire | body `{entryRef, optionId?}`,获取条目并加入当前账号书架 → `{kind:"ready", publicationId}` |
| POST /sources/:id/acquire | body `{entryRef, optionId?}`,获取条目并加入当前账号书架 → `{kind:"ready", publicationId}`;章节书的条目带 `coverRef` 时同时保存封面(失败不影响加入书架) |
| GET /sources/:id/cover?entryRef=&coverRef= | 条目封面。由服务端通过来源读取(`readResource({publicationRef: entryRef, ref: coverRef})`),只接受 PNG/JPEG/GIF/WebP 且字节与类型相符,插件的图片地址不会到达浏览器 |
| GET /sources/:id/credentials | 当前账号在该来源的凭据状态:`available`、`fields:[{key,label,configured}]` 等,不含明文 |
| PUT /sources/:id/credentials/:key、DELETE 同路径 | 保存(body `{value}`)/ 删除当前账号的一项凭据 → `{ok:true}` |
| GET /sources/:id/publications/:publicationRef/manifest | 调试用:直接读取来源的章节目录 |
@@ -924,6 +925,8 @@ pluginId 是接口兼容字段。创建返回 201 与 source 对象。本地来
| POST /books/:id/alternatives | body `{sessionId, cursor?, resultLimit?}`,以 SSE 返回可替换的来源结果(事件同搜索) |
| POST /books/:id/switch-preview | body `{entryRef}`,读取候选条目的目录 → `{chapters:[{id,title}], latestChapter}` |
| POST /books/:id/switch-source | body `{entryRef, chapterId, revision}`,换源 |
| GET /books/:id/cover-candidate?entryRef=&coverRef= | 换封面时的候选封面,经这本书所属的来源读取,限制同上 |
| POST /books/:id/cover-source | body `{entryRef, coverRef}`,把另一个版本的封面存为这本书的封面;封面地址随之变化(`coverUrl` 带 `?v=`) |
| POST /books/:id/switch-quality | body `{entryRef, chapterId}`,试读候选条目的一章用于比较(返回该章内容、`latestChapter` 和 `elapsedMs`),不改动书 |
| GET /subscriptions | 当前账号的章节更新订阅(`{subscriptions}`) |
| PATCH /books/:id/subscription | body `{enabled?, intervalMinutes?(15–10080), acknowledge?}` → `{subscription}` |
@@ -1211,3 +1214,8 @@ All endpoints require the account access token in the Authorization header. Read
- PUT /api/v1/books/:id/reading-overrides accepts the same object. A correction contains id, anchor (sectionId, start, end, quote, prefix, suffix), and replacement. The submitted version must match the latest version; stale writes return `409 CONFLICT`. The response is the saved object with `version` incremented by one. Up to 500 corrections are allowed, with each quote/replacement at most 4,000 characters. headingPrefix is a literal single-line prefix of at most 60 characters.
- POST /api/v1/books/:id/reading-overrides/undo accepts { version } and saves the preceding snapshot as a new version. History is scoped to account/book and stored in DATA_DIR/reader.db; it never rewrites library files.
- POST /api/v1/tts/test accepts { voice?, speed? }, synthesizes a fixed sample and returns { bytes, contentType, elapsedMs, cached }. Failure codes include TTS_DISABLED, TTS_AUTH, TTS_TIMEOUT, TTS_CONTENT, TTS_EMPTY and TTS_UPSTREAM. It does not accept an upstream URL. Audio playback remains GET /api/v1/tts.
### 来源条目和章节目录的可选字段
- `CatalogEntry.coverRef`:封面资源引用,约定用 `readResource({publicationRef: entry.ref, ref: coverRef})` 读取。
- 章节目录条目(插件 `getManifest` 的 `items`,以及 `/books/:id/manifest`、`/books/:id/toc` 的条目)可带 `volume`(所属卷名,相邻且相同的条目归在同一个卷标题下)和 `note`(标题下的一行说明,例如更新时间)。两者都是最长 200 字符的字符串,缺省时不出现。
+2
View File
@@ -536,6 +536,8 @@ SweetAlert、Mantine `Modal`、自绘遮罩或角落浮动卡片。
- 调用方的 `className` 只管**内容**样式,不得设置位置、尺寸、圆角、背景遮罩或标题栏。
- 确认框焦点默认在「取消」;破坏性操作传 `danger`,按钮用危险色。
- `⋯` 操作菜单和单选筛选(收藏类型等)用 `web/src/ui/action-menu.tsx` 的 `ActionMenu`:从触发按钮下方弹出的下拉菜单(手机和桌面一致),没有标题栏和遮罩,选中、Esc、Tab 或点外部即关闭,支持方向键;样式在 `web/src/styles/action-menu.css`。需要以问题形式出现的单选(书架的选择对话框)用 `ActionSheet`,即 compact Sheet 里的同一列菜单项。动作是数据(`label`、`onSelect`、`danger`、`checked`、`description`——条目下的一行说明,经 `aria-describedby` 读出),菜单先关再执行动作,所以动作可以再开一个 Sheet 或跳转。不再使用 Mantine `Menu` 或 `<details>` 下拉。
- 章节书的目录:条目带 `volume` 时,在该卷第一章上方画一行卷标题(`.toc-volume`,不可点、不计入章节数);带 `note` 时在章节名下显示一行小字(`.toc-note`,如更新时间)。
- 来源封面用 `ui/lazy-cover.tsx` 的 `LazyCover`:进入视口后才经 API 取图,固定 3:4 占位。搜书结果每本书显示第一个带封面的书源的封面。书架上来自书源的书(`format: 'chapters'`)的 ⋯ 菜单有“更换封面”,打开 `ui/change-cover-sheet.tsx`:按书名和作者在其它书源中查找,列出有封面的版本,点选即保存。
- 锚定在控件上的只有 `ActionMenu` 下拉、选择框(`MediaSelect`、`SearchableSelect`)和正文选区工具条,它们不是 Sheet。
以下是阅读器面板最初改成底部半屏的理由,仍然适用于手机形态:
+3 -2
View File
@@ -303,7 +303,8 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
const manifest = await ctx.sources.chapters.manifest(user.id, id);
return {
revision: manifest.revision,
toc: manifest.items.map((item) => ({ href: item.href, title: item.title, level: 0, spine: item.seq })),
toc: manifest.items.map((item) => ({ href: item.href, title: item.title, level: 0, spine: item.seq,
...(item.volume ? { volume: item.volume } : {}), ...(item.note ? { note: item.note } : {}) })),
};
}
const handler = resolveHandler(ctx, id, book.format);
@@ -487,7 +488,7 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo
const abs = resolveInside(ctx.config.dataDir, assertSafeRel(row.cover_path));
if (!existsSync(abs)) throw notFound('cover cache missing', 'NO_COVER');
const ext = extname(abs).toLowerCase();
const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.svg' ? 'image/svg+xml' : 'image/jpeg';
const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.svg' ? 'image/svg+xml' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
reply.header('content-type', mime);
reply.header('cache-control', 'public, max-age=31536000, immutable');
return reply.send(createReadStream(abs));
+26
View File
@@ -127,6 +127,32 @@ export function registerSourceRoutes(app: FastifyInstance, ctx: AppContext): voi
const ref = parameter(q.ref, 'ref', true)!;
return withSignal(request, reply, (signal) => host.detail(user.id, id, ref, signal));
});
app.get('/api/v1/sources/:id/cover', { preHandler: auth }, async (request, reply) => {
const user = currentUser(request); const { id } = request.params as { id: string };
const q = request.query as { entryRef?: string; coverRef?: string };
const entryRef = parameter(q.entryRef, 'entryRef', true)!, coverRef = parameter(q.coverRef, 'coverRef', true)!;
const image = await withSignal(request, reply, (signal) => host.cover(user.id, id, entryRef, coverRef, signal));
reply.header('content-type', image.type);
reply.header('x-content-type-options', 'nosniff');
reply.header('cache-control', 'private, max-age=86400');
return reply.send(image.bytes);
});
app.get('/api/v1/books/:id/cover-candidate', { preHandler: auth }, async (request, reply) => {
const user = currentUser(request); const { id } = request.params as { id: string };
const q = request.query as { entryRef?: string; coverRef?: string };
const entryRef = parameter(q.entryRef, 'entryRef', true)!, coverRef = parameter(q.coverRef, 'coverRef', true)!;
const image = await withSignal(request, reply, (signal) => host.candidateCover(user.id, id, entryRef, coverRef, signal));
reply.header('content-type', image.type);
reply.header('x-content-type-options', 'nosniff');
reply.header('cache-control', 'private, max-age=86400');
return reply.send(image.bytes);
});
app.post('/api/v1/books/:id/cover-source', { preHandler: auth }, async (request, reply) => {
const user = currentUser(request); const { id } = request.params as { id: string };
const body = request.body as Record<string, unknown> | null;
const entryRef = textBody(body, 'entryRef'), coverRef = textBody(body, 'coverRef');
return withSignal(request, reply, (signal) => host.changeCover(user.id, id, entryRef, coverRef, signal));
});
app.post('/api/v1/sources/:id/acquire', { preHandler: auth }, async (request, reply) => {
const user = currentUser(request); const { id } = request.params as { id: string };
const body = request.body as Record<string, unknown> | null;
+7
View File
@@ -81,6 +81,10 @@ export interface ContentItem {
* the size one request at a time. Absent means unknown.
*/
size?: number;
/** The volume a chapter belongs to; consecutive items sharing it sit under one heading. */
volume?: string;
/** A short line under the title, such as when a chapter was updated. */
note?: string;
}
/** A named group of items; volumes for comics, or a single implicit one. */
@@ -147,6 +151,9 @@ export interface TocEntry {
level: number;
/** Whole-book index, when the format has one. */
spine?: number;
/** The volume heading this entry sits under, and a short line under its title. */
volume?: string;
note?: string;
}
export interface AssetRequest {
+3 -1
View File
@@ -494,7 +494,9 @@ export async function saveCover(
? '.svg'
: cover.contentType.includes('gif')
? '.gif'
: '.jpg';
: cover.contentType.includes('webp')
? '.webp'
: '.jpg';
const rel = join('covers', `${bookId}${ext}`);
const abs = join(dataDir, rel);
await mkdir(dirname(abs), { recursive: true });
+3
View File
@@ -64,6 +64,7 @@ export class ChapterPublications {
// plugin's characters as HTML or run a markup parser over them.
mediaType: chapterMedia(item.mediaType),
...(chapterMedia(item.mediaType).startsWith('text/plain') ? { format: 'html' as const } : {}),
...(item.volume ? { volume: item.volume } : {}), ...(item.note ? { note: item.note } : {}),
};
}),
};
@@ -272,6 +273,8 @@ export class ChapterPublications {
...(snapshot.version === undefined ? {} : { version: snapshot.version }),
items: [...snapshot.items].sort((left, right) => left.seq - right.seq).map((item, seq) => ({
id: item.id, seq, sourceUrl: item.sourceUrl, title: item.title, kind: 'chapter', mediaType: chapterMedia(item.mediaType), ref: item.ref,
// Absent rather than empty, so a directory without them keeps the revision it had.
...(item.volume?.trim() ? { volume: item.volume.trim().slice(0, 200) } : {}), ...(item.note?.trim() ? { note: item.note.trim().slice(0, 200) } : {}),
})),
};
}
+3 -1
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import type { Db } from '../db/index.ts';
import { applyOverrides, type BaseMetadata } from './merge.ts';
import { safeJsonParse } from '../lib/text.ts';
@@ -377,7 +378,8 @@ export class ShelfService {
tags: effective.tags,
pubdate: effective.pubdate,
format: row.format,
coverUrl: row.cover_path ? `/api/v1/books/${row.id}/cover` : null,
// The cover response is immutable-cached; a changed cover file gets a new URL.
coverUrl: row.cover_path ? `/api/v1/books/${row.id}/cover?v=${createHash('sha1').update(row.cover_path).digest('hex').slice(0, 10)}` : null,
fileSize: row.file_size,
pageCount: row.page_count,
source: effective.source,
+60 -2
View File
@@ -13,6 +13,10 @@ import { FileAcquisitions } from '../sources/acquisitions.ts';
import { PluginManager } from '../sources/plugin-manager.ts';
import { ChapterPublications } from '../publications/chapters.ts';
import { ChapterUpdates } from './chapter-updates.ts';
import { raster } from '../publications/rich-content.ts';
import { saveCover } from '../indexer/metadata.ts';
import { resolveInside } from '../lib/paths.ts';
import { rm } from 'node:fs/promises';
function json(value: unknown): string { return JSON.stringify(value ?? {}); }
function parse(value: string): unknown { try { return JSON.parse(value); } catch { return {}; } }
@@ -232,13 +236,67 @@ export class SourceHost {
const bookId = acquisition.kind === 'chapters'
? await this.chapters.acquire(provider, ctx, { entryRef, optionId }, acquisition.publicationRef, entry)
: await this.acquisitions.acquire(provider, ctx, { entryRef, optionId }, acquisition, entry);
// A missing cover never fails the acquisition; it can be chosen later.
if (acquisition.kind === 'chapters' && entry.coverRef && !this.coverPath(bookId)) {
await this.storeCover(provider, ctx, bookId, entryRef, entry.coverRef).catch((error) => this.log?.info({ sourceId: id, bookId, code: (error as { code?: string }).code }, 'source cover unavailable'));
}
this.log?.info({ sourceId: id, bookId, kind: acquisition.kind }, 'source publication acquired');
return { kind: 'ready' as const, publicationId: bookId };
}, signal);
}
refreshPublication(userId: string, bookId: string, signal?: AbortSignal) {
return this.updates.check(userId, bookId, signal);
async refreshPublication(userId: string, bookId: string, signal?: AbortSignal) {
const result = await this.updates.check(userId, bookId, signal);
// Books added before covers were kept pick theirs up on the next manual refresh.
if (!this.coverPath(bookId)) void this.adoptCover(userId, bookId).catch(() => undefined);
return result;
}
private async adoptCover(userId: string, bookId: string): Promise<void> {
const binding = this.chapters.binding(userId, bookId);
const entryRef = this.db.get<{ entry_ref: string }>('SELECT entry_ref FROM source_acquisitions WHERE book_id = ? AND user_id = ? AND source_id = ?', bookId, userId, binding.source_id)?.entry_ref;
if (!entryRef) return;
await this.call(userId, binding.source_id, async (provider, ctx) => {
const entry = await provider.detail(ctx, entryRef);
if (entry.coverRef && provider.readResource) await this.storeCover(provider, ctx, bookId, entryRef, entry.coverRef);
});
}
/** An entry's cover image, read through its source so no plugin URL reaches the browser. */
async cover(userId: string, id: string, entryRef: string, coverRef: string, signal?: AbortSignal) {
return this.call(userId, id, async (provider, ctx) => {
if (!provider.readResource) throw badRequest('source has no resources', 'SOURCE_UNSUPPORTED');
return raster(await provider.readResource(ctx, { publicationRef: entryRef, ref: coverRef }));
}, signal);
}
/** A candidate cover while choosing one for a book: read through the book's own source instance. */
async candidateCover(userId: string, bookId: string, entryRef: string, coverRef: string, signal?: AbortSignal) {
return this.cover(userId, this.chapters.binding(userId, bookId).source_id, entryRef, coverRef, signal);
}
/** 换封面: use the cover of another edition found by the book's own source instance. */
async changeCover(userId: string, bookId: string, entryRef: string, coverRef: string, signal?: AbortSignal) {
const binding = this.chapters.binding(userId, bookId);
return this.call(userId, binding.source_id, async (provider, ctx) => {
if (!provider.readResource) throw badRequest('source has no resources', 'SOURCE_UNSUPPORTED');
await this.storeCover(provider, ctx, bookId, entryRef, coverRef);
return { ok: true as const };
}, signal);
}
private coverPath(bookId: string): string | null {
return this.db.get<{ cover_path: string | null }>('SELECT cover_path FROM books WHERE id = ?', bookId)?.cover_path ?? null;
}
private async storeCover(provider: SourceProvider, ctx: SourceContext, bookId: string, entryRef: string, coverRef: string): Promise<void> {
const { type, bytes } = raster(await provider.readResource!(ctx, { publicationRef: entryRef, ref: coverRef }));
ctx.signal.throwIfAborted();
const previous = this.coverPath(bookId);
// A new file name per cover: the cover URL is cached as immutable.
const path = await saveCover(this.config.dataDir, `${bookId}-${randomBytes(6).toString('hex')}`, { data: bytes, contentType: type });
this.db.run('UPDATE books SET cover_path = ?, updated_at = ? WHERE id = ?', path, Date.now(), bookId);
if (previous && previous !== path) await rm(resolveInside(this.config.dataDir, previous), { force: true }).catch(() => undefined);
}
async manifest(userId: string, id: string, ref: string, signal?: AbortSignal) {
+3 -1
View File
@@ -19,9 +19,10 @@ export function decodeCatalogEntry(input: unknown): CatalogEntry {
const value = object(input, 'catalog entry');
string(value.ref, 'entry ref');
string(value.title, 'entry title');
for (const key of ['description', 'coverUrl', 'language', 'publishedAt', 'latestChapter', 'sourceName']) {
for (const key of ['description', 'coverUrl', 'coverRef', 'language', 'publishedAt', 'latestChapter', 'sourceName']) {
if (value[key] !== undefined && typeof value[key] !== 'string') invalid(`invalid plugin entry ${key}`);
}
if (typeof value.coverRef === 'string' && (!value.coverRef || value.coverRef.length > 16_384)) invalid('invalid plugin entry coverRef');
if (value.authors !== undefined &&
(!Array.isArray(value.authors) || value.authors.some((author) => typeof author !== 'string'))) {
invalid('plugin authors must be strings');
@@ -113,6 +114,7 @@ export function decodeManifest(input: unknown, publicationRef: string): Manifest
string(item.title, 'chapter title');
string(item.mediaType, 'chapter media type');
if (item.kind !== 'chapter' && item.kind !== 'page') invalid('invalid plugin chapter kind');
for (const key of ['volume', 'note']) if (item[key] !== undefined && typeof item[key] !== 'string') invalid(`invalid plugin chapter ${key}`);
if (typeof item.seq !== 'number' || !Number.isSafeInteger(item.seq) || item.seq < 0) invalid('invalid plugin chapter order');
if (ids.has(item.id) || sequences.has(item.seq)) invalid('plugin manifest contains duplicate chapter ids or order');
ids.add(item.id);
+10
View File
@@ -132,6 +132,12 @@ export interface CatalogEntry {
readonly authors?: readonly string[];
readonly description?: string;
readonly coverUrl?: string;
/**
* The cover as a resource of this entry: the host reads it with
* `readResource({ publicationRef: entry.ref, ref: coverRef })`, so a plugin's
* cover URL never reaches the browser.
*/
readonly coverRef?: string;
readonly language?: string;
readonly publishedAt?: string;
readonly latestChapter?: string;
@@ -197,6 +203,10 @@ export interface ManifestItem {
readonly kind: 'chapter' | 'page';
readonly mediaType: string;
readonly ref: string;
/** The volume this chapter belongs to; consecutive chapters sharing it sit under one heading. */
readonly volume?: string;
/** A short line under the title, such as when the chapter was updated. */
readonly note?: string;
}
export interface ResourceRequest {
+52 -2
View File
@@ -18,13 +18,15 @@ import { UploadService } from '../src/services/uploads.ts';
interface Session { token: string; id: string }
interface Item { href: string; resourceRef: string; title: string; seq: number }
interface Chapter { id: string; seq: number; title: string; kind: 'chapter'; mediaType: string; ref: string }
interface Chapter { id: string; seq: number; title: string; kind: 'chapter'; mediaType: string; ref: string; volume?: string; note?: string }
interface FixtureState {
version: string;
items: Chapter[];
resources: Record<string, string>;
failManifest?: boolean;
resourceMediaType?: string;
/** Base64 images by cover ref; the entry advertises `cover` when present. */
covers?: Record<string, string>;
}
const PLUGIN_ID = 'reader.source.chapters-test';
@@ -110,7 +112,10 @@ const entry = { ref: ${JSON.stringify(BOOK_REF)}, title: '来自插件的章节
const methods = {
validateConfig: () => null,
browse: () => ({ items: [entry] }),
detail: () => entry,
detail: async () => {
const state = JSON.parse(await readFile(new URL('./state.json', import.meta.url), 'utf8'));
return state.covers?.cover ? { ...entry, coverRef: 'cover' } : entry;
},
acquire: () => ({ kind: 'chapters', publicationRef: entry.ref }),
getManifest: async ({ publicationRef }) => {
const state = JSON.parse(await readFile(new URL('./state.json', import.meta.url), 'utf8'));
@@ -119,6 +124,7 @@ const methods = {
},
readResource: async ({ context, request }) => {
const state = JSON.parse(await readFile(new URL('./state.json', import.meta.url), 'utf8'));
if (state.covers?.[request.ref]) return { mediaType: request.ref === 'other' ? 'image/gif' : 'image/png', base64: state.covers[request.ref] };
const text = state.resources[request.ref];
if (text === undefined) throw Object.assign(new Error('missing fixture chapter'), { code: 'RESOURCE_GONE' });
return { mediaType: state.resourceMediaType ?? 'text/plain; charset=utf-8', text: text + '\\nreader=' + context.userId };
@@ -339,3 +345,47 @@ test('chapter reading rejects executable upstream resources without replacing al
assert.doesNotMatch(rejected.headers['content-type'] as string, /^text\/html/);
assert.equal((await asset(h, h.admin, id, book.items[0]!)).body, first.body);
});
test('directories keep volumes and notes; covers are read through the source, saved on acquisition and can be changed', async (t) => {
const h = await harness(); t.after(() => h.close());
const fixture = await installFixture(h);
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aHf8AAAAASUVORK5CYII=';
const gif = Buffer.from('GIF89a\u0001\u0000\u0001\u0000\u0000\u0000\u0000;', 'latin1').toString('base64');
const state = initialState();
state.items[0] = { ...state.items[0]!, volume: '第一卷', note: '2024-01-01' }; state.items[1] = { ...state.items[1]!, volume: '第一卷' };
state.covers = { cover: png, other: gif, 'not-an-image': Buffer.from('<svg onload="alert(1)"/>').toString('base64') };
await fixture.state(state);
const member = await h.member(), id = await acquire(h, member);
const book = await manifest(h, member, id) as Awaited<ReturnType<typeof manifest>> & { items: Array<Item & { volume?: string; note?: string }> };
assert.deepEqual(book.items.map((item) => [item.volume, item.note]), [['第一卷', '2024-01-01'], ['第一卷', undefined], [undefined, undefined]]);
const toc = await h.app.inject({ method: 'GET', url: `/api/v1/books/${id}/toc`, headers: auth(member) });
assert.deepEqual(toc.json().toc.map((item: { volume?: string }) => item.volume), ['第一卷', '第一卷', undefined]);
const shelved = await h.app.inject({ method: 'GET', url: `/api/v1/books/${id}`, headers: auth(member) });
const coverUrl = shelved.json().book.coverUrl as string;
assert.match(coverUrl, new RegExp(`^/api/v1/books/${id}/cover\\?v=[a-f0-9]{10}$`), 'the cover is saved when the book is added');
const cover = await h.app.inject({ method: 'GET', url: coverUrl, headers: auth(member) });
assert.equal(cover.statusCode, 200); assert.equal(cover.headers['content-type'], 'image/png'); assert.ok(cover.rawPayload.equals(Buffer.from(png, 'base64')));
const query = (coverRef: string) => `entryRef=${encodeURIComponent(BOOK_REF)}&coverRef=${encodeURIComponent(coverRef)}`;
const preview = await h.app.inject({ method: 'GET', url: `/api/v1/sources/${SOURCE_ID}/cover?${query('other')}`, headers: auth(member) });
assert.equal(preview.statusCode, 200, preview.body); assert.equal(preview.headers['content-type'], 'image/gif'); assert.equal(preview.headers['x-content-type-options'], 'nosniff');
const candidate = await h.app.inject({ method: 'GET', url: `/api/v1/books/${id}/cover-candidate?${query('other')}`, headers: auth(member) });
assert.equal(candidate.statusCode, 200, candidate.body);
for (const url of [`/api/v1/sources/${SOURCE_ID}/cover?${query('not-an-image')}`, `/api/v1/books/${id}/cover-candidate?${query('not-an-image')}`]) {
const rejected = await h.app.inject({ method: 'GET', url, headers: auth(member) });
assert.equal(rejected.statusCode, 400, 'bytes that are not a raster image never reach the browser');
}
assert.equal((await h.app.inject({ method: 'GET', url: `/api/v1/sources/${SOURCE_ID}/cover?${query('other')}` })).statusCode, 401);
const changed = await h.app.inject({ method: 'POST', url: `/api/v1/books/${id}/cover-source`, headers: auth(member), payload: { entryRef: BOOK_REF, coverRef: 'other' } });
assert.equal(changed.statusCode, 200, changed.body);
const after = (await h.app.inject({ method: 'GET', url: `/api/v1/books/${id}`, headers: auth(member) })).json().book.coverUrl as string;
assert.notEqual(after, coverUrl, 'a changed cover has a new URL');
const replaced = await h.app.inject({ method: 'GET', url: after, headers: auth(member) });
assert.equal(replaced.headers['content-type'], 'image/gif'); assert.ok(replaced.rawPayload.equals(Buffer.from(gif, 'base64')));
const refused = await h.app.inject({ method: 'POST', url: `/api/v1/books/${id}/cover-source`, headers: auth(member), payload: { entryRef: BOOK_REF, coverRef: 'not-an-image' } });
assert.equal(refused.statusCode, 400); assert.equal((await h.app.inject({ method: 'GET', url: `/api/v1/books/${id}`, headers: auth(member) })).json().book.coverUrl, after);
const foreign = await h.app.inject({ method: 'POST', url: `/api/v1/books/${id}/cover-source`, headers: auth(h.admin), payload: { entryRef: BOOK_REF, coverRef: 'other' } });
assert.equal(foreign.statusCode, 404, 'another account cannot change this book');
});
+4
View File
@@ -297,6 +297,10 @@ export class ReaderApi {
async createOpdsCredential(name: string): Promise<OpdsCredential & { username: string; password: string; catalogUrl: string }> { return this.call('/api/v1/opds/credentials', 'POST', { name }); }
async revokeOpdsCredential(id: string): Promise<void> { await this.call('/api/v1/opds/credentials/' + encodeURIComponent(id), 'DELETE'); }
async deleteCredential(id: string, key: string): Promise<void> { await this.call('/api/v1/sources/' + encodeURIComponent(id) + '/credentials/' + encodeURIComponent(key),'DELETE'); }
/** Store another edition's cover as this book's cover. */
async changeCover(id: string, entryRef: string, coverRef: string): Promise<void> {
await this.call('/api/v1/books/' + encodeURIComponent(id) + '/cover-source', 'POST', { entryRef, coverRef });
}
async switchPreview(id: string, entryRef: string): Promise<{ chapters: Array<{ id: string; title: string }>; latestChapter?: string }> {
return this.call('/api/v1/books/' + encodeURIComponent(id) + '/switch-preview', 'POST', { entryRef });
}
+2
View File
@@ -10,6 +10,8 @@ export interface SourceInstance {
}
export interface SourceEntry {
latestChapter?: string; sourceName?: string; coverUrl?: string;
/** The cover as a resource of this entry, fetched through the server (see `sourceCoverUrl`). */
coverRef?: string;
ref: string; title: string; authors?: string[]; description?: string;
options?: Array<{ id: string; label: string; available?: boolean }>;
}
+6
View File
@@ -119,6 +119,10 @@ export interface ContentItem {
format?: 'text' | 'html';
/** Byte length when the format knows it cheaply. */
size?: number;
/** The volume a chapter belongs to; consecutive chapters sharing it sit under one heading. */
volume?: string;
/** A short line under the title, such as when the chapter was updated. */
note?: string;
}
/** A group of items: a comic volume, or a window of chapters. */
@@ -164,6 +168,8 @@ export interface TocEntry {
title: string;
level: number;
spine?: number;
volume?: string;
note?: string;
}
export interface Manifest {
@@ -18,6 +18,7 @@ import { useSwipeTabs } from '../../../shared/ui/use-swipe-tabs.ts';
import { notify } from '../../../ui/notifications.ts';
import { Pager } from '../../../ui/pager.tsx';
import { ActionMenu, type MenuItems } from '../../../ui/action-menu.tsx';
import { ChangeCoverSheet } from '../../../ui/change-cover-sheet.tsx';
import { describeShelfAction, SHELF_ACTION_LABELS } from '../../../ui/shelf-membership.ts';
const coverCache = new WeakMap<object, Map<string, string>>();
@@ -81,6 +82,7 @@ export function ShelfPage() {
const [removeTarget, setRemoveTarget] = useState<Book | ContinueReadingItem | null>(null);
const [groupsOpen, setGroupsOpen] = useState(false);
const [actionError, setActionError] = useState('');
const [coverBook, setCoverBook] = useState<Book | null>(null);
// 在读 / 想读 / 已读 and one 分组 (a series or a tag), all in the URL like the page and sort.
const status = isReadingState(params.get('status')) ? params.get('status') as ReadingState : undefined;
const series = params.get('series') || undefined, tag = series ? undefined : params.get('tag') || undefined;
@@ -130,6 +132,8 @@ export function ShelfPage() {
const menuItems = (book: Book): MenuItems => [
...readingStates.filter(state => state !== book.readingState).map(state => ({ label: '标记为' + readingLabels[state], onSelect: () => void act(book, state) })),
book.readingState && { label: '清除标记', onSelect: () => void act(book, 'clear') },
// A book from a book source can take its cover from another source's edition.
book.format === 'chapters' && { label: '更换封面', onSelect: () => setCoverBook(book) },
{ label: SHELF_ACTION_LABELS.remove, danger: true, onSelect: () => void act(book, 'remove') },
];
const groupOptions = [
@@ -191,6 +195,7 @@ export function ShelfPage() {
{query.data && pages > 1 && <Pager className="shelf-pager" page={Math.min(page, pages)} pageCount={pages} busy={query.isFetching} onGo={next => go({ page: next })} />}
</section>
{settingsOpen && <ShelfSettingsSheet settings={settings} onPatch={patch => { void runtime.updateSettings(patch); }} onClose={() => setSettingsOpen(false)} onOpenExternalReader={() => { setSettingsOpen(false); navigate('/me/opds', { state: { returnTo: '/shelf' } }); }} />}
{coverBook && <ChangeCoverSheet bookId={coverBook.id} title={coverBook.title} onClose={() => setCoverBook(null)} onChanged={() => void query.refetch()} />}
{groupsOpen && <DialogView dialog={{ kind: 'pick', title: groups.isPending ? '分组 · 正在读取…' : groups.error ? '分组 · 读取失败' : '分组', options: groupOptions, resolve: (_value: string | null) => undefined } satisfies Dialog} destructive="" onClose={answer => {
setGroupsOpen(false);
if (typeof answer !== 'string') return;
@@ -4,6 +4,10 @@ import { Icon } from '../../../ui/icon.tsx';
import { Sheet } from '../../../ui/sheet.tsx';
import { ActionMenu } from '../../../ui/action-menu.tsx';
import { notify } from '../../../ui/notifications.ts';
import { LazyCover } from '../../../ui/lazy-cover.tsx';
const sourceCoverUrl = (sourceId: string, entry: SourceEntry) => entry.coverRef
? `/api/v1/sources/${encodeURIComponent(sourceId)}/cover?entryRef=${encodeURIComponent(entry.ref)}&coverRef=${encodeURIComponent(entry.coverRef)}` : null;
import { Button, Callout, IconTextButton } from '../../../ui/toolkit.tsx';
import { SourcesHeader } from '../components/SourcesHeader.tsx';
import { SourceBookDetailSheet } from '../components/SourceBookDetailSheet.tsx';
@@ -155,7 +159,7 @@ export function SourcesPage() {
{searchState === 'idle' && catalogPage?.title && <p className="notice">{catalogPage.title}</p>}
{catalogPage?.navigation && <div className="source-navigation">{catalogPage.navigation.map(item => <Button key={item.ref} onClick={() => void loadCatalog(item.ref)}>{item.title}</Button>)}</div>}
{catalogBusy && !catalogPage && <FloatingNotice message="正在加载目录…" busy />}
{catalogPage && <div className="source-results">{results.filter(group => { const q = resultGroupQuery.trim().toLocaleLowerCase(); return !q || group.entry.title.toLocaleLowerCase().includes(q) || group.entry.authors?.some(author => author.toLocaleLowerCase().includes(q)); }).map(group => <article className="sources-row catalog-book" key={group.key}><div><strong>{group.entry.title}</strong><small>{group.entry.authors?.join(' / ') || '作者未知'}</small>{!grouped && group.entry.description && <p className="source-description">{group.entry.description}</p>}</div>{grouped ? <button className="button catalog-source-count" type="button" onClick={() => setResultGroupKey(group.key)}><span>{group.entries.length} 条书源</span><Icon name="chevron-right" size={16} /></button> : <div className="sources-actions"><button className="button" type="button" onClick={() => setDetail(group.entry)}>详情</button>{(group.entry.options?.length ? group.entry.options : [{ id: '', label: '加入书架' }]).map(option => <button className="button primary" type="button" key={option.id} disabled={option.available === false || acquire.isPending} onClick={() => acquireEntry(group.entry, option.id)}>{option.label}</button>)}</div>}</article>)}</div>}
{catalogPage && <div className="source-results">{results.filter(group => { const q = resultGroupQuery.trim().toLocaleLowerCase(); return !q || group.entry.title.toLocaleLowerCase().includes(q) || group.entry.authors?.some(author => author.toLocaleLowerCase().includes(q)); }).map(group => <article className="sources-row catalog-book" key={group.key}><div className="catalog-book-main"><LazyCover url={sourceCoverUrl(selectedSourceId, group.entries.find(entry => entry.coverRef) ?? group.entry)} title={group.entry.title} /><div><strong>{group.entry.title}</strong><small>{group.entry.authors?.join(' / ') || '作者未知'}</small>{!grouped && group.entry.description && <p className="source-description">{group.entry.description}</p>}</div></div>{grouped ? <button className="button catalog-source-count" type="button" onClick={() => setResultGroupKey(group.key)}><span>{group.entries.length} 条书源</span><Icon name="chevron-right" size={16} /></button> : <div className="sources-actions"><button className="button" type="button" onClick={() => setDetail(group.entry)}>详情</button>{(group.entry.options?.length ? group.entry.options : [{ id: '', label: '加入书架' }]).map(option => <button className="button primary" type="button" key={option.id} disabled={option.available === false || acquire.isPending} onClick={() => acquireEntry(group.entry, option.id)}>{option.label}</button>)}</div>}</article>)}</div>}
{catalogPage && !catalogBusy && !catalogPage.items.length && !catalogPage.navigation?.length && <div className="catalog-empty"><strong>没有找到匹配书籍</strong><p>试试其他关键词,或调整搜索范围。</p></div>}
{searchState === 'complete' && catalogPage?.nextCursor && <div className="catalog-pagination"><button className="button" type="button" disabled={!canResume} onClick={() => void searchSources(true)}>加载更多结果</button></div>}
{acquired && <button className="button" type="button" onClick={() => navigate(`/book/${encodeURIComponent(acquired.id)}`)}>阅读《{acquired.title}》</button>}
+37
View File
@@ -1965,6 +1965,29 @@ book-content {
background: color-mix(in srgb, var(--reader-accent) 12%, transparent);
}
/* A volume heading: names the chapters under it, and is not one of them. */
.toc-list .toc-volume {
margin-top: var(--space-2);
padding: 0.45rem 0.6rem 0.25rem;
color: var(--reader-muted);
font-size: var(--text-label);
font-weight: 600;
letter-spacing: 0.02em;
}
.toc-list .toc-volume:first-child {
margin-top: 0;
}
/* When a chapter was updated, or how long it is: a quiet second line. */
.toc-note {
display: block;
margin-top: 0.1rem;
color: var(--reader-muted);
font-size: var(--text-meta);
font-weight: 400;
}
/* A row for a chapter the loaded window cannot reach. It is still listed — the
book does have that chapter — but it must not look tappable, because tapping it
is the case the panel used to accept in silence. */
@@ -3698,6 +3721,20 @@ book-content {
.catalog-empty > .icon { font-size:1.7rem; color:var(--reader-muted); }
.catalog-empty p { margin:0; font-size:var(--text-ui); color:var(--reader-muted); line-height:1.7; }
.catalog-book { padding:1.4rem 0; align-items:flex-start; }
.catalog-book-main { display:flex; align-items:flex-start; gap:var(--space-3); }
.catalog-book-main > div { flex:1; min-width:0; }
/* A source's cover: a fixed 3:4 frame, so rows do not jump when the image arrives. */
.lazy-cover { flex:none; display:block; width:3rem; aspect-ratio:3 / 4; overflow:hidden; border-radius:var(--radius-sm); background:var(--reader-surface); border:1px solid var(--reader-line); }
.lazy-cover img { display:block; width:100%; height:100%; object-fit:cover; }
.lazy-cover-placeholder { display:grid; place-items:center; width:100%; height:100%; color:var(--reader-muted); font-size:var(--text-title); }
.cover-picker-status { margin:0 0 var(--space-3); color:var(--reader-muted); font-size:var(--text-ui); }
.cover-picker-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(6rem, 1fr)); gap:var(--space-3); }
.cover-picker-item { display:flex; flex-direction:column; align-items:stretch; gap:var(--space-1); min-height:44px; padding:var(--space-1); border:1px solid transparent; border-radius:var(--radius-md); background:transparent; color:var(--reader-ink); font:inherit; cursor:pointer; text-align:center; }
.cover-picker-item .lazy-cover { width:100%; }
.cover-picker-item small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--reader-muted); font-size:var(--text-meta); }
.cover-picker-item:focus-visible { outline:var(--focus-ring); outline-offset:2px; }
.cover-picker-item:disabled { opacity:.6; cursor:default; }
@media (hover: hover) and (pointer: fine) { .cover-picker-item:hover { border-color:var(--reader-line); background:var(--reader-surface); } }
.catalog-book strong { font-size:var(--text-title); }
.catalog-book .source-description { line-height:1.75; color:var(--reader-muted); margin:.7rem 0 0; }
.catalog-book .sources-actions { padding-top:.2rem; }
+64
View File
@@ -0,0 +1,64 @@
/**
* 换封面: the covers other book sources have for the same book.
*
* It runs the same search as 换源 (same title and author, through the book's own
* source library) and lists only the editions that have a cover. Picking one stores
* it on the server as this book's cover; the book and its source stay as they are.
*/
import { type JSX, useEffect, useState } from 'react';
import type { SourceEntry } from '../api/sources.ts';
import { useRuntime } from '../app/providers/runtime-context.tsx';
import { LazyCover } from './lazy-cover.tsx';
import { notify } from './notifications.ts';
import { Sheet } from './sheet.tsx';
const candidateUrl = (bookId: string, entry: SourceEntry) =>
`/api/v1/books/${encodeURIComponent(bookId)}/cover-candidate?entryRef=${encodeURIComponent(entry.ref)}&coverRef=${encodeURIComponent(entry.coverRef ?? '')}`;
export function ChangeCoverSheet({ bookId, title, onClose, onChanged }: { bookId: string; title: string; onClose(): void; onChanged(): void }): JSX.Element {
const { api } = useRuntime();
const [entries, setEntries] = useState<SourceEntry[]>([]);
const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null);
const [searching, setSearching] = useState(true);
const [error, setError] = useState('');
const [saving, setSaving] = useState('');
useEffect(() => {
const run = new AbortController();
const sessionId = Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, '0')).join('');
void (async () => {
try {
const found = new Map<string, SourceEntry>();
for await (const page of api.alternatives(bookId, { sessionId }, { signal: run.signal })) {
if (run.signal.aborted) return;
for (const entry of page.items) if (entry.coverRef) found.set(entry.ref, entry);
setEntries([...found.values()]); if (page.batch) setProgress(page.batch);
}
} catch (reason) {
if (!run.signal.aborted) setError(reason instanceof Error ? reason.message : '搜索封面失败');
} finally { if (!run.signal.aborted) setSearching(false); }
})();
return () => run.abort();
}, [api, bookId]);
const choose = async (entry: SourceEntry) => {
if (saving || !entry.coverRef) return;
setSaving(entry.ref); setError('');
try {
await api.changeCover(bookId, entry.ref, entry.coverRef);
notify(`已更换《${title}》的封面`, { kind: 'success' });
onChanged(); onClose();
} catch (reason) { setError(reason instanceof Error ? reason.message : '更换封面失败'); setSaving(''); }
};
return <Sheet title="更换封面" subtitle={title} busy={!!saving} onClose={onClose}>
<div className="cover-picker">
<p className="cover-picker-status" role="status">
{searching ? `正在其它书源中查找封面${progress ? ` · ${progress.completed} / ${progress.total}` : ''}` : entries.length ? `找到 ${entries.length} 个封面,点选一个使用` : '其它书源没有提供这本书的封面'}
</p>
{error && <p className="notice error">{error}</p>}
<div className="cover-picker-grid">{entries.map((entry) => <button type="button" key={entry.ref} className="cover-picker-item" disabled={!!saving}
aria-label={`使用${entry.sourceName ?? '此书源'}的封面`} onClick={() => void choose(entry)}>
<LazyCover url={candidateUrl(bookId, entry)} title={entry.title} />
<small>{saving === entry.ref ? '正在保存…' : entry.sourceName ?? '书源'}</small>
</button>)}</div>
</div>
</Sheet>;
}
+43
View File
@@ -0,0 +1,43 @@
/**
* A cover fetched through the API only once it scrolls into view.
*
* Source covers are read through the server and the book source behind it, one
* request each, so a list of fifty results must not ask for fifty images at once.
* Bytes are fetched with the session (an `<img src>` cannot carry it) and kept as
* object URLs for the life of the page.
*/
import { type JSX, useEffect, useRef, useState } from 'react';
import { useRuntime } from '../app/providers/runtime-context.tsx';
const loaded = new Map<string, string>();
export function LazyCover({ url, title, className }: { url: string | null; title: string; className?: string }): JSX.Element {
const { api } = useRuntime();
const frame = useRef<HTMLSpanElement>(null);
const [visible, setVisible] = useState(false);
const [src, setSrc] = useState<string | null>(url ? loaded.get(url) ?? null : null);
useEffect(() => {
const node = frame.current;
if (!node || visible) return;
if (typeof IntersectionObserver === 'undefined') { setVisible(true); return; }
const observer = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) setVisible(true); }, { rootMargin: '200px' });
observer.observe(node);
return () => observer.disconnect();
}, [visible]);
useEffect(() => {
if (!url) { setSrc(null); return; }
const cached = loaded.get(url);
if (cached) { setSrc(cached); return; }
setSrc(null);
if (!visible) return;
let live = true;
void api.coverBytes(url).then((bytes) => {
const objectUrl = URL.createObjectURL(new Blob([bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer]));
loaded.set(url, objectUrl); if (live) setSrc(objectUrl);
}).catch(() => undefined);
return () => { live = false; };
}, [api, url, visible]);
return <span ref={frame} className={className ? `lazy-cover ${className}` : 'lazy-cover'} aria-hidden="true">
{src ? <img src={src} alt="" /> : <span className="lazy-cover-placeholder">{title.trim().slice(0, 1) || '书'}</span>}
</span>;
}
+11 -5
View File
@@ -28,7 +28,7 @@ import { DEFAULT_APP_SETTINGS, READOUT_FIELDS, READOUT_OPTIONS, type ReadoutMode
import { formatDate } from '../lib/format.ts';
import { ReaderIndicators } from './reader-indicators.tsx';
import type { SpeechEngineKind } from '../render/speech.ts';
import { type ReactNode, type JSX, useState } from 'react';
import { Fragment, type ReactNode, type JSX, useState } from 'react';
import { Button, IconButton, SectionTitle, SwitchRow } from './toolkit.tsx';
import { Icon, type IconName } from './icon.tsx';
import { ActionMenu } from './action-menu.tsx';
@@ -47,6 +47,10 @@ export interface ChromeTocEntry {
label: string;
depth: number;
spine?: number;
/** The volume heading this entry sits under; drawn once, above the first entry that has it. */
volume?: string;
/** A short line under the label, such as when the chapter was updated. */
note?: string;
}
export interface SpeechBarState {
@@ -328,8 +332,10 @@ export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProp
const hasChildren = (state.toc[index + 1]?.depth ?? -1) > entry.depth;
if (hasChildren && collapsed.has(entry.id)) hiddenDepth = entry.depth;
const reachable = entry.spine !== undefined && entry.spine >= 0;
return (
<li key={entry.id + index} data-section={entry.id}>
const heading = entry.volume && entry.volume !== state.toc[index - 1]?.volume ? entry.volume : '';
return (<Fragment key={entry.id + index}>
{heading && <li className="toc-volume" role="presentation">{heading}</li>}
<li data-section={entry.id}>
{hasChildren && <button className="toc-collapse" aria-label={entry.label + (collapsed.has(entry.id) ? ' 展开' : ' 折叠')} aria-expanded={!collapsed.has(entry.id)} onClick={() => { const next = new Set(collapsed); if (next.has(entry.id)) next.delete(entry.id); else next.add(entry.id); setCollapsed(next); }}><Icon name={collapsed.has(entry.id) ? 'chevron-right' : 'chevron-down'} size={16} /></button>}
<button
type="button"
@@ -341,10 +347,10 @@ export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProp
onClick={() => handlers.onTocEntry(entry.id)}
>
<span className="toc-index">{index + 1}</span>
<span className="toc-label">{entry.label}</span>
<span className="toc-label">{entry.label}{entry.note && <small className="toc-note">{entry.note}</small>}</span>
</button>
</li>
);
</Fragment>);
})}
</ul>
)}
+13 -5
View File
@@ -16,7 +16,13 @@ import { attachGestures } from './gestures.ts';
import { READOUT_FIELDS, type AppSettings } from '../store/settings.ts';
import { createStagedDoc, isStagedKind, windowIndexOf } from '../formats/windowed.ts';
import type { BookDoc } from '../formats/types.ts';
import type { TocEntry } from '../api/types.ts';
import type { ContentItem, TocEntry } from '../api/types.ts';
/** The directory of a chapter book, straight from its items: volumes and notes included. */
const tocOf = (items: ContentItem[]): ChromeTocEntry[] => items.map((item) => ({
id: item.href, label: item.title, depth: 0, spine: item.seq,
...(item.volume ? { volume: item.volume } : {}), ...(item.note ? { note: item.note } : {}),
}));
import type { NativePageHost } from './native-page.ts';
import { TtsEngine, type TtsSnapshot } from '../render/tts.ts';
import type { SpokenChunk } from '../render/tts-text.ts';
@@ -498,6 +504,7 @@ export class ReaderScreen {
title: item.title,
level: 0,
spine: item.seq,
...(item.volume ? { volume: item.volume } : {}), ...(item.note ? { note: item.note } : {}),
}));
}
if (token !== this.loadingToken) return null;
@@ -505,6 +512,7 @@ export class ReaderScreen {
this.patch({ toc: toc.map((entry) => ({
id: entry.href, label: entry.title, depth: entry.level,
...(entry.spine === undefined ? {} : { spine: entry.spine }),
...(entry.volume ? { volume: entry.volume } : {}), ...(entry.note ? { note: entry.note } : {}),
})) });
this.content = content;
@@ -838,7 +846,7 @@ export class ReaderScreen {
const blob = await this.options.api.refreshChapter(book.id, item.resourceRef ?? item.href, { signal: run.signal });
if (token !== this.loadingToken || run.signal.aborted) return;
await this.publicationCache.putResource(book.id, renditionRef(item), new Uint8Array(await blob.arrayBuffer()));
const toc = content.items.map(value => ({ id: value.href, label: value.title, depth: 0, spine: value.seq }));
const toc = tocOf(content.items);
const doc = createStagedDoc({ kind: content.kind, content, toc, orderedByBook: true, loader: { read: value => this.readStagedSection(book, value) } });
(doc as BookDoc & { staged?: unknown }).staged = doc;
// Navigation remains possible during the network request; restore the current landing chapter.
@@ -904,7 +912,7 @@ export class ReaderScreen {
if (token !== this.loadingToken) return;
const result = await this.options.api.switchSource(book.id, this.alternativeRef, this.chrome.alternativeChapter!, String(this.content?.revision ?? previous.content?.revision ?? previous.revision));
if (token !== this.loadingToken) return;
const content = result.content, toc = content.items.map(item => ({ id: item.href, label: item.title, depth: 0, spine: item.seq }));
const content = result.content, toc = tocOf(content.items);
const doc = createStagedDoc({ kind: content.kind, content, toc, orderedByBook: true, loader: { read: item => this.readStagedSection(book, item) } });
(doc as BookDoc & { staged?: unknown }).staged = doc;
const index = doc.sections.findIndex(section => section.id === result.href);
@@ -934,7 +942,7 @@ export class ReaderScreen {
try {
const content = await this.options.api.refreshPublication(book.id);
if (token !== this.loadingToken) return;
const toc = content.items.map((item) => ({ id: item.href, label: item.title, depth: 0, spine: item.seq }));
const toc = tocOf(content.items);
const doc = createStagedDoc({
kind: content.kind, content, toc, orderedByBook: true,
loader: { read: (item) => this.readStagedSection(book, item) },
@@ -1609,7 +1617,7 @@ export class ReaderScreen {
let entries: ChromeTocEntry[] = [];
if (book.format === 'chapters' && this.content) {
entries = this.content.items.map((item) => ({ id: item.href, label: item.title, depth: 0, spine: item.seq }));
entries = tocOf(this.content.items);
this.patch({ toc: entries });
return;
}