Compare commits

...

17 Commits

Author SHA1 Message Date
bc10a39920 merge: feature/53-adaptive-reading dans develop (lecture adaptative pour mauvaises connexions — manifest, ressources Range/ETag, fenêtre de lecture priorisée, cache reconstructible, EPUB par ressources manifestées et fallback PDF allégé — évolution #53) 2026-09-29 23:56:50 +02:00
bdc6d58d45 feat(reading): lecture adaptative pour mauvaises connexions — manifest de lecture avec fenêtre priorisée autour de la position du lecteur et préchargement progressif des pages voisines, ressources authentifiées servies avec Range/ETag (module reading API: controller/service/dto/ports, source archive et cache reconstructible filesystem), client web adaptatif useAdaptiveReading avec enrichissement du cache au fil de la lecture, EPUB chargé par ressources manifestées via loader Foliate avec credentials (fini le téléchargement complet du fichier), optimisation des images EPUB et fallback page PDF allégée, avec tests api/web — évolution #53
Ouvrir rapidement un très grand livre et continuer la lecture sans attendre le chargement complet sur très mauvaise connexion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-29 23:56:42 +02:00
bf5441d5c4 merge: feature/52-books-read-status dans develop (marquage des livres comme lus avec complétion automatique et badge visuel — évolution #52) 2026-08-30 10:37:46 +02:00
05f5f1cd16 feat(books): marquage des livres comme lus — statut lu/non lu par utilisateur sur progress (colonnes read_status/completed_at/read_status_source, migration idempotente avec backfill à 95 %), endpoint PUT /progress/:bookId/status, complétion automatique au fil de la progression, état lu exposé sur catalogue/recherche/fiche/séries, étagère « continuer » limitée aux lectures en cours, badge Lu sur cartes/fiche/accueil et bouton Marquer comme lu/non lu sur la fiche, avec tests api/shared/web — évolution #52 2026-08-30 10:37:42 +02:00
3faa7ca44b merge: fix/51-auth-status-unavailable dans develop (première connexion utilisable quand le statut d'authentification est indisponible — correctif #51) 2026-08-30 09:41:43 +02:00
d2557c14fa fix(web): login — première connexion utilisable quand le statut d'authentification est indisponible : l'appel de statut n'enrichit que l'écran déconnecté, l'erreur bloquante est retirée du catch, avec test de non-régression LoginPageAuthStatusUnavailable (bug #51) 2026-08-30 09:41:43 +02:00
7e6c0bdfab merge: feature/50-gitea-docker-packages dans develop (images Docker publiées sur le registry Gitea et consommées par compose — évolution #50) 2026-08-30 09:13:58 +02:00
2484fd1e09 feat(build): publier les images API+web sur le registry Gitea et faire consommer compose aux packages — compose tire ${READABOOK_REGISTRY}/api|web au lieu de builder, scripts/publish.sh (buildx --push, labels OCI), override docker-compose.build.yaml pour le build local, chemin bibliothèque re-paramétrable via READABOOK_LIBRARY_HOST_PATH — évolution #50 2026-08-30 09:13:58 +02:00
c17ea105a9 merge: develop dans main — release v1.0.0 (lecteur PDF/EPUB/CBZ avec zoom/pinch/modes, accueil refondu, pages d'erreur, profil clarifié — tickets #34 à #48) 2026-08-28 23:56:17 +02:00
af8ebef275 merge: feature/48-home-profile-polish dans develop (accueil allégé, grilles pleine largeur à 5 colonnes, bibliothèques scrollables, profil clarifié — évolution #48) 2026-08-28 23:53:15 +02:00
1bbf03d875 feat(web): accueil et profil — allègement du hero (textes descriptifs retirés), grilles de livres pleine largeur à 5 colonnes (auto-fit sans max-width), liste des bibliothèques scrollable (3 lignes visibles, partagée avec .continue-grid), et écran de profil clarifié (avatar, bloc identité, actions Modifier/Sortir — évolution #48) 2026-08-28 23:53:11 +02:00
8d5d1bc2ff merge: fix/47-home-continue-meter dans develop (barre d'avancement rétablie dans les livres en cours de l'accueil — correctif #47) 2026-08-28 23:36:15 +02:00
72f43c629d fix(web): accueil — rétablir la barre d'avancement dans les livres en cours (Meter avec progressPercent dans renderHomeBook, tuiles « en cours » pointant de nouveau vers le lecteur — correctif bug #47, régression de l'évolution #45) 2026-08-28 23:36:13 +02:00
28ee33bd0b merge: feature/46-home-grid-error-pages dans develop (grilles limitées à 5 colonnes, bandeau API retiré, pages d'erreur pour livres inexistants — évolution #46) 2026-08-28 23:29:08 +02:00
c2cdb07f9a feat(web): bibliothèque et lecteur — grilles de livres bornées à 5 colonnes (max-width calc sur .book-grid et .home-book-grid), retrait du bandeau « API absente ou incomplete » de l'accueil, et véritables pages d'erreur « Livre introuvable » sur fiche et lecteur (suppression de getApiFallback, retour à l'accueil + réessayer — évolution #46) 2026-08-28 23:29:03 +02:00
644a4b0183 merge: feature/45-home-redesign dans develop (refonte sobre de l'accueil : cartes simplifiées et livres en cours limités à 3 visibles — évolution #45) 2026-08-28 23:07:43 +02:00
11bb529322 feat(web): accueil — refonte sobre et moderne : hero resserré, cartes de livres simplifiées (renderHomeBook : couverture + titre + auteur, sans pill de métadonnées ni Meter) et liste des livres en cours limitée à 3 éléments visibles avant scroll (tuiles 112px — évolution #45) 2026-08-28 23:07:39 +02:00
58 changed files with 2539 additions and 235 deletions

View File

@ -1,5 +1,9 @@
# Local compose defaults. Copy to .env when you need machine-specific paths.
# Images used by docker-compose.yaml.
READABOOK_REGISTRY=gitea.anthonybouteiller.ovh/blomios/readabook
READABOOK_TAG=latest
# Directory mounted read-only as /library in the API container.
READABOOK_LIBRARY_HOST_PATH=./data/library

View File

@ -19,6 +19,15 @@ RUN pnpm --filter @readabook/api build
RUN pnpm --filter @readabook/api deploy --prod /prod
FROM node:22-bookworm-slim AS runtime
ARG OCI_IMAGE_CREATED
ARG OCI_IMAGE_REVISION
ARG OCI_IMAGE_VERSION=latest
LABEL org.opencontainers.image.title="ReadaBook API" \
org.opencontainers.image.description="ReadaBook NestJS API service" \
org.opencontainers.image.source="https://gitea.anthonybouteiller.ovh/blomios/ReadaBook" \
org.opencontainers.image.created="${OCI_IMAGE_CREATED}" \
org.opencontainers.image.revision="${OCI_IMAGE_REVISION}" \
org.opencontainers.image.version="${OCI_IMAGE_VERSION}"
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000

View File

@ -47,7 +47,8 @@ data/
library/ Dossier local à scanner, monté en lecture seule dans compose
storage/ Cache backend, notamment couvertures extraites
Dockerfile Image API
docker-compose.yaml Services API + web
docker-compose.yaml Services API + web depuis images publiees
docker-compose.build.yaml Override de build local des images API + web
pnpm-workspace.yaml Workspace monorepo
```
@ -72,7 +73,7 @@ Note sandbox constatée : sur Node 24 local, `better-sqlite3` peut nécessiter u
## Docker Compose
```bash
JWT_SECRET="replace-me" docker compose up --build
JWT_SECRET="replace-me" docker compose up
```
Services :
@ -85,6 +86,24 @@ Ports :
- Hôte `3000` -> conteneur `web:80`.
- L’API est accessible depuis le navigateur via les routes proxifiées par Nginx : `/auth`, `/admin`, `/books`, `/progress`, `/healthz`.
Images par défaut :
- `${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/api:${READABOOK_TAG:-latest}`
- `${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/web:${READABOOK_TAG:-latest}`
Pour construire localement au lieu de tirer les images publiées :
```bash
READABOOK_TAG=local docker compose -f docker-compose.yaml -f docker-compose.build.yaml up --build
```
Pour publier les images vers le registry Gitea :
```bash
docker login gitea.anthonybouteiller.ovh
./scripts/publish.sh
```
## Frontend PWA
Le frontend est accessible sur `http://localhost:3000` en compose. Il expose les routes MVP :
@ -108,6 +127,8 @@ Volumes :
Variables principales :
- `READABOOK_REGISTRY` : préfixe registry/repository des images API et web.
- `READABOOK_TAG` : tag d’image utilisé par compose et par `scripts/publish.sh`.
- `JWT_SECRET` : secret JWT, à changer hors développement.
- `OPEN_LIBRARY_ENABLED=true|false` : active/désactive l’enrichissement distant.
- `READABOOK_LIBRARY_HOST_PATH` : dossier hôte monté en lecture seule sur `/library`.

View File

@ -6,11 +6,12 @@ import { BooksModule } from "./books/books.module.js";
import { DatabaseModule } from "./database/database.module.js";
import { ProgressModule } from "./progress/progress.module.js";
import { ReaderModule } from "./reader/reader.module.js";
import { ReadingModule } from "./reading/reading.module.js";
import { ScannerModule } from "./scanner/scanner.module.js";
import { HealthController } from "./health.controller.js";
@Module({
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ScannerModule, AutomationModule],
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ReadingModule, ScannerModule, AutomationModule],
controllers: [HealthController]
})
export class AppModule {}

View File

@ -3,6 +3,7 @@ import { FastifyReply } from "fastify";
import { lookup } from "mime-types";
import { BookQueryDto, BookQuerySchema } from "@readabook/shared";
import { AuthGuard } from "../auth/auth.guard.js";
import { CurrentUser, CurrentUserParam } from "../auth/current-user.js";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { BooksService } from "./books.service.js";
@ -12,18 +13,18 @@ export class BooksController {
constructor(private readonly books: BooksService) {}
@Get()
list(@Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
return this.books.list(query);
list(@CurrentUserParam() user: CurrentUser, @Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
return this.books.list(query, user.id);
}
@Get("search")
search(@Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
return query.q ? this.books.search(query.q, query.limit, query.offset) : [];
search(@CurrentUserParam() user: CurrentUser, @Query(new ZodValidationPipe(BookQuerySchema)) query: BookQueryDto) {
return query.q ? this.books.search(query.q, query.limit, query.offset, user.id) : [];
}
@Get(":id")
get(@Param("id") id: string) {
return this.books.get(Number(id));
get(@CurrentUserParam() user: CurrentUser, @Param("id") id: string) {
return this.books.get(Number(id), user.id);
}
@Get(":id/pages")

View File

@ -4,7 +4,7 @@ import { extname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { BookQuerySchema } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { books, libraries, series } from "../database/schema.js";
import { books, libraries, progress, series, users } from "../database/schema.js";
import { BooksService } from "./books.service.js";
const previousDatabasePath = process.env.DATABASE_PATH;
@ -149,6 +149,89 @@ describe("BooksService", () => {
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("exposes user read status on catalogue, search and book details", () => {
const database = createDatabase();
const service = new BooksService(database);
const now = database.now();
const user = database.db
.insert(users)
.values({
email: "reader@example.com",
name: "Reader",
passwordHash: "hash",
role: "user",
createdAt: now,
updatedAt: now
})
.returning()
.get();
const library = database.db
.insert(libraries)
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const book = database.db
.insert(books)
.values({
libraryId: library.id,
seriesId: null,
title: "Daredevil Read State",
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: null,
localMetadataJson: null,
language: null,
publisher: null,
publishedDate: null,
volumeNumber: null,
volumeLabel: null,
format: "cbz",
filePath: "/library/Daredevil Read State.cbz",
coverPath: null,
metadataStatus: "none",
metadataProvenanceJson: null,
scanStatus: "succeeded",
enrichmentStatus: "idle",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.returning()
.get();
database.db
.insert(progress)
.values({
userId: user.id,
bookId: book.id,
locator: "manual:read",
percent: 100,
readStatus: "read",
completedAt: now,
readStatusSource: "manual",
createdAt: now,
updatedAt: now
})
.run();
expect(service.get(book.id)).toMatchObject({ readStatus: "unread", completedAt: null, readStatusSource: null });
expect(service.get(book.id, user.id)).toMatchObject({ readStatus: "read", completedAt: now, readStatusSource: "manual" });
expect(service.list({ limit: 50, offset: 0 }, user.id)[0]).toMatchObject({
readStatus: "read",
completedAt: now,
readStatusSource: "manual"
});
expect(service.search("Daredevil", undefined, 0, user.id)[0]).toMatchObject({
readStatus: "read",
completedAt: now,
readStatusSource: "manual"
});
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("lists a series with distinct books sharing the same volume", () => {
const database = createDatabase();
const service = new BooksService(database);

View File

@ -1,24 +1,32 @@
import { BadRequestException, HttpException, HttpStatus, Injectable, NotFoundException } from "@nestjs/common";
import { createReadStream, existsSync, statSync } from "node:fs";
import { extname } from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { BookQueryDto } from "@readabook/shared";
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
import { DatabaseService } from "../database/database.service.js";
import { books, series } from "../database/schema.js";
import { books, progress, series } from "../database/schema.js";
import { normalizePublishedDate } from "../metadata/use-cases/normalize-published-date.js";
type ReadState = {
readStatus: "unread" | "reading" | "read";
completedAt: string | null;
readStatusSource: "progress" | "manual" | null;
};
const unreadState: ReadState = { readStatus: "unread", completedAt: null, readStatusSource: null };
@Injectable()
export class BooksService {
constructor(private readonly database: DatabaseService) {}
list(query: BookQueryDto) {
list(query: BookQueryDto, userId?: number) {
const filters = [];
if (query.format) filters.push(eq(books.format, query.format));
if (query.libraryId) filters.push(eq(books.libraryId, query.libraryId));
if (query.q) {
return this.search(query.q, query.limit, query.offset);
return this.search(query.q, query.limit, query.offset, userId);
}
const statement = this.database.db
.select()
@ -27,11 +35,11 @@ export class BooksService {
.orderBy(books.title);
const rows =
query.limit === undefined ? statement.all() : statement.limit(query.limit).offset(query.offset).all();
return rows
.map((book) => this.mapBookSelect(book));
const states = this.readStatesFor(userId, rows.map((book) => book.id));
return rows.map((book) => this.mapBookSelect(book, states.get(book.id)));
}
search(q: string, limit?: number, offset = 0) {
search(q: string, limit?: number, offset = 0, userId?: number) {
if (limit === undefined) {
const rows = this.database.sqlite
.prepare(
@ -44,7 +52,7 @@ export class BooksService {
`
)
.all(`${q.replace(/"/g, '""')}*`);
return (rows as Array<Record<string, unknown>>).map((row) => this.mapBookRow(row));
return this.mapBookRows(rows as Array<Record<string, unknown>>, userId);
}
const rows = this.database.sqlite
@ -59,18 +67,18 @@ export class BooksService {
`
)
.all(`${q.replace(/"/g, '""')}*`, limit, offset);
return (rows as Array<Record<string, unknown>>).map((row) => this.mapBookRow(row));
return this.mapBookRows(rows as Array<Record<string, unknown>>, userId);
}
get(id: number) {
return this.mapBookSelect(this.getRecord(id));
get(id: number, userId?: number) {
return this.mapBookSelect(this.getRecord(id), this.readStatesFor(userId, [id]).get(id));
}
listSeries() {
return this.database.db.select().from(series).orderBy(series.title).all();
}
getSeries(id: number) {
getSeries(id: number, userId?: number) {
const row = this.database.db.select().from(series).where(eq(series.id, id)).get();
if (!row) throw new NotFoundException("Series not found");
const seriesBooks = this.database.db
@ -78,9 +86,10 @@ export class BooksService {
.from(books)
.where(eq(books.seriesId, id))
.orderBy(books.volumeNumber, books.title)
.all()
.map((book) => this.mapBookSelect(book));
return { ...row, books: seriesBooks };
.all();
const states = this.readStatesFor(userId, seriesBooks.map((book) => book.id));
const mappedBooks = seriesBooks.map((book) => this.mapBookSelect(book, states.get(book.id)));
return { ...row, books: mappedBooks };
}
streamFile(id: number, range?: string) {
@ -155,7 +164,15 @@ export class BooksService {
}
}
private mapBookRow(row: Record<string, unknown>) {
private mapBookRows(rows: Array<Record<string, unknown>>, userId?: number) {
const states = this.readStatesFor(
userId,
rows.map((row) => Number(row.id))
);
return rows.map((row) => this.mapBookRow(row, states.get(Number(row.id))));
}
private mapBookRow(row: Record<string, unknown>, readState = unreadState) {
const seriesId = nullable(row.series_id);
return {
id: Number(row.id),
@ -179,6 +196,7 @@ export class BooksService {
series: seriesId ? this.getSeriesRecord(Number(seriesId)) : null,
scanStatus: statusValue(row.scan_status),
enrichmentStatus: statusValue(row.enrichment_status),
...readState,
fileSize: Number(row.file_size),
fileMtime: String(row.file_mtime),
createdAt: String(row.created_at),
@ -186,19 +204,44 @@ export class BooksService {
};
}
private mapBookSelect(row: typeof books.$inferSelect) {
private mapBookSelect(row: typeof books.$inferSelect, readState = unreadState) {
const { metadataProvenanceJson: _metadataProvenanceJson, ...book } = row;
return {
...book,
publishedDate: normalizePublishedDate(row.publishedDate),
metadataProvenance: parseObject(row.metadataProvenanceJson),
series: row.seriesId ? this.getSeriesRecord(row.seriesId) : null
series: row.seriesId ? this.getSeriesRecord(row.seriesId) : null,
...readState
};
}
private getSeriesRecord(id: number) {
return this.database.db.select().from(series).where(eq(series.id, id)).get() ?? null;
}
private readStatesFor(userId: number | undefined, bookIds: number[]): Map<number, ReadState> {
if (userId === undefined || bookIds.length === 0) return new Map();
const rows = this.database.db
.select({
bookId: progress.bookId,
readStatus: progress.readStatus,
completedAt: progress.completedAt,
readStatusSource: progress.readStatusSource
})
.from(progress)
.where(and(eq(progress.userId, userId), inArray(progress.bookId, bookIds)))
.all();
return new Map(
rows.map((row) => [
row.bookId,
{
readStatus: readStatusValue(row.readStatus),
completedAt: row.completedAt,
readStatusSource: readStatusSourceValue(row.readStatusSource)
}
])
);
}
}
function parseByteRange(range: string | undefined, size: number): { start: number; end: number; partial: boolean } {
@ -254,6 +297,14 @@ function metadataStatusValue(value: unknown): "enriched" | "partial" | "none" {
return value === "enriched" || value === "partial" ? value : "none";
}
function readStatusValue(value: unknown): "unread" | "reading" | "read" {
return value === "reading" || value === "read" ? value : "unread";
}
function readStatusSourceValue(value: unknown): "progress" | "manual" | null {
return value === "progress" || value === "manual" ? value : null;
}
function parseObject(value: unknown): Record<string, string> {
if (typeof value !== "string") return {};
try {

View File

@ -1,5 +1,6 @@
import { Controller, Get, Param, UseGuards } from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard.js";
import { CurrentUser, CurrentUserParam } from "../auth/current-user.js";
import { BooksService } from "./books.service.js";
@Controller("series")
@ -13,7 +14,7 @@ export class SeriesController {
}
@Get(":id")
get(@Param("id") id: string) {
return this.books.getSeries(Number(id));
get(@CurrentUserParam() user: CurrentUser, @Param("id") id: string) {
return this.books.getSeries(Number(id), user.id);
}
}

View File

@ -89,6 +89,8 @@ describe("database migrations", () => {
INSERT INTO libraries (id, name, path, enabled, created_at, updated_at)
VALUES (1, 'Corpus', '/library', 1, '${now}', '${now}');
INSERT INTO users (id, email, name, password_hash, role, created_at, updated_at)
VALUES (1, 'reader@example.com', 'Reader', 'hash', 'user', '${now}', '${now}');
INSERT INTO books (
library_id, title, author, description, isbn, language, publisher, published_date,
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at
@ -97,6 +99,8 @@ describe("database migrations", () => {
(1, 'Solo Leveling T03', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Solo Leveling T03.cbz', NULL, 42, '${now}', '${now}', '${now}'),
(1, 'Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Eyeshield.21.T01.FRENCH.CBZ.eBook-ebdz.cbz', NULL, 42, '${now}', '${now}', '${now}'),
(1, 'Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+', NULL, NULL, NULL, NULL, NULL, NULL, 'cbz', '/library/Dragon.Ball.SD.T01.FRENCH.CBZ.eBook-Paprika+.cbz', NULL, 42, '${now}', '${now}', '${now}');
INSERT INTO progress (user_id, book_id, locator, percent, created_at, updated_at)
VALUES (1, 1, 'epubcfi(/6/2)', 97, '${now}', '${now}');
`);
legacy.close();
@ -105,6 +109,7 @@ describe("database migrations", () => {
const database = new DatabaseService();
const bookColumns = database.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>;
const progressColumns = database.sqlite.prepare("PRAGMA table_info(progress)").all() as Array<{ name: string }>;
const bookIndexes = database.sqlite.prepare("PRAGMA index_list(books)").all() as Array<{ name: string }>;
const metadataSources = database.sqlite.prepare("SELECT provider FROM metadata_source_config ORDER BY priority").all() as Array<{ provider: string }>;
const seriesRows = database.sqlite
@ -126,6 +131,9 @@ describe("database migrations", () => {
const automationSettings = database.sqlite.prepare("SELECT id, isbn_priority_enabled FROM automation_settings").get() as
| { id: number; isbn_priority_enabled: number }
| undefined;
const progressRow = database.sqlite
.prepare("SELECT read_status, completed_at, read_status_source FROM progress WHERE user_id = 1 AND book_id = 1")
.get() as { read_status: string; completed_at: string | null; read_status_source: string | null };
expect(bookColumns.map((column) => column.name)).toContain("isbn13");
expect(bookColumns.map((column) => column.name)).toContain("identifiers_json");
@ -134,6 +142,9 @@ describe("database migrations", () => {
expect(bookColumns.map((column) => column.name)).toContain("series_id");
expect(bookColumns.map((column) => column.name)).toContain("volume_number");
expect(bookColumns.map((column) => column.name)).toContain("volume_label");
expect(progressColumns.map((column) => column.name)).toContain("read_status");
expect(progressColumns.map((column) => column.name)).toContain("completed_at");
expect(progressColumns.map((column) => column.name)).toContain("read_status_source");
expect(bookIndexes.map((index) => index.name)).toContain("books_isbn13_idx");
expect(bookIndexes.map((index) => index.name)).toContain("books_series_idx");
expect(seriesRows).toEqual([
@ -161,6 +172,7 @@ describe("database migrations", () => {
]);
expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"]);
expect(automationSettings).toMatchObject({ id: 1, isbn_priority_enabled: 1 });
expect(progressRow).toEqual({ read_status: "read", completed_at: now, read_status_source: "progress" });
database.onModuleDestroy();
});

View File

@ -95,6 +95,9 @@ export class DatabaseService implements OnModuleDestroy {
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
locator TEXT NOT NULL,
percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100),
read_status TEXT NOT NULL DEFAULT 'unread' CHECK (read_status IN ('unread','reading','read')),
completed_at TEXT,
read_status_source TEXT CHECK (read_status_source IN ('progress','manual')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, book_id)
@ -165,6 +168,7 @@ export class DatabaseService implements OnModuleDestroy {
this.ensureBooksMetadataColumns();
this.ensureSeriesModel();
this.ensureReaderPreferencesTable();
this.ensureProgressReadStatusColumns();
this.ensureMetadataSourceConfigSupportsComicProviders();
this.ensureMetadataSourceConfigColumns();
this.ensureAutomationSettingsColumns();
@ -375,6 +379,35 @@ export class DatabaseService implements OnModuleDestroy {
`);
}
private ensureProgressReadStatusColumns(): void {
const names = this.columnNames("progress");
if (!names.has("read_status")) {
this.sqlite.exec("ALTER TABLE progress ADD COLUMN read_status TEXT NOT NULL DEFAULT 'unread'");
}
if (!names.has("completed_at")) {
this.sqlite.exec("ALTER TABLE progress ADD COLUMN completed_at TEXT");
}
if (!names.has("read_status_source")) {
this.sqlite.exec("ALTER TABLE progress ADD COLUMN read_status_source TEXT");
}
this.sqlite.exec(`
UPDATE progress
SET
read_status = CASE
WHEN percent >= 95 THEN 'read'
WHEN percent > 0 THEN 'reading'
ELSE 'unread'
END,
completed_at = CASE
WHEN percent >= 95 THEN COALESCE(completed_at, updated_at)
ELSE NULL
END,
read_status_source = COALESCE(read_status_source, 'progress')
WHERE read_status_source IS NULL
`);
this.sqlite.exec("CREATE INDEX IF NOT EXISTS progress_user_status_idx ON progress(user_id, read_status)");
}
private ensureMetadataSourceConfigColumns(): void {
const names = this.columnNames("metadata_source_config");
const now = sqlString(this.now());

View File

@ -102,6 +102,9 @@ export const progress = sqliteTable(
.references(() => books.id, { onDelete: "cascade" }),
locator: text("locator").notNull(),
percent: integer("percent").notNull(),
readStatus: text("read_status", { enum: ["unread", "reading", "read"] }).notNull().default("unread"),
completedAt: text("completed_at"),
readStatusSource: text("read_status_source", { enum: ["progress", "manual"] }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},

View File

@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import { ProgressController } from "./progress.controller.js";
describe("ProgressController read status endpoint", () => {
it("exposes PUT /progress/:bookId/status and delegates manual read status changes", () => {
const source = readFileSync(new URL("./progress.controller.ts", import.meta.url), "utf8");
const markReadStatus = vi.fn().mockReturnValue({
bookId: 42,
locator: "manual:read",
percent: 100,
readStatus: "read",
completedAt: "2026-08-30T00:00:00.000Z",
readStatusSource: "manual",
updatedAt: "2026-08-30T00:00:00.000Z"
});
const controller = new ProgressController({ markReadStatus } as never);
expect(source).toContain('@Put(":bookId/status")');
expect(source).toContain("new ZodValidationPipe(UpdateReadStatusSchema)");
expect(controller.updateStatus({ id: 7, email: "reader@example.com", role: "user" }, "42", { status: "read" })).toMatchObject({
bookId: 42,
readStatus: "read",
readStatusSource: "manual"
});
expect(markReadStatus).toHaveBeenCalledWith(7, 42, { status: "read" });
});
});

View File

@ -1,5 +1,5 @@
import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common";
import { UpdateProgressDto, UpdateProgressSchema } from "@readabook/shared";
import { UpdateProgressDto, UpdateProgressSchema, UpdateReadStatusDto, UpdateReadStatusSchema } from "@readabook/shared";
import { AuthGuard } from "../auth/auth.guard.js";
import { CurrentUser, CurrentUserParam } from "../auth/current-user.js";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
@ -28,4 +28,13 @@ export class ProgressController {
) {
return this.progress.upsert(user.id, Number(bookId), body);
}
@Put(":bookId/status")
updateStatus(
@CurrentUserParam() user: CurrentUser,
@Param("bookId") bookId: string,
@Body(new ZodValidationPipe(UpdateReadStatusSchema)) body: UpdateReadStatusDto
) {
return this.progress.markReadStatus(user.id, Number(bookId), body);
}
}

View File

@ -0,0 +1,158 @@
import Database from "better-sqlite3";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { DatabaseService } from "../database/database.service.js";
import { books, libraries, users } from "../database/schema.js";
import { ProgressService } from "./progress.service.js";
const previousDatabasePath = process.env.DATABASE_PATH;
const previousStorageDir = process.env.STORAGE_DIR;
const tempDirs: string[] = [];
afterEach(() => {
process.env.DATABASE_PATH = previousDatabasePath;
process.env.STORAGE_DIR = previousStorageDir;
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("ProgressService", () => {
it.runIf(canLoadBetterSqlite())("marks a book read automatically when progress reaches the completion threshold", () => {
const { database, service, userId, bookId } = createService();
try {
const result = service.upsert(userId, bookId, { locator: "epubcfi(/6/2)", percent: 95 });
expect(result).toMatchObject({
bookId,
locator: "epubcfi(/6/2)",
percent: 95,
readStatus: "read",
readStatusSource: "progress"
});
expect(result.completedAt).toEqual(expect.any(String));
expect(service.continueReading(userId)).toHaveLength(0);
} finally {
database.onModuleDestroy();
}
});
it.runIf(canLoadBetterSqlite())("keeps reading progress below the completion threshold in continue reading", () => {
const { database, service, userId, bookId } = createService();
try {
service.upsert(userId, bookId, { locator: "epubcfi(/6/2)", percent: 42 });
expect(service.get(userId, bookId)).toMatchObject({
percent: 42,
readStatus: "reading",
completedAt: null,
readStatusSource: "progress"
});
expect(service.continueReading(userId)).toHaveLength(1);
} finally {
database.onModuleDestroy();
}
});
it.runIf(canLoadBetterSqlite())("allows manual read and unread status changes", () => {
const { database, service, userId, bookId } = createService();
try {
const read = service.markReadStatus(userId, bookId, { status: "read" });
expect(read).toMatchObject({
locator: "manual:read",
percent: 100,
readStatus: "read",
readStatusSource: "manual"
});
expect(read.completedAt).toEqual(expect.any(String));
const unread = service.markReadStatus(userId, bookId, { status: "unread" });
expect(unread).toMatchObject({
locator: "manual:unread",
percent: 0,
readStatus: "unread",
completedAt: null,
readStatusSource: "manual"
});
expect(service.continueReading(userId)).toHaveLength(0);
} finally {
database.onModuleDestroy();
}
});
});
function createService(): { database: DatabaseService; service: ProgressService; userId: number; bookId: number } {
const database = createDatabase();
const service = new ProgressService(database);
const now = database.now();
const user = database.db
.insert(users)
.values({
email: "reader@example.com",
name: "Reader",
passwordHash: "hash",
role: "user",
createdAt: now,
updatedAt: now
})
.returning()
.get();
const library = database.db
.insert(libraries)
.values({ name: "Corpus", path: "/library", enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const book = database.db
.insert(books)
.values({
libraryId: library.id,
seriesId: null,
title: "Daredevil",
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: null,
localMetadataJson: null,
language: null,
publisher: null,
publishedDate: null,
volumeNumber: null,
volumeLabel: null,
format: "cbz",
filePath: "/library/Daredevil.cbz",
coverPath: null,
metadataStatus: "none",
metadataProvenanceJson: null,
scanStatus: "succeeded",
enrichmentStatus: "idle",
fileSize: 42,
fileMtime: now,
createdAt: now,
updatedAt: now
})
.returning()
.get();
return { database, service, userId: user.id, bookId: book.id };
}
function createDatabase(): DatabaseService {
const dir = mkdtempSync(join(tmpdir(), "readabook-progress-service-"));
tempDirs.push(dir);
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
process.env.STORAGE_DIR = join(dir, "storage");
return new DatabaseService();
}
function canLoadBetterSqlite(): boolean {
try {
new Database(":memory:").close();
return true;
} catch {
return false;
}
}

View File

@ -1,31 +1,63 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { desc, eq, sql } from "drizzle-orm";
import { UpdateProgressDto } from "@readabook/shared";
import { UpdateProgressDto, UpdateReadStatusDto } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js";
import { books, progress } from "../database/schema.js";
const READ_COMPLETION_THRESHOLD = 95;
@Injectable()
export class ProgressService {
constructor(private readonly database: DatabaseService) {}
upsert(userId: number, bookId: number, input: UpdateProgressDto) {
const book = this.database.db.select({ id: books.id }).from(books).where(eq(books.id, bookId)).get();
if (!book) {
throw new NotFoundException("Book not found");
}
this.assertBookExists(bookId);
const now = this.database.now();
const percent = Math.round(input.percent);
const current = this.find(userId, bookId);
const readStatus = statusFromProgressPercent(percent);
const completedAt = readStatus === "read" ? current?.completedAt ?? now : null;
this.database.sqlite
.prepare(
`
INSERT INTO progress(user_id, book_id, locator, percent, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO progress(user_id, book_id, locator, percent, read_status, completed_at, read_status_source, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'progress', ?, ?)
ON CONFLICT(user_id, book_id) DO UPDATE SET
locator = excluded.locator,
percent = excluded.percent,
read_status = excluded.read_status,
completed_at = excluded.completed_at,
read_status_source = excluded.read_status_source,
updated_at = excluded.updated_at
`
)
.run(userId, bookId, input.locator, Math.round(input.percent), now, now);
.run(userId, bookId, input.locator, percent, readStatus, completedAt, now, now);
return this.get(userId, bookId);
}
markReadStatus(userId: number, bookId: number, input: UpdateReadStatusDto) {
this.assertBookExists(bookId);
const now = this.database.now();
const current = this.find(userId, bookId);
const status = input.status;
const percent = status === "read" ? 100 : 0;
const locator = status === "read" ? current?.locator ?? "manual:read" : "manual:unread";
const completedAt = status === "read" ? current?.completedAt ?? now : null;
this.database.sqlite
.prepare(
`
INSERT INTO progress(user_id, book_id, locator, percent, read_status, completed_at, read_status_source, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'manual', ?, ?)
ON CONFLICT(user_id, book_id) DO UPDATE SET
locator = excluded.locator,
percent = excluded.percent,
read_status = excluded.read_status,
completed_at = excluded.completed_at,
read_status_source = excluded.read_status_source,
updated_at = excluded.updated_at
`
)
.run(userId, bookId, locator, percent, status, completedAt, now, now);
return this.get(userId, bookId);
}
@ -43,6 +75,9 @@ export class ProgressService {
bookId: progress.bookId,
locator: progress.locator,
percent: progress.percent,
readStatus: progress.readStatus,
completedAt: progress.completedAt,
readStatusSource: progress.readStatusSource,
updatedAt: progress.updatedAt
})
.from(progress)
@ -59,14 +94,30 @@ export class ProgressService {
bookId: progress.bookId,
locator: progress.locator,
percent: progress.percent,
readStatus: progress.readStatus,
completedAt: progress.completedAt,
readStatusSource: progress.readStatusSource,
updatedAt: progress.updatedAt
}
})
.from(progress)
.innerJoin(books, eq(progress.bookId, books.id))
.where(eq(progress.userId, userId))
.where(sql`${progress.userId} = ${userId} AND ${progress.readStatus} = 'reading'`)
.orderBy(desc(progress.updatedAt))
.limit(20)
.all();
}
private assertBookExists(bookId: number): void {
const book = this.database.db.select({ id: books.id }).from(books).where(eq(books.id, bookId)).get();
if (!book) {
throw new NotFoundException("Book not found");
}
}
}
function statusFromProgressPercent(percent: number): "unread" | "reading" | "read" {
if (percent >= READ_COMPLETION_THRESHOLD) return "read";
if (percent > 0) return "reading";
return "unread";
}

View File

@ -0,0 +1,75 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it } from "vitest";
import { ArchiveReadingSourceAdapter } from "./archive-reading-source.adapter.js";
const directories: string[] = [];
afterEach(() => {
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true });
});
describe("ArchiveReadingSourceAdapter", () => {
it("builds an ordered EPUB spine and serves only opaque resource ids", async () => {
const directory = mkdtempSync(join(tmpdir(), "readabook-reading-"));
directories.push(directory);
const filePath = join(directory, "book.epub");
const zip = new AdmZip();
zip.addFile("META-INF/container.xml", Buffer.from(`<?xml version="1.0"?><container><rootfiles><rootfile full-path="OPS/package.opf"/></rootfiles></container>`));
zip.addFile("OPS/package.opf", Buffer.from(`<package><manifest><item id="one" href="chapter-1.xhtml" media-type="application/xhtml+xml"/><item id="pic" href="images/cover.jpg" media-type="image/jpeg"/><item id="two" href="chapter-2.xhtml" media-type="application/xhtml+xml"/></manifest><spine><itemref idref="one"/><itemref idref="two"/></spine></package>`));
zip.addFile("OPS/chapter-1.xhtml", Buffer.from('<html><head><link href="styles/book.css" rel="stylesheet"/></head><body><img src="images/cover.jpg"/><p>First</p></body></html>'));
zip.addFile("OPS/chapter-2.xhtml", Buffer.from("<p>Second</p>"));
zip.addFile("OPS/images/cover.jpg", Buffer.from([1, 2, 3]));
zip.addFile("OPS/styles/book.css", Buffer.from("body { color: black; }"));
zip.writeZip(filePath);
const stat = await import("node:fs").then(({ statSync }) => statSync(filePath));
const source = new ArchiveReadingSourceAdapter();
const book = { id: 7, format: "epub", filePath, fileSize: stat.size, fileMtime: stat.mtime.toISOString() };
const manifest = await source.manifest(book);
expect(manifest.spine).toHaveLength(2);
expect(manifest).toMatchObject({
containerPath: "META-INF/container.xml",
opfPath: "OPS/package.opf",
basePath: "OPS/",
revision: expect.any(String)
});
expect(JSON.stringify(manifest)).not.toContain(filePath);
expect(JSON.stringify(manifest)).not.toContain(`/books/${book.id}/file`);
expect(manifest.resources).toHaveLength(6);
expect(manifest.resources.map((resource) => resource.resourceId)).not.toContain("OPS/chapter-1.xhtml");
expect(manifest.resources).toEqual(expect.arrayContaining([
expect.objectContaining({ href: "META-INF/container.xml", mediaType: "application/xml", size: expect.any(Number), url: expect.stringContaining("/reading-resources/") }),
expect.objectContaining({ href: "OPS/package.opf", mediaType: "application/oebps-package+xml" }),
expect.objectContaining({ href: "OPS/images/cover.jpg", mediaType: "image/jpeg", size: 3 })
]));
expect(manifest.spine[0]).toMatchObject({ index: 0, href: "OPS/chapter-1.xhtml", mediaType: "application/xhtml+xml", size: expect.any(Number) });
const firstResource = manifest.resources.find((resource) => resource.href === "OPS/chapter-1.xhtml");
const coverResource = manifest.resources.find((resource) => resource.href === "OPS/images/cover.jpg");
expect(firstResource?.references).toContain(coverResource?.resourceId);
const chapter = await source.resource(book, manifest.spine[0].resourceId);
expect(chapter.contentType).toBe("application/xhtml+xml");
expect(chapter.data?.toString()).toContain("<p>First</p>");
const container = manifest.resources.find((resource) => resource.href === manifest.containerPath);
expect((await source.resource(book, container!.resourceId)).data?.toString()).toContain("container");
});
it("exposes a PDF as a range-capable original without leaking its path in the manifest", async () => {
const directory = mkdtempSync(join(tmpdir(), "readabook-reading-"));
directories.push(directory);
const filePath = join(directory, "secret-name.pdf");
writeFileSync(filePath, "%PDF-test");
const source = new ArchiveReadingSourceAdapter();
const book = { id: 8, format: "pdf", filePath, fileSize: 9, fileMtime: "2026-01-01T00:00:00.000Z" };
const manifest = await source.manifest(book);
const resource = await source.resource(book, "original");
expect(JSON.stringify(manifest)).not.toContain(filePath);
expect(manifest).toMatchObject({ format: "pdf", spine: [expect.objectContaining({ resourceId: "original" })], capabilities: { byteRanges: true, lightweightPages: false } });
expect(resource).toMatchObject({ filePath, contentType: "application/pdf", supportsRanges: true });
});
});

View File

@ -0,0 +1,220 @@
import { createHash } from "node:crypto";
import { existsSync, statSync } from "node:fs";
import { extname, posix } from "node:path";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import AdmZip from "adm-zip";
import { XMLParser } from "fast-xml-parser";
import { lookup } from "mime-types";
import { ReadingSourcePort } from "../reading.ports.js";
import { ReadingBook, ReadingManifest, ReadingResource, ReadingResourcePayload, ReadingSpineItem } from "../reading.types.js";
const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" });
const MAX_EPUB_ENTRIES = 20_000;
const MAX_RESOURCE_BYTES = 64 * 1024 * 1024;
@Injectable()
export class ArchiveReadingSourceAdapter implements ReadingSourcePort {
private readonly epubManifests = new Map<number, { revision: string; manifest: ReadingManifest }>();
async manifest(book: ReadingBook): Promise<ReadingManifest> {
this.assertReadable(book);
if (book.format === "pdf") return pdfManifest(book);
if (book.format !== "epub") throw new BadRequestException("Adaptive reading supports EPUB and PDF books");
const revision = revisionFor(book);
const cached = this.epubManifests.get(book.id);
if (cached?.revision === revision) return cached.manifest;
const manifest = epubManifest(book);
this.epubManifests.set(book.id, { revision, manifest });
return manifest;
}
async resource(book: ReadingBook, resourceId: string): Promise<ReadingResourcePayload> {
this.assertReadable(book);
if (book.format === "pdf") {
if (resourceId !== "original") throw new NotFoundException("Reading resource not found");
const size = statSync(book.filePath).size;
return { filePath: book.filePath, contentType: "application/pdf", size, etag: etag(book, resourceId), supportsRanges: true };
}
if (book.format !== "epub") throw new BadRequestException("Adaptive reading supports EPUB and PDF books");
const manifest = await this.manifest(book);
const resource = manifest.resources.find((candidate) => candidate.resourceId === resourceId);
if (!resource) throw new NotFoundException("Reading resource not found");
const zip = new AdmZip(book.filePath);
const entry = zip.getEntry(resource.href);
if (!entry || entry.isDirectory) throw new NotFoundException("Reading resource not found");
if (entry.header.size > MAX_RESOURCE_BYTES) throw new BadRequestException("EPUB resource is too large to extract");
const data = entry.getData();
return { data, contentType: resource.mediaType, size: data.length, etag: etag(book, resourceId), supportsRanges: false };
}
private assertReadable(book: ReadingBook): void {
if (!existsSync(book.filePath)) throw new NotFoundException("Book file not found on disk");
}
}
function epubManifest(book: ReadingBook): ReadingManifest {
const zip = new AdmZip(book.filePath);
const entries = zip.getEntries();
if (entries.length > MAX_EPUB_ENTRIES) throw new BadRequestException("EPUB archive has too many entries");
const containerPath = "META-INF/container.xml";
const container = xml.parse(zip.readAsText(containerPath));
const rootfiles = array(container?.container?.rootfiles?.rootfile);
const opfPath = rootfiles[0]?.["@_full-path"];
if (typeof opfPath !== "string") throw new BadRequestException("EPUB package document is missing");
const normalizedOpf = normalizeArchivePath(opfPath);
const parsed = xml.parse(zip.readAsText(normalizedOpf));
const packageDocument = parsed?.package;
const directory = posix.dirname(normalizedOpf);
const basePath = directory === "." ? "" : `${directory}/`;
const items = array(packageDocument?.manifest?.item);
const spineItems = array(packageDocument?.spine?.itemref);
const declaredMediaTypes = new Map<string, string>();
const packageIds = new Map<string, string>();
for (const item of items) {
const packageId = stringAttribute(item, "@_id");
const href = stringAttribute(item, "@_href");
if (!href) continue;
const archivePath = normalizeArchivePath(posix.join(directory, decodeHref(href)));
const mediaType = stringAttribute(item, "@_media-type");
if (mediaType) declaredMediaTypes.set(archivePath, mediaType);
if (packageId) packageIds.set(packageId, archivePath);
}
const byPackageId = new Map<string, ReadingResource>();
const resources = entries.flatMap((entry): ReadingResource[] => {
if (entry.isDirectory) return [];
const archivePath = normalizeArchivePath(entry.entryName);
const mediaType = declaredMediaTypes.get(archivePath) ?? archiveMediaType(archivePath, normalizedOpf);
const resource: ReadingResource = {
resourceId: opaqueId(archivePath),
href: archivePath,
url: resourceUrl(book.id, opaqueId(archivePath)),
kind: resourceKind(mediaType),
mediaType,
size: entry.header.size,
state: "ready"
};
return [resource];
});
const byHref = new Map(resources.map((resource) => [resource.href, resource]));
for (const [packageId, archivePath] of packageIds) {
const resource = byHref.get(archivePath);
if (resource) byPackageId.set(packageId, resource);
}
addResourceReferences(zip, resources, byHref);
const spine = spineItems.flatMap((item, index): ReadingSpineItem[] => {
if (stringAttribute(item, "@_linear") === "no") return [];
const resource = byPackageId.get(stringAttribute(item, "@_idref") ?? "");
if (!resource) return [];
return [{ index, href: resource.href, resourceId: resource.resourceId, url: resource.url, mediaType: resource.mediaType, size: resource.size }];
});
const revision = revisionFor(book);
return {
bookId: book.id,
format: "epub",
revision,
version: revision,
containerPath,
opfPath: normalizedOpf,
basePath,
resources,
spine,
capabilities: { byteRanges: false, derivedImages: true, lightweightPages: false }
};
}
function pdfManifest(book: ReadingBook): ReadingManifest {
const revision = revisionFor(book);
return {
bookId: book.id,
format: "pdf",
revision,
version: revision,
containerPath: null,
opfPath: null,
basePath: null,
resources: [{ resourceId: "original", href: "original", url: resourceUrl(book.id, "original"), kind: "document", mediaType: "application/pdf", size: book.fileSize, state: "ready" }],
spine: [{ index: 0, href: "original", resourceId: "original", url: resourceUrl(book.id, "original"), mediaType: "application/pdf", size: book.fileSize }],
capabilities: { byteRanges: true, derivedImages: false, lightweightPages: false }
};
}
function normalizeArchivePath(path: string): string {
const normalized = posix.normalize(path.replace(/^\/+/, ""));
if (normalized === ".." || normalized.startsWith("../")) throw new BadRequestException("Invalid EPUB resource path");
return normalized;
}
function decodeHref(href: string): string {
try {
return decodeURIComponent(href.split("#", 1)[0]);
} catch {
throw new BadRequestException("Invalid EPUB resource path");
}
}
function opaqueId(path: string): string {
return createHash("sha256").update(path).digest("base64url").slice(0, 22);
}
function revisionFor(book: ReadingBook): string {
return createHash("sha256").update(`${book.fileSize}:${book.fileMtime}`).digest("base64url");
}
function resourceUrl(bookId: number, resourceId: string): string {
return `/books/${bookId}/reading-resources/${encodeURIComponent(resourceId)}`;
}
function archiveMediaType(path: string, opfPath: string): string {
if (path === "META-INF/container.xml") return "application/xml";
if (path === opfPath) return "application/oebps-package+xml";
if (path === "mimetype") return "text/plain";
return String(lookup(extname(path)) || "application/octet-stream");
}
function addResourceReferences(zip: AdmZip, resources: ReadingResource[], byHref: Map<string, ReadingResource>): void {
for (const resource of resources) {
if ((resource.kind !== "chapter" && resource.kind !== "stylesheet") || resource.size > 2 * 1024 * 1024) continue;
const entry = zip.getEntry(resource.href);
if (!entry) continue;
const text = entry.getData().toString("utf8");
const candidates = resource.kind === "stylesheet"
? [...text.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/gi)].map((match) => match[1])
: [...text.matchAll(/(?:src|href)\s*=\s*["']([^"']+)["']/gi)].map((match) => match[1]);
const references = candidates.flatMap((candidate): string[] => {
const href = resolveReference(resource.href, candidate);
const target = href ? byHref.get(href) : undefined;
return target && target.kind !== "chapter" ? [target.resourceId] : [];
});
resource.references = [...new Set(references)];
}
}
function resolveReference(parent: string, reference: string): string | null {
const clean = reference.trim();
if (!clean || clean.startsWith("#") || /^(?:data|https?|mailto|tel):/i.test(clean) || clean.startsWith("//")) return null;
try {
return normalizeArchivePath(posix.join(posix.dirname(parent), decodeHref(clean)));
} catch {
return null;
}
}
function etag(book: ReadingBook, resourceId: string): string {
return `\"${createHash("sha256").update(`${book.id}:${book.fileSize}:${book.fileMtime}:${resourceId}`).digest("base64url")}\"`;
}
function array(value: any): any[] {
return Array.isArray(value) ? value : value ? [value] : [];
}
function stringAttribute(value: any, key: string): string | null {
return typeof value?.[key] === "string" ? value[key] : null;
}
function resourceKind(mediaType: string): ReadingResource["kind"] {
if (mediaType.startsWith("image/")) return "image";
if (mediaType === "text/css") return "stylesheet";
if (mediaType.includes("font") || mediaType.includes("woff")) return "font";
if (mediaType.includes("html") || mediaType.includes("xml")) return "chapter";
return "document";
}

View File

@ -0,0 +1,37 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Injectable } from "@nestjs/common";
import { DatabaseService } from "../../database/database.service.js";
import { ReadingCachePort } from "../reading.ports.js";
import { ReadingBook } from "../reading.types.js";
@Injectable()
export class FilesystemReadingCacheAdapter implements ReadingCachePort {
constructor(private readonly database: DatabaseService) {}
async get(book: ReadingBook, resourceId: string): Promise<Buffer | null> {
try {
return await readFile(this.path(book, resourceId));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
async put(book: ReadingBook, resourceId: string, data: Buffer): Promise<void> {
const path = this.path(book, resourceId);
await mkdir(join(path, ".."), { recursive: true });
await writeFile(path, data);
}
async remove(book: ReadingBook, resourceId: string): Promise<void> {
await rm(this.path(book, resourceId), { force: true });
}
private path(book: ReadingBook, resourceId: string): string {
const version = createHash("sha256").update(`${book.fileSize}:${book.fileMtime}`).digest("hex").slice(0, 16);
const resource = createHash("sha256").update(resourceId).digest("hex");
return join(this.database.config.storageDir, "reading-cache", String(book.id), version, resource);
}
}

View File

@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import { ReadingController } from "./reading.controller.js";
describe("ReadingController", () => {
it("returns 304 for a matching ETag without a range request", async () => {
const controller = new ReadingController({
resource: vi.fn().mockResolvedValue(resourcePayload({ partial: false }))
} as any);
const reply = fakeReply();
await controller.resource("12", "chapter-1", undefined, '"etag"', reply as any);
expect(reply.statusCode).toBe(304);
expect(reply.sent).toBeUndefined();
expect(reply.headers).toEqual({});
});
it("sets cache, ETag and range headers for partial PDF resources", async () => {
const controller = new ReadingController({
resource: vi.fn().mockResolvedValue(resourcePayload({ partial: true, supportsRanges: true, start: 2, end: 5, size: 10, data: Buffer.from("2345") }))
} as any);
const reply = fakeReply();
await controller.resource("12", "original", "bytes=2-5", undefined, reply as any);
expect(reply.statusCode).toBe(206);
expect(reply.headers).toMatchObject({
"Accept-Ranges": "bytes",
"Cache-Control": "private, max-age=3600, must-revalidate",
"Content-Length": "4",
"Content-Range": "bytes 2-5/10",
"Content-Type": "application/pdf",
ETag: '"etag"',
"X-Content-Type-Options": "nosniff"
});
expect(reply.sent?.toString()).toBe("2345");
});
});
function resourcePayload(overrides: Record<string, unknown>) {
return {
data: Buffer.from("chapter"),
contentType: "application/pdf",
size: 7,
etag: '"etag"',
supportsRanges: false,
start: 0,
end: 6,
partial: false,
...overrides
};
}
function fakeReply() {
return {
statusCode: 200,
headers: {} as Record<string, string>,
sent: undefined as unknown,
code(status: number) {
this.statusCode = status;
return this;
},
header(name: string, value: string) {
this.headers[name] = value;
return this;
},
send(payload?: unknown) {
this.sent = payload;
return this;
}
};
}

View File

@ -0,0 +1,50 @@
import { Body, Controller, Get, Headers, Param, Post, Res, UseGuards } from "@nestjs/common";
import { FastifyReply } from "fastify";
import { AuthGuard } from "../auth/auth.guard.js";
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
import { ReadingWindowDto, ReadingWindowSchema } from "./reading.dto.js";
import { ReadingService } from "./reading.service.js";
@Controller("books/:bookId")
@UseGuards(AuthGuard)
export class ReadingController {
constructor(private readonly reading: ReadingService) {}
@Get("reading-manifest")
manifest(@Param("bookId") bookId: string) {
return this.reading.manifest(Number(bookId));
}
@Get("reading-resources/:resourceId")
async resource(
@Param("bookId") bookId: string,
@Param("resourceId") resourceId: string,
@Headers("range") range: string | undefined,
@Headers("if-none-match") ifNoneMatch: string | undefined,
@Res() reply: FastifyReply
) {
const resource = await this.reading.resource(Number(bookId), resourceId, range);
if (!range && ifNoneMatch === resource.etag) return reply.code(304).send();
if (resource.partial) {
reply.code(206);
reply.header("Content-Range", `bytes ${resource.start}-${resource.end}/${resource.size}`);
}
if (resource.supportsRanges) reply.header("Accept-Ranges", "bytes");
reply.header("Content-Length", String(resource.end - resource.start + 1));
reply.header("Content-Type", resource.contentType);
reply.header("ETag", resource.etag);
reply.header("Cache-Control", "private, max-age=3600, must-revalidate");
reply.header("X-Content-Type-Options", "nosniff");
return reply.send(resource.stream ?? resource.data);
}
@Post("reading-window")
window(@Param("bookId") bookId: string, @Body(new ZodValidationPipe(ReadingWindowSchema)) input: ReadingWindowDto) {
return this.reading.window(Number(bookId), input);
}
@Post("reading-resources/:resourceId/retry")
retry(@Param("bookId") bookId: string, @Param("resourceId") resourceId: string) {
return this.reading.retry(Number(bookId), resourceId);
}
}

View File

@ -0,0 +1,9 @@
import { z } from "zod";
export const ReadingWindowSchema = z.object({
locator: z.string().trim().min(1).max(512),
mode: z.enum(["auto", "speed", "quality"]).default("auto"),
networkHint: z.enum(["offline", "slow", "normal", "fast", "unknown"]).default("unknown")
});
export type ReadingWindowDto = z.infer<typeof ReadingWindowSchema>;

View File

@ -0,0 +1,20 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { DatabaseModule } from "../database/database.module.js";
import { JobsModule } from "../jobs/jobs.module.js";
import { ArchiveReadingSourceAdapter } from "./adapters/archive-reading-source.adapter.js";
import { FilesystemReadingCacheAdapter } from "./adapters/filesystem-reading-cache.adapter.js";
import { ReadingController } from "./reading.controller.js";
import { ReadingCachePort, ReadingSourcePort } from "./reading.ports.js";
import { ReadingService } from "./reading.service.js";
@Module({
imports: [AuthModule, DatabaseModule, JobsModule],
controllers: [ReadingController],
providers: [
ReadingService,
{ provide: ReadingSourcePort, useClass: ArchiveReadingSourceAdapter },
{ provide: ReadingCachePort, useClass: FilesystemReadingCacheAdapter }
]
})
export class ReadingModule {}

View File

@ -0,0 +1,12 @@
import { ReadingBook, ReadingManifest, ReadingResourcePayload } from "./reading.types.js";
export abstract class ReadingSourcePort {
abstract manifest(book: ReadingBook): Promise<ReadingManifest>;
abstract resource(book: ReadingBook, resourceId: string): Promise<ReadingResourcePayload>;
}
export abstract class ReadingCachePort {
abstract get(book: ReadingBook, resourceId: string): Promise<Buffer | null>;
abstract put(book: ReadingBook, resourceId: string, data: Buffer): Promise<void>;
abstract remove(book: ReadingBook, resourceId: string): Promise<void>;
}

View File

@ -0,0 +1,169 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { DatabaseService } from "../database/database.service.js";
import { books, jobs, libraries } from "../database/schema.js";
import { JobsService } from "../jobs/jobs.service.js";
import { ReadingCachePort, ReadingSourcePort } from "./reading.ports.js";
import { ReadingService } from "./reading.service.js";
import { ReadingBook, ReadingManifest, ReadingResourcePayload } from "./reading.types.js";
const previousDatabasePath = process.env.DATABASE_PATH;
const previousStorageDir = process.env.STORAGE_DIR;
const directories: string[] = [];
afterEach(() => {
process.env.DATABASE_PATH = previousDatabasePath;
process.env.STORAGE_DIR = previousStorageDir;
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true });
});
describe("ReadingService", () => {
it.runIf(canLoadBetterSqlite())("rejects books in disabled libraries", async () => {
const { database, bookId } = fixture(false);
const service = new ReadingService(database, new JobsService(database), new FakeSource(), new MemoryCache());
await expect(service.manifest(bookId)).rejects.toThrow("accessible library");
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("reports a priority window around the current spine item without preparing while offline", async () => {
const { database, bookId } = fixture(true);
const service = new ReadingService(database, new JobsService(database), new FakeSource(), new MemoryCache());
const window = await service.window(bookId, { locator: "chapter-2", mode: "auto", networkHint: "offline" });
expect(window.current).toMatchObject({ resourceId: "chapter-2", relation: "current" });
expect(window.previous).toMatchObject({ resourceId: "chapter-1", relation: "previous" });
expect(window.next).toMatchObject({ resourceId: "chapter-3", relation: "next" });
expect(window.resources).toEqual([expect.objectContaining({ resourceId: "chapter-image", relation: "resource" })]);
expect(window.neighbors).toEqual([]);
expect(database.db.select().from((await import("../database/schema.js")).jobs).all()).toHaveLength(0);
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("queues the current resource before previous and next resources on slow connections", async () => {
const { database, bookId } = fixture(true);
const service = new ReadingService(database, new JobsService(database), new FakeSource(), new MemoryCache());
const window = await service.window(bookId, { locator: "chapter-2", mode: "speed", networkHint: "slow" });
expect(window.current).toMatchObject({ resourceId: "chapter-2", relation: "current", state: "pending" });
expect(window.previous).toMatchObject({ resourceId: "chapter-1", relation: "previous", state: "pending" });
expect(window.next).toMatchObject({ resourceId: "chapter-3", relation: "next", state: "pending" });
const [job] = database.db.select().from(jobs).all();
expect(job).toMatchObject({ type: "prepare-reading-window", status: "queued" });
expect(JSON.parse(job.detail ?? "{}")).toEqual({ bookId, resourceIds: ["chapter-2", "chapter-image", "chapter-1", "chapter-3"] });
await flushImmediates();
database.onModuleDestroy();
});
it.runIf(canLoadBetterSqlite())("serves byte ranges for the original PDF resource", async () => {
const { database, bookId, directory } = fixture(true, "pdf");
const filePath = join(directory, "book.pdf");
writeFileSync(filePath, Buffer.from("0123456789"));
database.db.update(books).set({ filePath, fileSize: 10 }).where(eq(books.id, bookId)).run();
const service = new ReadingService(database, new JobsService(database), new FakeSource(), new MemoryCache());
const resource = await service.resource(bookId, "original", "bytes=2-5");
expect(resource).toMatchObject({
contentType: "application/pdf",
etag: '"pdf-etag"',
supportsRanges: true,
size: 10,
start: 2,
end: 5,
partial: true
});
await expect(readStream(resource.stream)).resolves.toEqual(Buffer.from("2345"));
database.onModuleDestroy();
});
});
class FakeSource extends ReadingSourcePort {
async manifest(book: ReadingBook): Promise<ReadingManifest> {
if (book.format === "pdf") {
return {
bookId: book.id,
format: "pdf",
version: "pdf-version",
revision: "pdf-version",
containerPath: null, opfPath: null, basePath: null,
resources: [{ resourceId: "original", href: "original", url: "/original", kind: "document", mediaType: "application/pdf", size: book.fileSize, state: "ready" }],
spine: [{ index: 0, href: "original", resourceId: "original", url: "/original", mediaType: "application/pdf", size: book.fileSize }],
capabilities: { byteRanges: true, derivedImages: false, lightweightPages: false }
};
}
return {
bookId: book.id,
format: "epub",
revision: "version",
version: "version",
containerPath: "META-INF/container.xml", opfPath: "OPS/package.opf", basePath: "OPS/",
resources: [
...["chapter-1", "chapter-2", "chapter-3"].map((id) => ({ resourceId: id, href: id, url: `/${id}`, kind: "chapter" as const, mediaType: "application/xhtml+xml", size: 7, state: "ready" as const, ...(id === "chapter-2" ? { references: ["chapter-image"] } : {}) })),
{ resourceId: "chapter-image", href: "image.jpg", url: "/chapter-image", kind: "image" as const, mediaType: "image/jpeg", size: 5, state: "ready" as const }
],
spine: ["chapter-1", "chapter-2", "chapter-3"].map((id, index) => ({ index, href: id, resourceId: id, url: `/${id}`, mediaType: "application/xhtml+xml", size: 7 })),
capabilities: { byteRanges: false, derivedImages: true, lightweightPages: false }
};
}
async resource(book: ReadingBook, resourceId: string): Promise<ReadingResourcePayload> {
if (book.format === "pdf" && resourceId === "original") {
return { filePath: book.filePath, contentType: "application/pdf", size: book.fileSize, etag: '"pdf-etag"', supportsRanges: true };
}
return { data: Buffer.from("chapter"), contentType: "application/xhtml+xml", size: 7, etag: '"etag"', supportsRanges: false };
}
}
class MemoryCache extends ReadingCachePort {
private readonly values = new Map<string, Buffer>();
async get(_book: ReadingBook, resourceId: string) { return this.values.get(resourceId) ?? null; }
async put(_book: ReadingBook, resourceId: string, data: Buffer) { this.values.set(resourceId, data); }
async remove(_book: ReadingBook, resourceId: string) { this.values.delete(resourceId); }
}
function fixture(enabled: boolean, format: "epub" | "pdf" = "epub") {
const directory = mkdtempSync(join(tmpdir(), "readabook-reading-service-"));
directories.push(directory);
process.env.DATABASE_PATH = join(directory, "test.sqlite");
process.env.STORAGE_DIR = join(directory, "storage");
const database = new DatabaseService();
const now = database.now();
const library = database.db.insert(libraries).values({ name: "Library", path: directory, enabled, createdAt: now, updatedAt: now }).returning().get();
const book = database.db.insert(books).values({
libraryId: library.id, seriesId: null, title: "Book", author: null, description: null, isbn: null, isbn13: null,
identifiersJson: null, localMetadataJson: null, language: null, publisher: null, publishedDate: null, volumeNumber: null,
volumeLabel: null, format, filePath: join(directory, `book.${format}`), coverPath: null, metadataStatus: "none",
metadataProvenanceJson: null, scanStatus: "succeeded", enrichmentStatus: "idle", fileSize: 10, fileMtime: now,
createdAt: now, updatedAt: now
}).returning().get();
return { database, bookId: book.id, directory };
}
function canLoadBetterSqlite(): boolean {
try {
return Boolean(DatabaseService);
} catch {
return false;
}
}
function flushImmediates() {
return new Promise<void>((resolve) => setImmediate(resolve));
}
function readStream(stream: NodeJS.ReadableStream | undefined) {
return new Promise<Buffer>((resolve, reject) => {
if (!stream) {
resolve(Buffer.alloc(0));
return;
}
const chunks: Buffer[] = [];
stream.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
stream.on("error", reject);
stream.on("end", () => resolve(Buffer.concat(chunks)));
});
}

View File

@ -0,0 +1,168 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { BadRequestException, HttpException, HttpStatus, Inject, Injectable, NotFoundException } from "@nestjs/common";
import { and, eq } from "drizzle-orm";
import { DatabaseService } from "../database/database.service.js";
import { books, libraries } from "../database/schema.js";
import { JobsService } from "../jobs/jobs.service.js";
import { ReadingCachePort, ReadingSourcePort } from "./reading.ports.js";
import { ReadingBook, ReadingResourcePayload, ReadingWindowInput } from "./reading.types.js";
@Injectable()
export class ReadingService {
private readonly preparing = new Set<string>();
constructor(
private readonly database: DatabaseService,
private readonly jobs: JobsService,
@Inject(ReadingSourcePort) private readonly source: ReadingSourcePort,
@Inject(ReadingCachePort) private readonly cache: ReadingCachePort
) {}
async manifest(bookId: number) {
return this.source.manifest(this.accessibleBook(bookId));
}
async resource(bookId: number, resourceId: string, range?: string): Promise<ReadingResourcePayload & { stream?: NodeJS.ReadableStream; start: number; end: number; partial: boolean }> {
const book = this.accessibleBook(bookId);
const cached = book.format === "epub" ? await this.cache.get(book, resourceId) : null;
const manifestResource = cached ? (await this.source.manifest(book)).resources.find((resource) => resource.resourceId === resourceId) : null;
if (cached && !manifestResource) throw new NotFoundException("Reading resource not found");
const payload = cached && manifestResource
? { data: cached, contentType: manifestResource.mediaType, size: cached.length, etag: manifestVersionEtag(book, resourceId), supportsRanges: false }
: await this.source.resource(book, resourceId);
const byteRange = parseByteRange(range, payload.size, payload.supportsRanges);
if (payload.filePath) {
return { ...payload, stream: createReadStream(payload.filePath, { start: byteRange.start, end: byteRange.end }), ...byteRange };
}
const data = payload.data;
if (!data) throw new NotFoundException("Reading resource not found");
if (!cached) await this.cache.put(book, resourceId, data);
return { ...payload, data: data.subarray(byteRange.start, byteRange.end + 1), ...byteRange };
}
async window(bookId: number, input: ReadingWindowInput) {
const book = this.accessibleBook(bookId);
const manifest = await this.source.manifest(book);
const position = resolvePosition(input.locator, manifest.spine);
const current = manifest.spine[position];
const referenced = referencedResources(current.resourceId, manifest.resources);
const ordered = priorityWindow(manifest.spine, position, referenced, input.networkHint === "slow" || input.mode === "speed" ? 1 : 2);
const states = await Promise.all(ordered.map(async ({ id, distance }) => ({
resourceId: id,
relation: distance === 0 ? "current" : distance === null ? "resource" : distance === -1 ? "previous" : distance === 1 ? "next" : "neighbor",
state: await this.resourceState(book, id)
})));
if (input.networkHint !== "offline") this.enqueuePreparation(book, ordered.map((entry) => entry.id));
return {
bookId,
locator: input.locator,
mode: input.mode,
networkHint: input.networkHint,
current: states.find((state) => state.relation === "current") ?? null,
previous: states.find((state) => state.relation === "previous") ?? null,
next: states.find((state) => state.relation === "next") ?? null,
resources: states.filter((state) => state.relation === "resource"),
neighbors: states.filter((state) => state.relation === "neighbor")
};
}
async retry(bookId: number, resourceId: string) {
const book = this.accessibleBook(bookId);
const manifest = await this.source.manifest(book);
if (!manifest.resources.some((resource) => resource.resourceId === resourceId)) throw new NotFoundException("Reading resource not found");
await this.cache.remove(book, resourceId);
this.enqueuePreparation(book, [resourceId], true);
return { bookId, resourceId, state: "pending" as const };
}
private accessibleBook(bookId: number): ReadingBook {
if (!Number.isInteger(bookId) || bookId < 1) throw new BadRequestException("Invalid book id");
const row = this.database.db
.select({ id: books.id, format: books.format, filePath: books.filePath, fileSize: books.fileSize, fileMtime: books.fileMtime })
.from(books)
.innerJoin(libraries, and(eq(libraries.id, books.libraryId), eq(libraries.enabled, true)))
.where(eq(books.id, bookId))
.get();
if (!row) throw new NotFoundException("Book not found in an accessible library");
return row;
}
private async resourceState(book: ReadingBook, resourceId: string) {
if (book.format === "pdf" && resourceId === "original") return "ready" as const;
return (await this.cache.get(book, resourceId)) ? "ready" as const : "pending" as const;
}
private enqueuePreparation(book: ReadingBook, resourceIds: string[], force = false): void {
const pending = resourceIds.filter((id) => force || !this.preparing.has(`${book.id}:${id}`));
if (!pending.length) return;
const job = this.jobs.create("prepare-reading-window", JSON.stringify({ bookId: book.id, resourceIds: pending }));
for (const id of pending) this.preparing.add(`${book.id}:${id}`);
setImmediate(async () => {
this.jobs.markRunning(job.id, JSON.stringify({ bookId: book.id, resourceIds: pending }));
try {
for (const resourceId of pending) {
const payload = await this.source.resource(book, resourceId);
if (payload.data) await this.cache.put(book, resourceId, payload.data);
}
this.jobs.markSucceeded(job.id, JSON.stringify({ bookId: book.id, prepared: pending }));
} catch (error) {
this.jobs.markFailed(job.id, error);
} finally {
for (const id of pending) this.preparing.delete(`${book.id}:${id}`);
}
});
}
}
function manifestVersionEtag(book: ReadingBook, resourceId: string): string {
return `\"${createHash("sha256").update(`${book.id}:${book.fileSize}:${book.fileMtime}:${resourceId}`).digest("base64url")}\"`;
}
function resolvePosition(locator: string, spine: Array<{ index: number; href: string; resourceId: string }>): number {
if (!spine.length) throw new BadRequestException("Book has no readable spine");
const direct = spine.findIndex((item) => item.resourceId === locator || item.href === locator);
if (direct >= 0) return direct;
const numeric = Number(locator.match(/(?:^|:)(\d+)(?:$|:)/)?.[1]);
if (Number.isInteger(numeric) && numeric >= 0 && numeric < spine.length) return numeric;
throw new BadRequestException("Locator does not identify a readable resource");
}
function priorityWindow(spine: Array<{ resourceId: string }>, position: number, referenced: string[], radius: number) {
const result: Array<{ id: string; distance: number | null }> = [{ id: spine[position].resourceId, distance: 0 }];
for (const resourceId of referenced) result.push({ id: resourceId, distance: null });
for (let distance = 1; distance <= radius; distance += 1) {
if (position - distance >= 0) result.push({ id: spine[position - distance].resourceId, distance: -distance });
if (position + distance < spine.length) result.push({ id: spine[position + distance].resourceId, distance });
}
return result.filter((entry, index) => result.findIndex((candidate) => candidate.id === entry.id) === index);
}
function referencedResources(currentId: string, resources: Array<{ resourceId: string; references?: string[] }>): string[] {
const byId = new Map(resources.map((resource) => [resource.resourceId, resource]));
const result: string[] = [];
const queue = [...(byId.get(currentId)?.references ?? [])];
const seen = new Set([currentId]);
while (queue.length) {
const resourceId = queue.shift()!;
if (seen.has(resourceId)) continue;
seen.add(resourceId);
result.push(resourceId);
queue.push(...(byId.get(resourceId)?.references ?? []));
}
return result;
}
function parseByteRange(range: string | undefined, size: number, supported: boolean) {
if (!range) return { start: 0, end: Math.max(size - 1, 0), partial: false };
if (!supported || size <= 0) throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
const match = range.match(/^bytes=(\d*)-(\d*)$/);
if (!match) throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
const suffix = !match[1] && match[2] ? Number(match[2]) : null;
const start = suffix === null ? Number(match[1]) : Math.max(size - suffix, 0);
const end = suffix === null && match[2] ? Number(match[2]) : size - 1;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= size) {
throw new HttpException("Requested range not satisfiable", HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
}
return { start, end: Math.min(end, size - 1), partial: true };
}

View File

@ -0,0 +1,63 @@
export type ReadingFormat = "epub" | "pdf";
export type ReadingResourceKind = "chapter" | "image" | "stylesheet" | "font" | "document";
export type ReadingResourceState = "ready" | "pending" | "unsupported" | "failed";
export type ReadingResource = {
resourceId: string;
href: string;
url: string;
kind: ReadingResourceKind;
mediaType: string;
size: number;
state: ReadingResourceState;
references?: string[];
};
export type ReadingSpineItem = {
index: number;
href: string;
resourceId: string;
url: string;
mediaType: string;
size: number;
};
export type ReadingManifest = {
bookId: number;
format: ReadingFormat;
revision: string;
version: string;
containerPath: string | null;
opfPath: string | null;
basePath: string | null;
resources: ReadingResource[];
spine: ReadingSpineItem[];
capabilities: {
byteRanges: boolean;
derivedImages: boolean;
lightweightPages: boolean;
};
};
export type ReadingBook = {
id: number;
format: string;
filePath: string;
fileSize: number;
fileMtime: string;
};
export type ReadingResourcePayload = {
data?: Buffer;
filePath?: string;
contentType: string;
size: number;
etag: string;
supportsRanges: boolean;
};
export type ReadingWindowInput = {
locator: string;
mode: "auto" | "speed" | "quality";
networkHint: "offline" | "slow" | "normal" | "fast" | "unknown";
};

View File

@ -17,6 +17,15 @@ RUN pnpm --filter @readabook/shared build
RUN pnpm --filter @readabook/web build
FROM nginx:1.27-alpine AS runtime
ARG OCI_IMAGE_CREATED
ARG OCI_IMAGE_REVISION
ARG OCI_IMAGE_VERSION=latest
LABEL org.opencontainers.image.title="ReadaBook Web" \
org.opencontainers.image.description="ReadaBook PWA served by Nginx" \
org.opencontainers.image.source="https://gitea.anthonybouteiller.ovh/blomios/ReadaBook" \
org.opencontainers.image.created="${OCI_IMAGE_CREATED}" \
org.opencontainers.image.revision="${OCI_IMAGE_REVISION}" \
org.opencontainers.image.version="${OCI_IMAGE_VERSION}"
COPY apps/web/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80

View File

@ -59,6 +59,27 @@ describe("api fallback helpers", () => {
expect(headers.has("Content-Type")).toBe(false);
});
it("sends the adaptive reading window with the selected network policy", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ bookId: 12, locator: "chapter-2", current: null, previous: null, next: null, neighbors: [] }), {
status: 200,
headers: { "Content-Type": "application/json" }
})
);
vi.stubGlobal("fetch", fetchMock);
await api.readingWindow(12, "chapter-2", "speed", "slow");
expect(fetchMock.mock.calls[0][0]).toBe("/books/12/reading-window");
const init = fetchMock.mock.calls[0][1] as RequestInit;
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({ locator: "chapter-2", mode: "speed", networkHint: "slow" });
});
it("builds an encoded reading resource URL", () => {
expect(api.readingResourceUrl(12, "chapter/one")).toBe("/books/12/reading-resources/chapter%2Fone");
});
it("surfaces create library API errors without fallback", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "Library path does not exist" }), {

View File

@ -14,6 +14,7 @@ import type {
UpdateAccountDto,
UpdateMetadataSourcesConfigDto,
UpdateProgressDto,
UpdateReadStatusDto,
UserDto
} from "@readabook/shared";
import {
@ -26,7 +27,16 @@ import {
mockProgress,
mockUser
} from "./mockData";
import type { CbzPagesDto, ContinueItem, ReaderPreferencesDto, Session } from "./types";
import type {
CbzPagesDto,
ContinueItem,
ReaderPreferencesDto,
ReadingConnectionMode,
ReadingManifestDto,
ReadingNetworkHint,
ReadingWindowDto,
Session
} from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:";
@ -178,6 +188,21 @@ export const api = {
bookCoverUrl(id: number): string {
return `${API_BASE}/books/${id}/cover`;
},
async readingManifest(id: number): Promise<ReadingManifestDto> {
return request<ReadingManifestDto>(`/books/${id}/reading-manifest`);
},
readingResourceUrl(id: number, resourceId: string): string {
return `${API_BASE}/books/${id}/reading-resources/${encodeURIComponent(resourceId)}`;
},
async readingWindow(id: number, locator: string, mode: ReadingConnectionMode, networkHint: ReadingNetworkHint): Promise<ReadingWindowDto> {
return request<ReadingWindowDto>(`/books/${id}/reading-window`, {
method: "POST",
body: JSON.stringify({ locator, mode, networkHint })
});
},
async retryReadingResource(id: number, resourceId: string): Promise<void> {
await request(`/books/${id}/reading-resources/${encodeURIComponent(resourceId)}/retry`, { method: "POST" });
},
async cbzPages(id: number): Promise<CbzPagesDto> {
return request<CbzPagesDto>(`/books/${id}/pages`);
},
@ -198,7 +223,30 @@ export const api = {
return request<ProgressDto>(`/progress/${bookId}`, {
method: "PUT",
body: JSON.stringify(input),
fallback: { bookId, ...input, updatedAt: new Date().toISOString() }
fallback: {
bookId,
...input,
readStatus: input.percent >= 95 ? "read" : input.percent > 0 ? "reading" : "unread",
completedAt: input.percent >= 95 ? new Date().toISOString() : null,
readStatusSource: "progress",
updatedAt: new Date().toISOString()
}
});
},
async markReadStatus(bookId: number, input: UpdateReadStatusDto): Promise<ProgressDto> {
const updatedAt = new Date().toISOString();
return request<ProgressDto>(`/progress/${bookId}/status`, {
method: "PUT",
body: JSON.stringify(input),
fallback: {
bookId,
locator: input.status === "read" ? "manual:read" : "manual:unread",
percent: input.status === "read" ? 100 : 0,
readStatus: input.status,
completedAt: input.status === "read" ? updatedAt : null,
readStatusSource: "manual",
updatedAt
}
});
},
async readerPreferences(bookId: number): Promise<ReaderPreferencesDto> {

View File

@ -32,6 +32,63 @@ export type ReaderMode = "horizontal" | "vertical";
export type ReaderFit = "page" | "width";
export type ReadingConnectionMode = "auto" | "speed" | "quality";
export type ReadingNetworkHint = "offline" | "slow" | "normal" | "fast" | "unknown";
export type ReadingResourceDto = {
resourceId: string;
href: string;
url: string;
kind: "chapter" | "image" | "stylesheet" | "font" | "document";
mediaType: string;
size: number;
state: "ready" | "pending" | "unsupported" | "failed";
references?: string[];
};
export type ReadingSpineItemDto = {
index: number;
href: string;
resourceId: string;
url: string;
mediaType: string;
size: number;
};
export type ReadingManifestDto = {
bookId: number;
format: "epub" | "pdf";
revision: string;
version?: string;
containerPath: string | null;
opfPath: string | null;
basePath: string | null;
resources: ReadingResourceDto[];
spine: ReadingSpineItemDto[];
capabilities: {
byteRanges: boolean;
derivedImages: boolean;
lightweightPages: boolean;
};
};
export type ReadingWindowItemDto = {
resourceId: string;
relation: "current" | "previous" | "next" | "neighbor";
state: ReadingResourceDto["state"];
};
export type ReadingWindowDto = {
bookId: number;
locator: string;
mode: ReadingConnectionMode;
networkHint: ReadingNetworkHint;
current: ReadingWindowItemDto | null;
previous: ReadingWindowItemDto | null;
next: ReadingWindowItemDto | null;
neighbors: ReadingWindowItemDto[];
};
export type ReaderPreferencesDto = {
mode: ReaderMode;
fit?: ReaderFit;

View File

@ -3,7 +3,7 @@ import type { BookDto } from "@readabook/shared";
import { api } from "../api/client";
import { bookCardVolumeLabel, bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, displayPublishedDate } from "../book/metadata";
import { navigate } from "../router";
import { FormatPill } from "./ui";
import { FormatPill, ReadStatusBadge } from "./ui";
export function BookCard({ book, compact = false, coverLoading = false }: { book: BookDto; compact?: boolean; coverLoading?: boolean }) {
const metadataState = bookMetadataState(book);
@ -19,6 +19,7 @@ export function BookCard({ book, compact = false, coverLoading = false }: { book
<div className="book-card-body">
<div className="book-card-meta">
<FormatPill format={book.format} />
<ReadStatusBadge readStatus={book.readStatus} />
{volumeLabel && <span className="volume-pill">{volumeLabel}</span>}
<span className="book-card-language">{book.language ?? "langue inconnue"}</span>
</div>

View File

@ -1,4 +1,6 @@
import type { ReactNode } from "react";
import { CheckCircle2 } from "lucide-react";
import type { ReadStatus } from "@readabook/shared";
export function Panel({ children, className = "" }: { children: ReactNode; className?: string }) {
return <section className={`panel ${className}`}>{children}</section>;
@ -32,6 +34,16 @@ export function FormatPill({ format }: { format: "epub" | "pdf" | "cbz" | "cbr"
return <span className={`format-pill format-${format}`}>{format.toUpperCase()}</span>;
}
export function ReadStatusBadge({ readStatus }: { readStatus?: ReadStatus }) {
if (readStatus !== "read") return null;
return (
<span className="read-status-badge" aria-label="Livre lu">
<CheckCircle2 size={13} />
Lu
</span>
);
}
export function Meter({ value }: { value: number }) {
return (
<span className="meter" aria-label={`${Math.round(value)}%`}>

View File

@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("book missing error pages", () => {
it("does not render fallback books on the book detail page", () => {
const source = readFileSync(new URL("./BookPage.tsx", import.meta.url), "utf8");
expect(source).not.toContain("getApiFallback");
expect(source).toContain("setBook(null)");
expect(source).toContain("Livre introuvable");
expect(source).toContain("Ce livre n'existe pas dans le catalogue.");
});
it("does not open the reader with a fallback book", () => {
const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8");
expect(source).not.toContain("getApiFallback");
expect(source).toContain("setBook(null)");
expect(source).toContain("reader-missing-page");
expect(source).toContain("Livre introuvable");
expect(source).toContain("Ce livre n'existe pas dans le catalogue.");
});
});

View File

@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
import { BookOpen, CheckCircle2, LibraryBig, RotateCcw } from "lucide-react";
import type { BookDto, ProgressDto } from "@readabook/shared";
import { api, getApiFallback } from "../api/client";
import { api } from "../api/client";
import { cleanBookDescription } from "../book/description";
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata";
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel, ReadStatusBadge } from "../components/ui";
import { navigate } from "../router";
export function BookPage({ bookId }: { bookId: number }) {
@ -17,19 +17,43 @@ export function BookPage({ bookId }: { bookId: number }) {
setLoading(true);
setError(undefined);
try {
const [nextBook, nextProgress] = await Promise.all([api.book(bookId), api.progress(bookId)]);
const nextBook = await api.book(bookId);
setBook(nextBook);
setProgress(nextProgress);
} catch (loadError) {
const fallback = getApiFallback<BookDto>(loadError);
setBook(fallback ?? null);
try {
setProgress(await api.progress(bookId));
} catch {
setProgress(null);
}
} catch {
setBook(null);
setProgress(null);
setError(fallback ? "Fiche chargee en mode secours." : "Fiche livre indisponible.");
setError("Ce livre n'existe pas dans le catalogue.");
} finally {
setLoading(false);
}
}
async function markReadStatus(status: "read" | "unread") {
if (!book) return;
setError(undefined);
try {
const nextProgress = await api.markReadStatus(book.id, { status });
setProgress(nextProgress);
setBook((current) =>
current
? {
...current,
readStatus: nextProgress.readStatus,
completedAt: nextProgress.completedAt,
readStatusSource: nextProgress.readStatusSource
}
: current
);
} catch {
setError(status === "read" ? "Marquage comme lu impossible." : "Marquage comme non lu impossible.");
}
}
useEffect(() => {
let alive = true;
loadBook().finally(() => {
@ -43,18 +67,26 @@ export function BookPage({ bookId }: { bookId: number }) {
if (loading && !book) return <LoadingState />;
if (!book) {
return (
<Panel>
<EmptyState title="Fiche introuvable" detail={error ?? "Le catalogue n'a pas renvoye cet ouvrage."} />
<button className="ghost-button" onClick={() => void loadBook()}>
<RotateCcw size={17} />
Reessayer
</button>
</Panel>
<section className="book-error-page" role="alert">
<Panel>
<EmptyState title="Livre introuvable" detail={error ?? "Le serveur n'a pas renvoye cet ouvrage."} />
<div className="book-card-actions">
<button className="ghost-button" onClick={() => navigate("/home")}>
Retour à l'accueil
</button>
<button className="ghost-button" onClick={() => void loadBook()}>
<RotateCcw size={17} />
Reessayer
</button>
</div>
</Panel>
</section>
);
}
const series = bookSeriesInfo(book);
const metadataState = bookMetadataState(book);
const readStatus = progress?.readStatus ?? book.readStatus;
const publishedDate = displayPublishedDate(book.publishedDate);
const detailFacts = [
{ label: "Auteur", value: book.author },
@ -74,6 +106,7 @@ export function BookPage({ bookId }: { bookId: number }) {
<ErrorRibbon message={error} />
<div className="book-card-meta">
<FormatPill format={book.format} />
<ReadStatusBadge readStatus={readStatus} />
<span>{book.language ?? "langue inconnue"}</span>
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
</div>
@ -98,6 +131,10 @@ export function BookPage({ bookId }: { bookId: number }) {
<LibraryBig size={18} />
Rayon
</button>
<button className="ghost-button read-status-action" onClick={() => void markReadStatus(readStatus === "read" ? "unread" : "read")}>
{readStatus === "read" ? <RotateCcw size={18} /> : <CheckCircle2 size={18} />}
{readStatus === "read" ? "Marquer comme non lu" : "Marquer comme lu"}
</button>
{series && (
<button className="ghost-button" onClick={() => navigate(`/catalog/series/${encodeURIComponent(series.title)}`)}>
<LibraryBig size={18} />

View File

@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("book read status frontend wiring", () => {
it("uses the backend read status endpoint for manual book detail actions", () => {
const client = readFileSync(new URL("../api/client.ts", import.meta.url), "utf8");
const bookPage = readFileSync(new URL("./BookPage.tsx", import.meta.url), "utf8");
expect(client).toContain("markReadStatus");
expect(client).toContain("`/progress/${bookId}/status`");
expect(client).toContain("body: JSON.stringify(input)");
expect(bookPage).toContain('api.markReadStatus(book.id, { status })');
expect(bookPage).toContain("Marquer comme lu");
expect(bookPage).toContain("Marquer comme non lu");
expect(bookPage).toContain("setProgress(nextProgress)");
expect(bookPage).toContain("readStatus: nextProgress.readStatus");
});
it("renders read badges on catalogue cards, home cards and the book detail page", () => {
const ui = readFileSync(new URL("../components/ui.tsx", import.meta.url), "utf8");
const bookCard = readFileSync(new URL("../components/BookCard.tsx", import.meta.url), "utf8");
const homePage = readFileSync(new URL("./HomePage.tsx", import.meta.url), "utf8");
const bookPage = readFileSync(new URL("./BookPage.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
expect(ui).toContain('readStatus !== "read"');
expect(ui).toContain('aria-label="Livre lu"');
expect(bookCard).toContain("<ReadStatusBadge readStatus={book.readStatus} />");
expect(homePage).toContain("<ReadStatusBadge readStatus={item.readStatus} />");
expect(bookPage).toContain("<ReadStatusBadge readStatus={readStatus} />");
expect(styles).toContain(".read-status-badge");
});
it("keeps continue reading delegated to the API-updated reading list", () => {
const homePage = readFileSync(new URL("./HomePage.tsx", import.meta.url), "utf8");
expect(homePage).toContain("api.continueReading()");
expect(homePage).not.toContain('readStatus !== "read"');
expect(homePage).not.toContain('readStatus === "read"');
});
});

View File

@ -1,22 +1,19 @@
import { useEffect, useState } from "react";
import { BookOpen, LibraryBig, ScanLine } from "lucide-react";
import { api } from "../api/client";
import { hasActiveCoverWork, isBookCoverUpdating, type DashboardData } from "../api/types";
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookVolumeLabel, displayPublishedDate } from "../book/metadata";
import { BookCard } from "../components/BookCard";
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
import type { DashboardData } from "../api/types";
import { bookDisplayTitle, bookVolumeLabel } from "../book/metadata";
import { EmptyState, LoadingState, Meter, Panel, ReadStatusBadge } from "../components/ui";
import { navigate } from "../router";
export function HomePage() {
const [state, setState] = useState<DashboardData | null>(null);
const [fallback, setFallback] = useState(false);
useEffect(() => {
let alive = true;
Promise.all([api.books(), api.continueReading(), api.libraries(), api.jobs()])
.then(([books, continueReading, libraries, jobs]) => {
if (!alive) return;
setFallback(books.some((book) => book.filePath.startsWith("/library/")) && jobs.length === 1);
setState({ books, continueReading, libraries, jobs });
})
.catch(() => {
@ -28,15 +25,34 @@ export function HomePage() {
}, []);
if (!state) return <LoadingState />;
const fallbackCoverLoading = hasActiveCoverWork(state.jobs);
const renderHomeBook = (
item: DashboardData["books"][number],
className = "home-book-card",
options: { href?: string; progressPercent?: number } = {}
) => {
const volumeLabel = bookVolumeLabel(item);
return (
<button key={item.id} className={className} onClick={() => navigate(options.href ?? `/book/${item.id}`)}>
<span className="home-book-cover" aria-hidden="true">
{item.coverPath ? <img src={api.bookCoverUrl(item.id)} alt="" /> : <BookOpen size={24} />}
</span>
<span className="home-book-copy">
{volumeLabel && <span className="home-book-volume">{volumeLabel}</span>}
<ReadStatusBadge readStatus={item.readStatus} />
<strong>{bookDisplayTitle(item)}</strong>
<span>{item.author ?? "Auteur inconnu"}</span>
{options.progressPercent !== undefined && <Meter value={options.progressPercent} />}
</span>
</button>
);
};
return (
<div className="page-grid">
<section className="hero-band">
<div className="page-grid home-page">
<section className="hero-band home-hero">
<div>
<p>Cabinet de curiosites numerique</p>
<h1>Ouvrir, classer, reprendre.</h1>
<span>{fallback ? "API absente ou incomplete : specimens de demonstration actifs." : "Catalogue branche sur le serveur local."}</span>
<h1>Reprendre la lecture.</h1>
</div>
<button className="primary-button" onClick={() => navigate("/search")}>
<ScanLine size={18} />
@ -46,30 +62,17 @@ export function HomePage() {
<Panel className="span-2">
<div className="section-heading">
<h2>Reprise de lecture</h2>
<span>{state.continueReading.length} traces</span>
<h2>Livres en cours</h2>
<span>{state.continueReading.length} lectures</span>
</div>
{state.continueReading.length ? (
<div className="continue-grid">
{state.continueReading.map((item) => {
const metadataState = bookMetadataState(item.book);
const publishedDate = displayPublishedDate(item.book.publishedDate);
const volumeLabel = bookVolumeLabel(item.book);
return (
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}>
<span className="continue-cover" aria-hidden="true">
{item.book.coverPath ? <img src={api.bookCoverUrl(item.book.id)} alt="" /> : <BookOpen size={22} />}
</span>
<span className="continue-copy">
<strong>{bookDisplayTitle(item.book)}</strong>
<span>{item.book.author ?? "Auteur inconnu"}</span>
{(volumeLabel || publishedDate) && <span>{[volumeLabel, publishedDate].filter(Boolean).join(" · ")}</span>}
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(item.book)}</span>
<Meter value={item.progress.percent} />
</span>
</button>
);
})}
{state.continueReading.map((item) =>
renderHomeBook(item.book, "home-book-card continue-tile", {
href: `/reader/${item.book.id}`,
progressPercent: item.progress.percent
})
)}
</div>
) : (
<EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." />
@ -91,10 +94,8 @@ export function HomePage() {
</div>
</Panel>
<section className="book-grid span-3">
{state.books.map((book) => (
<BookCard key={book.id} book={book} coverLoading={isBookCoverUpdating(book, fallbackCoverLoading)} />
))}
<section className="home-book-grid span-3">
{state.books.map((book) => renderHomeBook(book))}
</section>
</div>
);

View File

@ -2,17 +2,61 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("home progress list layout", () => {
it("keeps the continue reading list scrollable after six visible books", () => {
it("keeps the continue reading list scrollable after three visible books", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
const continueGrid = styles.match(/\.continue-grid\s*\{[^}]+\}/)?.[0] ?? "";
const continueTile = styles.match(/\.continue-tile\s*\{[^}]+\}/)?.[0] ?? "";
expect(continueGrid).toContain("--continue-visible-rows: 6");
expect(continueGrid).toContain("--continue-tile-block-size: 118px");
expect(continueGrid).toContain("max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))");
expect(continueGrid).toContain("overflow-y: auto");
expect(continueGrid).toContain("scrollbar-gutter: stable");
expect(styles).toContain(".continue-grid,\n.library-list {\n --continue-visible-rows: 3;");
expect(styles).toContain("--continue-tile-block-size: 112px");
expect(styles).toContain("max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))");
expect(styles).toContain("overflow-y: auto");
expect(styles).toContain("scrollbar-gutter: stable");
expect(continueTile).toContain("block-size: var(--continue-tile-block-size)");
expect(continueTile).toContain("overflow: hidden");
});
it("keeps home book cards scoped and simplified", () => {
const source = readFileSync(new URL("./HomePage.tsx", import.meta.url), "utf8");
expect(source).toContain("renderHomeBook");
expect(source).toContain("home-book-card");
expect(source).toContain("progressPercent");
expect(source).toContain("<Meter value={options.progressPercent} />");
expect(source).toContain("progressPercent: item.progress.percent");
expect(source).not.toContain("<BookCard");
expect(source).not.toContain("FormatPill");
expect(source).not.toContain("bookMetadataState");
expect(source).not.toContain("bookMetadataStateLabel");
expect(source).not.toContain("displayPublishedDate");
expect(source).not.toContain("book.description");
expect(source).not.toContain("book.language");
expect(source).not.toContain("API absente ou incomplete");
expect(source).not.toContain("specimens de demonstration actifs");
expect(source).not.toContain("Catalogue branche sur le serveur local");
expect(source).not.toContain("Bibliotheque personnelle");
});
it("keeps visible book grids capped to five columns", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
const bookGrid = styles.match(/\.book-grid\s*\{[^}]+\}/)?.[0] ?? "";
const homeBookGrid = styles.match(/\.home-book-grid\s*\{[^}]+\}/)?.[0] ?? "";
expect(bookGrid).toContain("grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr))");
expect(homeBookGrid).toContain("grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr))");
expect(bookGrid).toContain("width: 100%");
expect(homeBookGrid).toContain("width: 100%");
expect(bookGrid).not.toContain("max-width");
expect(homeBookGrid).not.toContain("max-width");
});
it("keeps the library list capped like continue reading", () => {
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
const libraryButton = styles.match(/\.library-list button\s*\{[^}]+\}/)?.[0] ?? "";
expect(styles).toContain(".continue-grid,\n.library-list {\n --continue-visible-rows: 3;");
expect(styles).toContain("height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))");
expect(styles).toContain("max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)))");
expect(libraryButton).toContain("block-size: var(--continue-tile-block-size)");
expect(libraryButton).toContain("overflow: hidden");
});
});

View File

@ -24,7 +24,7 @@ export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<
setEmail((current) => current || nextStatus.initialAdminEmail);
})
.catch(() => {
if (alive) setError("Statut d'authentification indisponible.");
// Auth status only enriches the unauthenticated screen; login must remain usable without it.
});
return () => {
alive = false;

View File

@ -0,0 +1,14 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("login page when auth status is unavailable", () => {
it("keeps the unauthenticated login screen usable without a blocking auth status error", () => {
const source = readFileSync(new URL("./LoginPage.tsx", import.meta.url), "utf8");
expect(source).toContain("<form onSubmit={submit} className=\"stack-form\">");
expect(source).toContain('type="email" required');
expect(source).toContain('type="password" required');
expect(source).toContain('type="submit"');
expect(source).not.toContain("Statut d'authentification indisponible");
});
});

View File

@ -42,14 +42,24 @@ export function ProfilePage({ session, onSessionChange }: { session: Session; on
return (
<div className="page-grid">
<Panel className="profile-panel">
<UserRound size={28} />
<h1>{session.user?.name ?? "Lecteur invite"}</h1>
<p>{session.user?.email ?? "Session non connectee"}</p>
<span>{session.user?.role ?? "vitrine"}</span>
<button className="ghost-button" onClick={logout}>
<LogOut size={17} />
Sortir
</button>
<div className="profile-identity">
<span className="profile-avatar" aria-hidden="true">
<UserRound size={28} />
</span>
<h1>{session.user?.name ?? "Lecteur invite"}</h1>
<p>{session.user?.email ?? "Session non connectee"}</p>
<span>{session.user?.role ?? "vitrine"}</span>
</div>
<div className="profile-actions">
<button className="ghost-button" onClick={() => document.getElementById("profile-security-form")?.scrollIntoView({ block: "start" })}>
<KeyRound size={17} />
Modifier
</button>
<button className="ghost-button" onClick={logout}>
<LogOut size={17} />
Sortir
</button>
</div>
</Panel>
<Panel className="span-2">
<div className="section-heading">
@ -59,7 +69,7 @@ export function ProfilePage({ session, onSessionChange }: { session: Session; on
<p className="muted-copy">Change l'email et le mot de passe admin initial des que le cabinet est installe.</p>
<ErrorRibbon message={error} />
{success && <div className="success-ribbon">{success}</div>}
<form className="stack-form" onSubmit={updateSecurity}>
<form id="profile-security-form" className="stack-form" onSubmit={updateSecurity}>
<label>
Email
<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" required />

View File

@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("profile page polish", () => {
it("focuses the left column on identity and immediate actions", () => {
const source = readFileSync(new URL("./ProfilePage.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");
expect(source).toContain("profile-identity");
expect(source).toContain("profile-avatar");
expect(source).toContain("profile-actions");
expect(source).toContain('id="profile-security-form"');
expect(source).toContain("scrollIntoView");
expect(styles).toContain(".profile-identity");
expect(styles).toContain(".profile-actions");
});
});

View File

@ -1,6 +1,6 @@
import { Component, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode } from "react";
import type { BookDto } from "@readabook/shared";
import { api, getApiFallback } from "../api/client";
import { api } from "../api/client";
import { CbzReader } from "../reader/CbzReader";
import { EpubReader } from "../reader/EpubReader";
import { pageLocator, parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
@ -10,7 +10,9 @@ import { clampReaderZoom, READER_ZOOM_DEFAULT, READER_ZOOM_STEP } from "../reade
import { majorityVisiblePage, type ReaderMode } from "../reader/readerScroll";
import { useReaderPreferences } from "../reader/useReaderPreferences";
import { useReaderProgress } from "../reader/useReaderProgress";
import { useAdaptiveReading } from "../reader/useAdaptiveReading";
import { navigate } from "../router";
import { EmptyState, LoadingState, Panel } from "../components/ui";
const idleControls: ReaderControls = {
canPrevious: false,
@ -68,16 +70,16 @@ export function ReaderPage({ bookId }: { bookId: number }) {
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
const { progress, error: progressError, save, queueSave } = useReaderProgress(bookId);
const { preferences, setMode: saveReaderMode, error: preferencesError } = useReaderPreferences(bookId);
const adaptive = useAdaptiveReading(bookId, book?.format === "epub" || book?.format === "pdf");
async function loadBook() {
setLoading(true);
setError(undefined);
try {
setBook(await api.book(bookId));
} catch (loadError) {
const fallback = getApiFallback<BookDto>(loadError);
setBook(fallback ?? null);
setError(fallback ? "Lecteur ouvert en mode secours." : "Ouvrage indisponible.");
} catch {
setBook(null);
setError("Ce livre n'existe pas dans le catalogue.");
} finally {
setLoading(false);
}
@ -104,7 +106,11 @@ export function ReaderPage({ bookId }: { bookId: number }) {
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
}, [progress]);
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
const legacyFileUrl = useMemo(() => (book?.format === "epub" ? "" : api.bookFileUrl(bookId)), [book?.format, bookId]);
const adaptiveFileUrl = useMemo(() => {
if (book?.format !== "pdf" || !adaptive.manifest) return legacyFileUrl;
return adaptive.manifest.spine[0]?.url ?? api.readingResourceUrl(bookId, "original");
}, [adaptive.manifest, book?.format, bookId, legacyFileUrl]);
const backHref = useMemo(() => (book ? `/book/${book.id}` : "/home"), [book]);
const savePdfPage = useCallback(
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
@ -113,10 +119,19 @@ export function ReaderPage({ bookId }: { bookId: number }) {
const percent = pdfPagePercent(nextPage, pages, anchor);
if (strategy === "queued") queueSave(locator, percent);
else void save(locator, percent);
void adaptive.prioritize(adaptive.manifest?.spine[0]?.resourceId ?? "original");
},
[queueSave, save]
[adaptive.manifest?.spine, adaptive.prioritize, queueSave, save]
);
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
const saveEpubLocator = useCallback((locator: string, percent: number, readingLocator: string) => {
void save(locator, percent);
void adaptive.prioritize(readingLocator);
}, [adaptive.prioritize, save]);
useEffect(() => {
const first = adaptive.manifest?.spine[0]?.resourceId;
if (first) void adaptive.prioritize(first);
}, [adaptive.manifest?.revision, adaptive.mode]);
const saveComicPage = useCallback(
(nextPage: number, pages: number, anchor = 1, strategy: "immediate" | "queued" = "immediate") => {
setPage(nextPage);
@ -129,7 +144,7 @@ export function ReaderPage({ bookId }: { bookId: number }) {
[book?.format, queueSave, save]
);
const readerError = error ?? progressError ?? preferencesError;
const readerError = error ?? progressError ?? preferencesError ?? adaptive.error;
const supportsZoom = book?.format === "pdf" || book?.format === "cbz" || book?.format === "cbr";
const supportsMode = supportsZoom;
const currentVisiblePage = useCallback(() => {
@ -206,15 +221,37 @@ export function ReaderPage({ bookId }: { bookId: number }) {
[changeMode, mode, supportsMode]
);
if (loading && !book) return <LoadingState />;
if (!book) {
return (
<section className="reader-missing-page" role="alert">
<Panel>
<EmptyState title="Livre introuvable" detail={error ?? "Le serveur n'a pas renvoye cet ouvrage."} />
<div className="reader-error-actions">
<button className="ghost-button" onClick={() => navigate("/home")}>
Retour à l'accueil
</button>
<button className="ghost-button" onClick={() => void loadBook()}>
Réessayer
</button>
</div>
</Panel>
</section>
);
}
return (
<ReaderShell
title={book?.title ?? "Ouverture du lecteur"}
backHref={backHref}
error={readerError}
onRetry={error ? () => void loadBook() : undefined}
controls={readerControls}
zoomControls={zoomControls}
modeControls={modeControls}
connectionControls={book.format === "epub" || book.format === "pdf" ? { mode: adaptive.mode, onModeChange: adaptive.setMode } : undefined}
activity={book.format === "epub" || book.format === "pdf" ? adaptive.statusLabel : undefined}
onRetry={error ? () => void loadBook() : adaptive.error ? adaptive.retry : undefined}
>
<ReaderCrashBoundary
resetKey={`${bookId}:${book?.format ?? "loading"}:${mode}`}
@ -251,16 +288,9 @@ export function ReaderPage({ bookId }: { bookId: number }) {
</div>
)}
>
{!book ? (
<div className="reader-fallback">
<span>{error ?? "Chargement du livre."}</span>
<button className="ghost-button" onClick={() => void loadBook()}>
Reessayer
</button>
</div>
) : book.format === "pdf" ? (
{book.format === "pdf" ? (
<PdfReader
url={fileUrl}
url={adaptiveFileUrl}
page={page}
backHref={backHref}
zoom={zoom}
@ -277,15 +307,17 @@ export function ReaderPage({ bookId }: { bookId: number }) {
onPageCommit={saveComicPage}
onControlsChange={setReaderControls}
/>
) : (
) : adaptive.manifest?.format === "epub" ? (
<EpubReader
url={fileUrl}
manifest={adaptive.manifest}
locator={progress?.locator}
backHref={backHref}
mode="horizontal"
onLocatorChange={saveEpubLocator}
onControlsChange={setReaderControls}
/>
) : (
<div className="reader-fallback"><span>Ouverture de la page…</span></div>
)}
</ReaderCrashBoundary>
</ReaderShell>

View File

@ -1,36 +1,60 @@
import { useEffect, useRef, useState } from "react";
import type { ReadingManifestDto, ReaderMode } from "../api/types";
import { ReaderError, readerErrorMessage } from "./ReaderError";
import type { ReaderControls } from "./ReaderShell";
import type { ReaderMode } from "../api/types";
type FoliateLocation = {
cfi?: string;
fraction?: number;
current?: number;
total?: number;
};
type FoliateLocation = { cfi?: string; fraction?: number; current?: number; total?: number };
type FoliateView = HTMLElement & {
open(input: File | Blob | string): Promise<void>;
open(input: unknown): Promise<void>;
close(): void;
goLeft(): Promise<void>;
goRight(): Promise<void>;
goTo(target: string): Promise<unknown>;
next(): Promise<void>;
lastLocation?: FoliateLocation;
};
type EpubApiLoader = {
loadText(path: string): Promise<string | null>;
loadBlob(path: string): Promise<Blob | null>;
getSize(path: string): number;
};
export function epubFileName(url: string): string {
function normalizedResourcePath(path: string): string {
const withoutFragment = path.split("#", 1)[0].split("?", 1)[0];
try {
const base = globalThis.location?.href ?? "http://readabook.local/";
const pathname = new URL(url, base).pathname;
const name = pathname.split("/").filter(Boolean).at(-1);
return name && name.includes(".") ? name : "book.epub";
return decodeURI(withoutFragment).replace(/^\.\//, "").replace(/^\/+/, "");
} catch {
return "book.epub";
return withoutFragment.replace(/^\.\//, "").replace(/^\/+/, "");
}
}
export function createEpubApiLoader(manifest: ReadingManifestDto, fetcher: typeof fetch = fetch): EpubApiLoader {
const resources = new Map(manifest.resources.map((resource) => [normalizedResourcePath(resource.href), resource]));
const responses = new Map<string, Promise<Blob>>();
const loadBlob = async (path: string) => {
const resource = resources.get(normalizedResourcePath(path));
if (!resource) return null;
let pending = responses.get(resource.resourceId);
if (!pending) {
pending = fetcher(resource.url, { credentials: "include", cache: "default" }).then(async (response) => {
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return response.blob();
});
responses.set(resource.resourceId, pending);
}
try {
return await pending;
} catch (error) {
responses.delete(resource.resourceId);
throw error;
}
};
return {
loadBlob,
loadText: async (path) => (await loadBlob(path))?.text() ?? null,
getSize: (path) => resources.get(normalizedResourcePath(path))?.size ?? 0
};
}
function locationPercent(location: FoliateLocation): number {
if (typeof location.fraction === "number") return Math.max(0, Math.min(100, location.fraction * 100));
if (typeof location.current === "number" && typeof location.total === "number" && location.total > 0) {
@ -39,24 +63,18 @@ function locationPercent(location: FoliateLocation): number {
return 1;
}
export function EpubReader({
url,
locator,
backHref,
mode,
onLocatorChange,
onControlsChange
}: {
url: string;
export function EpubReader({ manifest, locator, backHref, mode, onLocatorChange, onControlsChange }: {
manifest: ReadingManifestDto;
locator?: string;
backHref: string;
mode: ReaderMode;
onLocatorChange: (locator: string, percent: number) => void;
onLocatorChange: (locator: string, percent: number, readingLocator: string) => void;
onControlsChange: (controls: ReaderControls) => void;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<FoliateView | null>(null);
const locatorRef = useRef(locator);
const sectionIndexRef = useRef(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();
const [attempt, setAttempt] = useState(0);
@ -65,43 +83,39 @@ export function EpubReader({
onControlsChange({
canPrevious: !loading && !error,
canNext: !loading && !error,
positionLabel: loading ? "Ouverture EPUB" : "Lecture integree",
positionLabel: loading ? "Ouverture de la page…" : "Lecture intégrée",
onPrevious: () => void viewRef.current?.goLeft(),
onNext: () => void viewRef.current?.goRight()
});
}, [error, loading, onControlsChange]);
useEffect(() => {
locatorRef.current = locator;
}, [locator]);
useEffect(() => { locatorRef.current = locator; }, [locator]);
useEffect(() => {
let cancelled = false;
let view: FoliateView | null = null;
async function mount() {
try {
setLoading(true);
setError(undefined);
await import("foliate-js/view.js");
const [{ EPUB }] = await Promise.all([import("foliate-js/epub.js"), import("foliate-js/view.js")]);
if (cancelled || !hostRef.current) return;
const response = await fetch(url, { credentials: "include" });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
const blob = await response.blob();
const book = await new EPUB(createEpubApiLoader(manifest)).init();
if (cancelled || !hostRef.current) return;
view = document.createElement("foliate-view") as FoliateView;
view.classList.add("epub-view");
view.addEventListener("load", (event) => {
const index = (event as CustomEvent<{ index?: number }>).detail?.index;
if (typeof index === "number") sectionIndexRef.current = index;
});
view.addEventListener("relocate", (event) => {
const location = (event as CustomEvent<FoliateLocation>).detail;
if (location?.cfi) onLocatorChange(location.cfi, locationPercent(location));
const spine = manifest.spine[sectionIndexRef.current];
if (location?.cfi && spine) onLocatorChange(location.cfi, locationPercent(location), spine.resourceId);
});
hostRef.current.replaceChildren(view);
viewRef.current = view;
const file = new File([blob], epubFileName(url), { type: blob.type || "application/epub+zip" });
await view.open(file);
await view.open(book);
if (cancelled) return;
if (locatorRef.current) await view.goTo(locatorRef.current);
else await view.next();
@ -113,7 +127,6 @@ export function EpubReader({
}
}
}
void mount();
return () => {
cancelled = true;
@ -121,7 +134,7 @@ export function EpubReader({
view?.remove();
if (viewRef.current === view) viewRef.current = null;
};
}, [attempt, onLocatorChange, url]);
}, [attempt, manifest, onLocatorChange]);
useEffect(() => {
viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical");
@ -129,28 +142,10 @@ export function EpubReader({
}, [mode]);
if (error) {
return (
<div className="epub-reader">
<ReaderError
title="Lecture EPUB indisponible"
detail="ReadaBook n'a pas pu ouvrir ce fichier dans le lecteur web."
technicalDetail={error}
downloadUrl={url}
backHref={backHref}
onRetry={() => setAttempt((value) => value + 1)}
/>
</div>
);
return <div className="epub-reader"><ReaderError title="Lecture EPUB indisponible" detail="ReadaBook n'a pas pu ouvrir cette page dans le lecteur web." technicalDetail={error} backHref={backHref} onRetry={() => setAttempt((value) => value + 1)} /></div>;
}
return (
<div className={`epub-reader epub-reader-${mode}`}>
{loading && (
<div className="reader-fallback">
<span>Ouverture EPUB</span>
</div>
)}
<div className="epub-host" ref={hostRef} />
</div>
);
return <div className={`epub-reader epub-reader-${mode}`}>
{loading && <div className="reader-fallback"><span>Ouverture de la page…</span></div>}
<div className="epub-host" ref={hostRef} />
</div>;
}

View File

@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import type { ReadingManifestDto } from "../api/types";
import { createEpubApiLoader } from "./EpubReader";
const manifest: ReadingManifestDto = {
bookId: 4,
format: "epub",
revision: "rev-1",
containerPath: "META-INF/container.xml",
opfPath: "OPS/package.opf",
basePath: "OPS/",
resources: [
{ resourceId: "container", href: "META-INF/container.xml", url: "/books/4/reading-resources/container", mediaType: "application/xml", size: 12, kind: "document", state: "ready" },
{ resourceId: "chapter", href: "OPS/text/chapter.xhtml", url: "/books/4/reading-resources/chapter", mediaType: "application/xhtml+xml", size: 18, kind: "chapter", state: "ready" }
],
spine: [{ index: 0, href: "OPS/text/chapter.xhtml", resourceId: "chapter", url: "/books/4/reading-resources/chapter", mediaType: "application/xhtml+xml", size: 18 }],
capabilities: { byteRanges: false, derivedImages: true, lightweightPages: false }
};
describe("EPUB adaptive loader", () => {
it("loads manifest resources with credentials and reuses a fetched entry", async () => {
const fetcher = vi.fn().mockResolvedValue(new Response("<html>chapter</html>", { status: 200 }));
const loader = createEpubApiLoader(manifest, fetcher);
await expect(loader.loadText("OPS/text/chapter.xhtml#part")).resolves.toBe("<html>chapter</html>");
await loader.loadBlob("OPS/text/chapter.xhtml");
expect(fetcher).toHaveBeenCalledTimes(1);
expect(fetcher).toHaveBeenCalledWith("/books/4/reading-resources/chapter", { credentials: "include", cache: "default" });
expect(loader.getSize("OPS/text/chapter.xhtml")).toBe(18);
});
it("does not contain the complete-book fetch path", () => {
const source = readFileSync(new URL("./EpubReader.tsx", import.meta.url), "utf8");
expect(source).not.toContain("bookFileUrl");
expect(source).not.toContain("fetch(url");
expect(source).not.toContain("new File(");
expect(source).toContain("new EPUB(createEpubApiLoader(manifest)).init()");
});
});

View File

@ -285,7 +285,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
onControlsChange({
canPrevious: !loadingDocument && !documentError && !pageError && currentPage > 1,
canNext: !loadingDocument && !documentError && !pageError && currentPage < pages,
positionLabel: loadingDocument ? "Chargement" : readerPositionLabel(currentPage, pages),
positionLabel: loadingDocument ? "Ouverture de la page…" : readerPositionLabel(currentPage, pages),
onPrevious: () => go(currentPage - 1),
onNext: () => go(currentPage + 1)
});
@ -584,7 +584,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
<div className={`pdf-reader${mode === "vertical" ? " pdf-reader-vertical" : ""}`} ref={frameRef}>
{loadingDocument || !documentProxy ? (
<div className="reader-fallback">
<span>Ouverture PDF</span>
<span>Ouverture de la page…</span>
</div>
) : mode === "vertical" ? (
<div className="reader-vertical-stack">
@ -619,7 +619,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
<div className="pdf-page-frame" data-reader-page={currentPage}>
{(!viewportSize || loadingPage || !pageRendered) && (
<div className="reader-fallback">
<span>Page {currentPage}</span>
<span>Ouverture de la page…</span>
</div>
)}
<canvas ref={canvasRef} data-pdf-render-status={pageStatus} style={{ visibility: showCanvas ? "visible" : "hidden" }} />

View File

@ -18,7 +18,7 @@ export function ReaderError({
title: string;
detail: string;
technicalDetail?: string;
downloadUrl: string;
downloadUrl?: string;
backHref: string;
onRetry: () => void;
}) {
@ -37,10 +37,12 @@ export function ReaderError({
<ArrowLeft size={16} />
Retour à la fiche
</button>
<a className="ghost-button" href={downloadUrl} download>
<Download size={16} />
Télécharger
</a>
{downloadUrl && (
<a className="ghost-button" href={downloadUrl} download>
<Download size={16} />
Télécharger
</a>
)}
</div>
{technicalDetail && (
<details>

View File

@ -14,6 +14,7 @@ import {
import { readerMovedBeyondTap, readerPinchZoom, readerPointDistance, type ReaderPoint } from "./readerGestures";
import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout";
import type { ReaderMode } from "./readerScroll";
import type { ReadingConnectionMode } from "../api/types";
export type ReaderControls = {
canPrevious: boolean;
@ -36,6 +37,11 @@ export type ReaderModeControls = {
onModeChange: (mode: ReaderMode) => void;
};
export type ReaderConnectionControls = {
mode: ReadingConnectionMode;
onModeChange: (mode: ReadingConnectionMode) => void;
};
type ReaderShellProps = {
title: string;
backHref: string;
@ -44,6 +50,8 @@ type ReaderShellProps = {
controls: ReaderControls;
zoomControls?: ReaderZoomControls;
modeControls?: ReaderModeControls;
connectionControls?: ReaderConnectionControls;
activity?: string;
children: ReactNode;
};
@ -105,7 +113,7 @@ function scheduleReaderZoomAnchorRestore(stage: HTMLElement, anchor: ReaderZoomA
});
}
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, children }: ReaderShellProps) {
export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, modeControls, connectionControls, activity, children }: ReaderShellProps) {
const readerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLElement>(null);
const stageRef = useRef<HTMLElement>(null);
@ -368,6 +376,16 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
</select>
</label>
)}
{connectionControls && (
<label className="reader-mode-control reader-connection-control">
<span>Mode connexion lente</span>
<select value={connectionControls.mode} onChange={(event) => connectionControls.onModeChange(event.currentTarget.value as ReadingConnectionMode)}>
<option value="auto">Automatique</option>
<option value="speed">Toujours privilégier la vitesse</option>
<option value="quality">Toujours privilégier la qualité</option>
</select>
</label>
)}
<button className="ghost-button reader-nav-button reader-previous-button" onClick={controls.onPrevious} disabled={!controls.canPrevious} aria-label="Page precedente">
<ArrowLeft size={16} />
<span>Precedent</span>
@ -405,6 +423,11 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon
)}
</div>
)}
{!error && activity && (
<div className="reader-status reader-activity" role="status" aria-live="polite">
<span>{activity}</span>
</div>
)}
<main className="reader-stage" ref={stageRef}>
<div className="reader-content">{children}</div>
</main>

View File

@ -0,0 +1,41 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ReaderShell } from "./ReaderShell";
describe("ReaderShell adaptive connection controls", () => {
it("renders the slow connection mode choices without technical wording or a global percentage", () => {
const html = renderToStaticMarkup(
<ReaderShell
title="Livre"
backHref="/books/1"
controls={{ canPrevious: false, canNext: true, positionLabel: "Page 2 / 12", onPrevious: () => undefined, onNext: () => undefined }}
connectionControls={{ mode: "auto", onModeChange: () => undefined }}
>
<p>Page courante</p>
</ReaderShell>
);
expect(html).toContain("Mode connexion lente");
expect(html).toContain("Automatique");
expect(html).toContain("Toujours privilégier la vitesse");
expect(html).toContain("Toujours privilégier la qualité");
expect(html).not.toMatch(/%|pourcentage global|manifest|spine|range|etag|cache|resource|locator/i);
});
it("shows retry next to offline or loading errors", () => {
const html = renderToStaticMarkup(
<ReaderShell
title="Livre"
backHref="/books/1"
error="Cette page n’a pas pu être chargée. Votre position est conservée."
onRetry={() => undefined}
controls={{ canPrevious: false, canNext: false, positionLabel: "Page 2 / 12", onPrevious: () => undefined, onNext: () => undefined }}
>
<p>Page courante</p>
</ReaderShell>
);
expect(html).toContain("Cette page n’a pas pu être chargée. Votre position est conservée.");
expect(html).toContain("Reessayer");
});
});

View File

@ -1,6 +1,5 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { epubFileName } from "./EpubReader";
import { pdfDocumentOptions } from "./pdfDocumentOptions";
import {
clampReaderPage,
@ -23,11 +22,6 @@ import { isPageInRenderWindow, majorityVisiblePage, verticalRenderWindow } from
import { readerErrorMessage } from "./ReaderError";
describe("reader runtime helpers", () => {
it("extracts an EPUB file name from the file URL", () => {
expect(epubFileName("http://readabook.local/books/12/file?token=abc")).toBe("book.epub");
expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub");
});
it("keeps PDF.js worker fallback source on the bundled module worker", () => {
expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs");
});

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { adaptiveStatusLabel } from "./useAdaptiveReading";
describe("adaptive reading status", () => {
it("uses the short reader wording without a global percentage", () => {
expect(adaptiveStatusLabel("opening", false)).toBe("Ouverture de la page…");
expect(adaptiveStatusLabel("next", false)).toBe("Préparation de la page suivante…");
expect(adaptiveStatusLabel("neighbors", false)).toBe("Chargement des pages voisines…");
expect(adaptiveStatusLabel("slow", false)).toBe("Connexion lente. On charge d’abord cette page.");
expect(adaptiveStatusLabel("offline", false)).toBe("Hors connexion. La page déjà chargée reste disponible.");
for (const status of ["opening", "next", "neighbors", "slow", "offline", "idle"] as const) {
const label = adaptiveStatusLabel(status, false) ?? "";
expect(label).not.toMatch(/%|pourcentage|global|manifest|spine|range|etag|cache|resource|locator/i);
}
});
it("announces a lightweight resource when the manifest supports it", () => {
expect(adaptiveStatusLabel("opening", true)).toBe("Version allégée");
});
});

View File

@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import type { ReadingConnectionMode, ReadingManifestDto, ReadingNetworkHint, ReadingWindowItemDto } from "../api/types";
const CONNECTION_MODE_KEY = "readabook:reading-connection-mode";
export type AdaptiveReadingStatus = "opening" | "next" | "neighbors" | "slow" | "offline" | "idle";
function storedMode(): ReadingConnectionMode {
if (typeof localStorage === "undefined") return "auto";
const value = localStorage.getItem(CONNECTION_MODE_KEY);
return value === "speed" || value === "quality" ? value : "auto";
}
export function readingNetworkHint(): ReadingNetworkHint {
if (typeof navigator === "undefined") return "unknown";
if (!navigator.onLine) return "offline";
const connection = (navigator as Navigator & { connection?: { effectiveType?: string; saveData?: boolean } }).connection;
if (connection?.saveData || connection?.effectiveType === "slow-2g" || connection?.effectiveType === "2g") return "slow";
if (connection?.effectiveType === "3g") return "normal";
if (connection?.effectiveType === "4g") return "fast";
return "unknown";
}
export function adaptiveStatusLabel(status: AdaptiveReadingStatus, lightweight: boolean): string | undefined {
if (lightweight) return "Version allégée";
if (status === "opening") return "Ouverture de la page…";
if (status === "next") return "Préparation de la page suivante…";
if (status === "neighbors") return "Chargement des pages voisines…";
if (status === "slow") return "Connexion lente. On charge d’abord cette page.";
if (status === "offline") return "Hors connexion. La page déjà chargée reste disponible.";
return undefined;
}
export function useAdaptiveReading(bookId: number, enabled: boolean) {
const [manifest, setManifest] = useState<ReadingManifestDto | null>(null);
const [mode, setModeState] = useState<ReadingConnectionMode>(storedMode);
const [status, setStatus] = useState<AdaptiveReadingStatus>("opening");
const [error, setError] = useState<string>();
const generationRef = useRef(0);
const lastLocatorRef = useRef<string | null>(null);
const currentResourceRef = useRef<string | null>(null);
const loadManifest = useCallback(async () => {
if (!enabled) return;
setError(undefined);
setStatus("opening");
try {
setManifest(await api.readingManifest(bookId));
} catch {
setError("Impossible de préparer la lecture. Réessayez sans quitter cette page.");
setStatus(readingNetworkHint() === "offline" ? "offline" : "idle");
}
}, [bookId, enabled]);
useEffect(() => {
setManifest(null);
void loadManifest();
}, [loadManifest]);
useEffect(() => {
const update = () => setStatus(navigator.onLine ? "idle" : "offline");
window.addEventListener("online", update);
window.addEventListener("offline", update);
return () => {
window.removeEventListener("online", update);
window.removeEventListener("offline", update);
};
}, []);
const setMode = useCallback((next: ReadingConnectionMode) => {
setModeState(next);
localStorage.setItem(CONNECTION_MODE_KEY, next);
}, []);
const prioritize = useCallback(
async (locator: string, prefetch = true) => {
if (!manifest) return;
lastLocatorRef.current = locator;
const generation = ++generationRef.current;
const networkHint = readingNetworkHint();
setError(undefined);
setStatus(networkHint === "offline" ? "offline" : networkHint === "slow" || mode === "speed" ? "slow" : "opening");
try {
const window = await api.readingWindow(bookId, locator, mode, networkHint);
currentResourceRef.current = window.current?.resourceId ?? null;
if (generation !== generationRef.current || networkHint === "offline") return;
// pdf.js owns its byte-range requests; fetching the resource here would download the whole PDF.
if (manifest.format === "pdf" || !prefetch) {
setStatus("idle");
return;
}
const fetchItem = async (item: ReadingWindowItemDto | null) => {
if (!item) return;
const response = await fetch(api.readingResourceUrl(bookId, item.resourceId), { credentials: "include", cache: "default" });
if (!response.ok) throw new Error(String(response.status));
await response.arrayBuffer();
};
await fetchItem(window.current);
if (generation !== generationRef.current) return;
setStatus("next");
await Promise.allSettled([fetchItem(window.previous), fetchItem(window.next)]);
if (generation !== generationRef.current) return;
if (window.neighbors.length) {
setStatus("neighbors");
await Promise.allSettled(window.neighbors.map(fetchItem));
}
if (generation === generationRef.current) setStatus("idle");
} catch {
if (generation !== generationRef.current) return;
setError("Cette page n’a pas pu être chargée. Votre position est conservée.");
setStatus(readingNetworkHint() === "offline" ? "offline" : "idle");
}
},
[bookId, manifest, mode]
);
const retry = useCallback(async () => {
const resourceId = currentResourceRef.current;
const locator = lastLocatorRef.current;
if (!resourceId || !locator) {
await loadManifest();
return;
}
try {
await api.retryReadingResource(bookId, resourceId);
} catch {
// Retrying the GET still gives an already cached response a chance while offline.
}
await prioritize(locator);
}, [bookId, loadManifest, prioritize]);
return { manifest, mode, setMode, status, statusLabel: adaptiveStatusLabel(status, Boolean(manifest?.capabilities.lightweightPages)), error, retry, prioritize };
}

View File

@ -161,9 +161,12 @@ h2 {
}
.book-grid {
--book-grid-column-min: 240px;
--book-grid-gap: 16px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr));
gap: var(--book-grid-gap);
width: 100%;
}
.book-card {
@ -237,6 +240,15 @@ h2 {
white-space: pre-line;
}
.book-error-page,
.reader-missing-page {
display: grid;
align-items: center;
width: min(100%, 680px);
min-height: 60vh;
margin: 0 auto;
}
.book-card-description {
display: -webkit-box;
overflow: hidden;
@ -281,6 +293,11 @@ h2 {
white-space: nowrap;
}
.book-card-actions .read-status-action {
flex-basis: 180px;
white-space: normal;
}
.format-pill {
flex: 0 0 auto;
padding: 4px 7px;
@ -327,6 +344,22 @@ h2 {
color: var(--ink);
}
.read-status-badge {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 4px;
width: max-content;
max-width: 100%;
padding: 4px 7px;
border: 1px solid rgba(45, 111, 99, 0.76);
border-radius: 999px;
color: #d8fff5;
font-size: 0.72rem;
font-weight: 900;
background: rgba(45, 111, 99, 0.2);
}
.metadata-pill {
width: max-content;
max-width: 100%;
@ -356,6 +389,24 @@ h2 {
background: rgba(169, 72, 52, 0.12);
}
.home-page {
align-items: start;
gap: 18px;
}
.home-hero {
min-height: 176px;
border-color: rgba(247, 240, 223, 0.13);
background:
linear-gradient(180deg, rgba(247, 240, 223, 0.08), rgba(247, 240, 223, 0.025)),
rgba(23, 17, 13, 0.74);
}
.home-hero h1 {
max-width: 740px;
font-size: clamp(2.2rem, 5vw, 4.1rem);
}
.continue-grid,
.library-list,
.job-list {
@ -363,9 +414,11 @@ h2 {
gap: 10px;
}
.continue-grid {
--continue-visible-rows: 6;
--continue-tile-block-size: 118px;
.continue-grid,
.library-list {
--continue-visible-rows: 3;
--continue-tile-block-size: 112px;
height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)));
max-height: calc((var(--continue-tile-block-size) * var(--continue-visible-rows)) + (10px * (var(--continue-visible-rows) - 1)));
overflow-y: auto;
padding-right: 4px;
@ -416,12 +469,97 @@ h2 {
}
.continue-tile {
grid-template-columns: 52px minmax(0, 1fr);
align-items: start;
block-size: var(--continue-tile-block-size);
overflow: hidden;
}
.library-list button {
block-size: var(--continue-tile-block-size);
overflow: hidden;
}
.home-book-grid {
--book-grid-column-min: 190px;
--book-grid-gap: 14px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, max(var(--book-grid-column-min), calc((100% - (var(--book-grid-gap) * 4)) / 5))), 1fr));
gap: var(--book-grid-gap);
width: 100%;
}
.home-book-card {
display: grid;
grid-template-columns: 62px minmax(0, 1fr);
align-items: center;
gap: 12px;
min-width: 0;
padding: 10px;
border: 1px solid rgba(247, 240, 223, 0.12);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.035);
color: var(--ink);
text-align: left;
}
.home-book-card:hover {
border-color: rgba(213, 168, 77, 0.42);
background: rgba(255, 255, 255, 0.055);
}
.home-book-cover {
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 2 / 3;
overflow: hidden;
border: 1px solid rgba(247, 240, 223, 0.14);
border-radius: 5px;
background:
linear-gradient(135deg, rgba(213, 168, 77, 0.16), rgba(45, 111, 99, 0.16)),
var(--paper-soft);
color: var(--brass);
}
.home-book-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.home-book-copy {
display: grid;
gap: 5px;
min-width: 0;
}
.home-book-copy strong,
.home-book-copy span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-book-copy strong {
line-height: 1.15;
}
.home-book-copy > span {
color: var(--ink-muted);
}
.home-book-volume {
width: max-content;
max-width: 100%;
padding: 3px 6px;
border: 1px solid rgba(213, 168, 77, 0.42);
border-radius: 999px;
color: #f5dfaa;
font-size: 0.72rem;
font-weight: 900;
background: rgba(213, 168, 77, 0.1);
}
.continue-cover {
display: grid;
place-items: center;
@ -868,8 +1006,54 @@ select {
.profile-panel {
display: grid;
gap: 10px;
align-content: start;
gap: 18px;
}
.profile-identity {
display: grid;
justify-items: start;
gap: 8px;
}
.profile-avatar {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border: 1px solid rgba(247, 240, 223, 0.14);
border-radius: 999px;
background: rgba(255, 255, 255, 0.045);
color: var(--brass);
}
.profile-identity h1 {
margin-bottom: 0;
font-size: 1.8rem;
line-height: 1.05;
}
.profile-identity p {
margin-bottom: 0;
color: var(--ink-muted);
overflow-wrap: anywhere;
}
.profile-identity span:not(.profile-avatar) {
width: max-content;
max-width: 100%;
padding: 4px 7px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--ink-muted);
font-size: 0.76rem;
font-weight: 900;
}
.profile-actions {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.reader-page {
@ -1025,6 +1209,17 @@ main.app-main-reader .reader-page {
min-width: 0;
}
.reader-activity {
justify-content: center;
min-height: 30px;
color: var(--ink-muted);
font-size: 0.86rem;
}
.reader-connection-control select {
max-width: 220px;
}
.reader-stage {
grid-row: 3;
position: relative;
@ -1352,6 +1547,11 @@ main.app-main-reader .reader-page {
min-width: 0;
}
.reader-connection-control {
order: 8;
flex-basis: 100%;
}
.reader-mode-control select {
width: 100%;
min-width: 0;
@ -1382,7 +1582,7 @@ main.app-main-reader .reader-page {
.reader-back-button span,
.reader-nav-button span,
.reader-fullscreen-toggle span,
.reader-mode-control > span {
.reader-mode-control > span:not(:only-child) {
display: none;
}

View File

@ -1,9 +1,14 @@
/// <reference types="vite/client" />
declare module "foliate-js/epub.js" {
const module: unknown;
export default module;
export const EPUB: unknown;
export class EPUB {
constructor(loader: {
loadText(path: string): Promise<string | null>;
loadBlob(path: string): Promise<Blob | null>;
getSize(path: string): number;
});
init(): Promise<unknown>;
}
}
declare module "foliate-js/view.js" {

20
docker-compose.build.yaml Normal file
View File

@ -0,0 +1,20 @@
services:
api:
build:
context: .
dockerfile: Dockerfile
args:
OCI_IMAGE_CREATED: ${OCI_IMAGE_CREATED:-local}
OCI_IMAGE_REVISION: ${OCI_IMAGE_REVISION:-local}
OCI_IMAGE_VERSION: ${READABOOK_TAG:-local}
image: ${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/api:${READABOOK_TAG:-local}
web:
build:
context: .
dockerfile: apps/web/Dockerfile
args:
OCI_IMAGE_CREATED: ${OCI_IMAGE_CREATED:-local}
OCI_IMAGE_REVISION: ${OCI_IMAGE_REVISION:-local}
OCI_IMAGE_VERSION: ${READABOOK_TAG:-local}
image: ${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/web:${READABOOK_TAG:-local}

View File

@ -1,8 +1,6 @@
services:
api:
build:
context: .
dockerfile: Dockerfile
image: ${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/api:${READABOOK_TAG:-latest}
environment:
NODE_ENV: production
PORT: "3000"
@ -16,7 +14,7 @@ services:
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-readabook-admin-change-me}
volumes:
- ./data:/data
- /home/anthony/Documents/Projects/ReadaBook/Books:/library:ro
- ${READABOOK_LIBRARY_HOST_PATH:-./data/library}:/library:ro
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
@ -25,9 +23,7 @@ services:
start_period: 10s
web:
build:
context: .
dockerfile: apps/web/Dockerfile
image: ${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}/web:${READABOOK_TAG:-latest}
depends_on:
api:
condition: service_healthy

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { BootstrapAdminSchema, BookQuerySchema } from "./index.js";
import { BookSchema, BootstrapAdminSchema, BookQuerySchema, ProgressSchema, UpdateReadStatusSchema } from "./index.js";
describe("shared contracts", () => {
it("validates bootstrap admin payloads", () => {
@ -12,4 +12,43 @@ describe("shared contracts", () => {
expect(BookQuerySchema.parse({}).limit).toBeUndefined();
expect(BookQuerySchema.parse({ limit: "50" }).limit).toBe(50);
});
it("exposes read state on book and progress contracts", () => {
expect(
BookSchema.parse({
id: 1,
libraryId: 1,
title: "Daredevil",
author: null,
description: null,
isbn: null,
isbn13: null,
language: null,
publisher: null,
publishedDate: null,
format: "cbz",
filePath: "/library/Daredevil.cbz",
coverPath: null,
readStatus: "unread",
completedAt: null,
readStatusSource: null,
fileSize: 42,
fileMtime: "2026-08-30T00:00:00.000Z",
createdAt: "2026-08-30T00:00:00.000Z",
updatedAt: "2026-08-30T00:00:00.000Z"
})
).toMatchObject({ readStatus: "unread", completedAt: null, readStatusSource: null });
expect(
ProgressSchema.parse({
bookId: 1,
locator: "epubcfi(/6/2)",
percent: 95,
readStatus: "read",
completedAt: "2026-08-30T00:00:00.000Z",
readStatusSource: "progress",
updatedAt: "2026-08-30T00:00:00.000Z"
})
).toMatchObject({ readStatus: "read", readStatusSource: "progress" });
expect(UpdateReadStatusSchema.parse({ status: "unread" })).toEqual({ status: "unread" });
});
});

View File

@ -95,6 +95,19 @@ export const SeriesSchema = z.object({
});
export type SeriesDto = z.infer<typeof SeriesSchema>;
export const ReadStatusSchema = z.enum(["unread", "reading", "read"]);
export type ReadStatus = z.infer<typeof ReadStatusSchema>;
export const ReadStatusSourceSchema = z.enum(["progress", "manual"]);
export type ReadStatusSource = z.infer<typeof ReadStatusSourceSchema>;
export const BookReadStateSchema = z.object({
readStatus: ReadStatusSchema.optional(),
completedAt: z.string().nullable().optional(),
readStatusSource: ReadStatusSourceSchema.nullable().optional()
});
export type BookReadStateDto = z.infer<typeof BookReadStateSchema>;
export const BookSchema = z.object({
id: z.number().int().positive(),
libraryId: z.number().int().positive(),
@ -117,6 +130,9 @@ export const BookSchema = z.object({
series: SeriesSchema.nullable().optional(),
scanStatus: z.enum(["idle", "running", "succeeded", "failed"]).default("idle"),
enrichmentStatus: z.enum(["idle", "running", "succeeded", "failed"]).default("idle"),
readStatus: ReadStatusSchema.optional(),
completedAt: z.string().nullable().optional(),
readStatusSource: ReadStatusSourceSchema.nullable().optional(),
fileSize: z.number().int().nonnegative(),
fileMtime: z.string(),
createdAt: z.string(),
@ -137,6 +153,9 @@ export const ProgressSchema = z.object({
bookId: z.number().int().positive(),
locator: z.string().min(1),
percent: z.number().min(0).max(100),
readStatus: ReadStatusSchema.optional(),
completedAt: z.string().nullable().optional(),
readStatusSource: ReadStatusSourceSchema.nullable().optional(),
updatedAt: z.string()
});
export type ProgressDto = z.infer<typeof ProgressSchema>;
@ -147,6 +166,11 @@ export const UpdateProgressSchema = z.object({
});
export type UpdateProgressDto = z.infer<typeof UpdateProgressSchema>;
export const UpdateReadStatusSchema = z.object({
status: z.enum(["read", "unread"])
});
export type UpdateReadStatusDto = z.infer<typeof UpdateReadStatusSchema>;
export const ReaderPreferencesSchema = z.object({
mode: z.enum(["paged", "scrolled", "horizontal", "vertical"]).default("paged"),
fit: z.enum(["page", "width", "height", "auto"]).nullable().default(null),

41
scripts/publish.sh Executable file
View File

@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
REGISTRY="${READABOOK_REGISTRY:-gitea.anthonybouteiller.ovh/blomios/readabook}"
TAG="${READABOOK_TAG:-latest}"
REVISION="${OCI_IMAGE_REVISION:-$(git rev-parse HEAD)}"
CREATED="${OCI_IMAGE_CREATED:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
PLATFORM="${DOCKER_BUILD_PLATFORM:-}"
build_args=(
--build-arg "OCI_IMAGE_CREATED=${CREATED}"
--build-arg "OCI_IMAGE_REVISION=${REVISION}"
--build-arg "OCI_IMAGE_VERSION=${TAG}"
)
platform_args=()
if [[ -n "${PLATFORM}" ]]; then
platform_args=(--platform "${PLATFORM}")
fi
echo "Publishing ReadaBook images to ${REGISTRY} with tag ${TAG}"
docker buildx build \
"${platform_args[@]}" \
"${build_args[@]}" \
--tag "${REGISTRY}/api:${TAG}" \
--push \
--file Dockerfile \
.
docker buildx build \
"${platform_args[@]}" \
"${build_args[@]}" \
--tag "${REGISTRY}/web:${TAG}" \
--push \
--file apps/web/Dockerfile \
.
echo "Published:"
echo " ${REGISTRY}/api:${TAG}"
echo " ${REGISTRY}/web:${TAG}"