From 485ab38c2efcc8ba698bd11c8d07acc14bce91db Mon Sep 17 00:00:00 2001 From: hectorqin Date: Fri, 13 Nov 2026 01:27:32 +0000 Subject: [PATCH] feat: source covers, volume headings and chapter notes for chapter books MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/api.md | 10 ++- docs/ui.md | 2 + server/src/http/routes/library.ts | 5 +- server/src/http/routes/sources.ts | 26 ++++++++ server/src/indexer/formats/registry.ts | 7 ++ server/src/indexer/metadata.ts | 4 +- server/src/publications/chapters.ts | 3 + server/src/services/shelf.ts | 4 +- server/src/services/source-host.ts | 62 +++++++++++++++++- server/src/sources/protocol.ts | 4 +- server/src/sources/types.ts | 10 +++ server/test/chapter-publications-api.test.ts | 54 +++++++++++++++- web/src/api/client.ts | 4 ++ web/src/api/sources.ts | 2 + web/src/api/types.ts | 6 ++ web/src/features/shelf/pages/ShelfPage.tsx | 5 ++ .../features/sources/pages/SourcesPage.tsx | 6 +- web/src/styles/reader.css | 37 +++++++++++ web/src/ui/change-cover-sheet.tsx | 64 +++++++++++++++++++ web/src/ui/lazy-cover.tsx | 43 +++++++++++++ web/src/ui/reader-chrome.tsx | 16 +++-- web/src/ui/reader-screen.tsx | 18 ++++-- 22 files changed, 371 insertions(+), 21 deletions(-) create mode 100644 web/src/ui/change-cover-sheet.tsx create mode 100644 web/src/ui/lazy-cover.tsx diff --git a/docs/api.md b/docs/api.md index a3fbd1c..ebe89fb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 字符的字符串,缺省时不出现。 diff --git a/docs/ui.md b/docs/ui.md index d9d0220..e11411f 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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` 或 `
` 下拉。 +- 章节书的目录:条目带 `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。 以下是阅读器面板最初改成底部半屏的理由,仍然适用于手机形态: diff --git a/server/src/http/routes/library.ts b/server/src/http/routes/library.ts index 845e96a..c240c8b 100644 --- a/server/src/http/routes/library.ts +++ b/server/src/http/routes/library.ts @@ -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)); diff --git a/server/src/http/routes/sources.ts b/server/src/http/routes/sources.ts index 8f27833..4856e14 100644 --- a/server/src/http/routes/sources.ts +++ b/server/src/http/routes/sources.ts @@ -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 | 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 | null; diff --git a/server/src/indexer/formats/registry.ts b/server/src/indexer/formats/registry.ts index 30d2b97..c6dd5e1 100644 --- a/server/src/indexer/formats/registry.ts +++ b/server/src/indexer/formats/registry.ts @@ -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 { diff --git a/server/src/indexer/metadata.ts b/server/src/indexer/metadata.ts index 864a8c9..58f0d64 100644 --- a/server/src/indexer/metadata.ts +++ b/server/src/indexer/metadata.ts @@ -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 }); diff --git a/server/src/publications/chapters.ts b/server/src/publications/chapters.ts index 894c079..70bdb2e 100644 --- a/server/src/publications/chapters.ts +++ b/server/src/publications/chapters.ts @@ -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) } : {}), })), }; } diff --git a/server/src/services/shelf.ts b/server/src/services/shelf.ts index 2d5a323..7e034cf 100644 --- a/server/src/services/shelf.ts +++ b/server/src/services/shelf.ts @@ -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, diff --git a/server/src/services/source-host.ts b/server/src/services/source-host.ts index 684243c..ac3588e 100644 --- a/server/src/services/source-host.ts +++ b/server/src/services/source-host.ts @@ -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 { + 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 { + 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) { diff --git a/server/src/sources/protocol.ts b/server/src/sources/protocol.ts index 79101e3..d5dfe92 100644 --- a/server/src/sources/protocol.ts +++ b/server/src/sources/protocol.ts @@ -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); diff --git a/server/src/sources/types.ts b/server/src/sources/types.ts index ddd1a6a..b8e928c 100644 --- a/server/src/sources/types.ts +++ b/server/src/sources/types.ts @@ -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 { diff --git a/server/test/chapter-publications-api.test.ts b/server/test/chapter-publications-api.test.ts index b8ea180..b395110 100644 --- a/server/test/chapter-publications-api.test.ts +++ b/server/test/chapter-publications-api.test.ts @@ -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; failManifest?: boolean; resourceMediaType?: string; + /** Base64 images by cover ref; the entry advertises `cover` when present. */ + covers?: Record; } 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('').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> & { items: Array }; + 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'); +}); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 243f37c..67af5a0 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -297,6 +297,10 @@ export class ReaderApi { async createOpdsCredential(name: string): Promise { return this.call('/api/v1/opds/credentials', 'POST', { name }); } async revokeOpdsCredential(id: string): Promise { await this.call('/api/v1/opds/credentials/' + encodeURIComponent(id), 'DELETE'); } async deleteCredential(id: string, key: string): Promise { 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 { + 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 }); } diff --git a/web/src/api/sources.ts b/web/src/api/sources.ts index 1b87a99..037e653 100644 --- a/web/src/api/sources.ts +++ b/web/src/api/sources.ts @@ -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 }>; } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 485dde7..247d9f4 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 { diff --git a/web/src/features/shelf/pages/ShelfPage.tsx b/web/src/features/shelf/pages/ShelfPage.tsx index a51b85f..d76b6f4 100644 --- a/web/src/features/shelf/pages/ShelfPage.tsx +++ b/web/src/features/shelf/pages/ShelfPage.tsx @@ -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>(); @@ -81,6 +82,7 @@ export function ShelfPage() { const [removeTarget, setRemoveTarget] = useState(null); const [groupsOpen, setGroupsOpen] = useState(false); const [actionError, setActionError] = useState(''); + const [coverBook, setCoverBook] = useState(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 && go({ page: next })} />} {settingsOpen && { void runtime.updateSettings(patch); }} onClose={() => setSettingsOpen(false)} onOpenExternalReader={() => { setSettingsOpen(false); navigate('/me/opds', { state: { returnTo: '/shelf' } }); }} />} + {coverBook && setCoverBook(null)} onChanged={() => void query.refetch()} />} {groupsOpen && undefined } satisfies Dialog} destructive="" onClose={answer => { setGroupsOpen(false); if (typeof answer !== 'string') return; diff --git a/web/src/features/sources/pages/SourcesPage.tsx b/web/src/features/sources/pages/SourcesPage.tsx index ed613cf..2b4b71d 100644 --- a/web/src/features/sources/pages/SourcesPage.tsx +++ b/web/src/features/sources/pages/SourcesPage.tsx @@ -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 &&

{catalogPage.title}

} {catalogPage?.navigation &&
{catalogPage.navigation.map(item => )}
} {catalogBusy && !catalogPage && } - {catalogPage &&
{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 =>
{group.entry.title}{group.entry.authors?.join(' / ') || '作者未知'}{!grouped && group.entry.description &&

{group.entry.description}

}
{grouped ? :
{(group.entry.options?.length ? group.entry.options : [{ id: '', label: '加入书架' }]).map(option => )}
}
)}
} + {catalogPage &&
{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 =>
entry.coverRef) ?? group.entry)} title={group.entry.title} />
{group.entry.title}{group.entry.authors?.join(' / ') || '作者未知'}{!grouped && group.entry.description &&

{group.entry.description}

}
{grouped ? :
{(group.entry.options?.length ? group.entry.options : [{ id: '', label: '加入书架' }]).map(option => )}
}
)}
} {catalogPage && !catalogBusy && !catalogPage.items.length && !catalogPage.navigation?.length &&
没有找到匹配书籍

试试其他关键词,或调整搜索范围。

} {searchState === 'complete' && catalogPage?.nextCursor &&
} {acquired && } diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index 062ec72..ad14f98 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -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; } diff --git a/web/src/ui/change-cover-sheet.tsx b/web/src/ui/change-cover-sheet.tsx new file mode 100644 index 0000000..dda39c5 --- /dev/null +++ b/web/src/ui/change-cover-sheet.tsx @@ -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([]); + 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(); + 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 +
+

+ {searching ? `正在其它书源中查找封面${progress ? ` · ${progress.completed} / ${progress.total}` : ''}` : entries.length ? `找到 ${entries.length} 个封面,点选一个使用` : '其它书源没有提供这本书的封面'} +

+ {error &&

{error}

} +
{entries.map((entry) => )}
+
+
; +} diff --git a/web/src/ui/lazy-cover.tsx b/web/src/ui/lazy-cover.tsx new file mode 100644 index 0000000..773cf8d --- /dev/null +++ b/web/src/ui/lazy-cover.tsx @@ -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 `` 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(); + +export function LazyCover({ url, title, className }: { url: string | null; title: string; className?: string }): JSX.Element { + const { api } = useRuntime(); + const frame = useRef(null); + const [visible, setVisible] = useState(false); + const [src, setSrc] = useState(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 ; +} diff --git a/web/src/ui/reader-chrome.tsx b/web/src/ui/reader-chrome.tsx index 3137836..b14ca00 100644 --- a/web/src/ui/reader-chrome.tsx +++ b/web/src/ui/reader-chrome.tsx @@ -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 ( -
  • + const heading = entry.volume && entry.volume !== state.toc[index - 1]?.volume ? entry.volume : ''; + return ( + {heading &&
  • {heading}
  • } +
  • {hasChildren && }
  • - ); + ); })} )} diff --git a/web/src/ui/reader-screen.tsx b/web/src/ui/reader-screen.tsx index cf14b19..b8583b4 100644 --- a/web/src/ui/reader-screen.tsx +++ b/web/src/ui/reader-screen.tsx @@ -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; }