merge: feature/52-books-read-status dans develop (marquage des livres comme lus avec complétion automatique et badge visuel — évolution #52)

This commit is contained in:
Git Agent
2026-08-30 10:37:46 +02:00
20 changed files with 664 additions and 43 deletions

View File

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

View File

@ -4,7 +4,7 @@ import { extname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { BookQuerySchema } from "@readabook/shared"; import { BookQuerySchema } from "@readabook/shared";
import { DatabaseService } from "../database/database.service.js"; 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"; import { BooksService } from "./books.service.js";
const previousDatabasePath = process.env.DATABASE_PATH; const previousDatabasePath = process.env.DATABASE_PATH;
@ -149,6 +149,89 @@ describe("BooksService", () => {
database.onModuleDestroy(); 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", () => { it.runIf(canLoadBetterSqlite())("lists a series with distinct books sharing the same volume", () => {
const database = createDatabase(); const database = createDatabase();
const service = new BooksService(database); const service = new BooksService(database);

View File

@ -1,24 +1,32 @@
import { BadRequestException, HttpException, HttpStatus, Injectable, NotFoundException } from "@nestjs/common"; import { BadRequestException, HttpException, HttpStatus, Injectable, NotFoundException } from "@nestjs/common";
import { createReadStream, existsSync, statSync } from "node:fs"; import { createReadStream, existsSync, statSync } from "node:fs";
import { extname } from "node:path"; 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 { BookQueryDto } from "@readabook/shared";
import { listCbrImageEntries, readCbrPage } from "../common/cbr.js"; import { listCbrImageEntries, readCbrPage } from "../common/cbr.js";
import { listCbzImageEntries, readCbzPage } from "../common/cbz.js"; import { listCbzImageEntries, readCbzPage } from "../common/cbz.js";
import { DatabaseService } from "../database/database.service.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"; 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() @Injectable()
export class BooksService { export class BooksService {
constructor(private readonly database: DatabaseService) {} constructor(private readonly database: DatabaseService) {}
list(query: BookQueryDto) { list(query: BookQueryDto, userId?: number) {
const filters = []; const filters = [];
if (query.format) filters.push(eq(books.format, query.format)); if (query.format) filters.push(eq(books.format, query.format));
if (query.libraryId) filters.push(eq(books.libraryId, query.libraryId)); if (query.libraryId) filters.push(eq(books.libraryId, query.libraryId));
if (query.q) { 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 const statement = this.database.db
.select() .select()
@ -27,11 +35,11 @@ export class BooksService {
.orderBy(books.title); .orderBy(books.title);
const rows = const rows =
query.limit === undefined ? statement.all() : statement.limit(query.limit).offset(query.offset).all(); query.limit === undefined ? statement.all() : statement.limit(query.limit).offset(query.offset).all();
return rows const states = this.readStatesFor(userId, rows.map((book) => book.id));
.map((book) => this.mapBookSelect(book)); 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) { if (limit === undefined) {
const rows = this.database.sqlite const rows = this.database.sqlite
.prepare( .prepare(
@ -44,7 +52,7 @@ export class BooksService {
` `
) )
.all(`${q.replace(/"/g, '""')}*`); .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 const rows = this.database.sqlite
@ -59,18 +67,18 @@ export class BooksService {
` `
) )
.all(`${q.replace(/"/g, '""')}*`, limit, offset); .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) { get(id: number, userId?: number) {
return this.mapBookSelect(this.getRecord(id)); return this.mapBookSelect(this.getRecord(id), this.readStatesFor(userId, [id]).get(id));
} }
listSeries() { listSeries() {
return this.database.db.select().from(series).orderBy(series.title).all(); 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(); const row = this.database.db.select().from(series).where(eq(series.id, id)).get();
if (!row) throw new NotFoundException("Series not found"); if (!row) throw new NotFoundException("Series not found");
const seriesBooks = this.database.db const seriesBooks = this.database.db
@ -78,9 +86,10 @@ export class BooksService {
.from(books) .from(books)
.where(eq(books.seriesId, id)) .where(eq(books.seriesId, id))
.orderBy(books.volumeNumber, books.title) .orderBy(books.volumeNumber, books.title)
.all() .all();
.map((book) => this.mapBookSelect(book)); const states = this.readStatesFor(userId, seriesBooks.map((book) => book.id));
return { ...row, books: seriesBooks }; const mappedBooks = seriesBooks.map((book) => this.mapBookSelect(book, states.get(book.id)));
return { ...row, books: mappedBooks };
} }
streamFile(id: number, range?: string) { 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); const seriesId = nullable(row.series_id);
return { return {
id: Number(row.id), id: Number(row.id),
@ -179,6 +196,7 @@ export class BooksService {
series: seriesId ? this.getSeriesRecord(Number(seriesId)) : null, series: seriesId ? this.getSeriesRecord(Number(seriesId)) : null,
scanStatus: statusValue(row.scan_status), scanStatus: statusValue(row.scan_status),
enrichmentStatus: statusValue(row.enrichment_status), enrichmentStatus: statusValue(row.enrichment_status),
...readState,
fileSize: Number(row.file_size), fileSize: Number(row.file_size),
fileMtime: String(row.file_mtime), fileMtime: String(row.file_mtime),
createdAt: String(row.created_at), 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; const { metadataProvenanceJson: _metadataProvenanceJson, ...book } = row;
return { return {
...book, ...book,
publishedDate: normalizePublishedDate(row.publishedDate), publishedDate: normalizePublishedDate(row.publishedDate),
metadataProvenance: parseObject(row.metadataProvenanceJson), 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) { private getSeriesRecord(id: number) {
return this.database.db.select().from(series).where(eq(series.id, id)).get() ?? null; 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 } { 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"; 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> { function parseObject(value: unknown): Record<string, string> {
if (typeof value !== "string") return {}; if (typeof value !== "string") return {};
try { try {

View File

@ -1,5 +1,6 @@
import { Controller, Get, Param, UseGuards } from "@nestjs/common"; import { Controller, Get, Param, UseGuards } from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard.js"; import { AuthGuard } from "../auth/auth.guard.js";
import { CurrentUser, CurrentUserParam } from "../auth/current-user.js";
import { BooksService } from "./books.service.js"; import { BooksService } from "./books.service.js";
@Controller("series") @Controller("series")
@ -13,7 +14,7 @@ export class SeriesController {
} }
@Get(":id") @Get(":id")
get(@Param("id") id: string) { get(@CurrentUserParam() user: CurrentUser, @Param("id") id: string) {
return this.books.getSeries(Number(id)); 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) INSERT INTO libraries (id, name, path, enabled, created_at, updated_at)
VALUES (1, 'Corpus', '/library', 1, '${now}', '${now}'); 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 ( INSERT INTO books (
library_id, title, author, description, isbn, language, publisher, published_date, library_id, title, author, description, isbn, language, publisher, published_date,
format, file_path, cover_path, file_size, file_mtime, created_at, updated_at 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, '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, '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}'); (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(); legacy.close();
@ -105,6 +109,7 @@ describe("database migrations", () => {
const database = new DatabaseService(); const database = new DatabaseService();
const bookColumns = database.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>; 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 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 metadataSources = database.sqlite.prepare("SELECT provider FROM metadata_source_config ORDER BY priority").all() as Array<{ provider: string }>;
const seriesRows = database.sqlite 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 const automationSettings = database.sqlite.prepare("SELECT id, isbn_priority_enabled FROM automation_settings").get() as
| { id: number; isbn_priority_enabled: number } | { id: number; isbn_priority_enabled: number }
| undefined; | 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("isbn13");
expect(bookColumns.map((column) => column.name)).toContain("identifiers_json"); 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("series_id");
expect(bookColumns.map((column) => column.name)).toContain("volume_number"); expect(bookColumns.map((column) => column.name)).toContain("volume_number");
expect(bookColumns.map((column) => column.name)).toContain("volume_label"); 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_isbn13_idx");
expect(bookIndexes.map((index) => index.name)).toContain("books_series_idx"); expect(bookIndexes.map((index) => index.name)).toContain("books_series_idx");
expect(seriesRows).toEqual([ expect(seriesRows).toEqual([
@ -161,6 +172,7 @@ describe("database migrations", () => {
]); ]);
expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"]); expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf", "mangadex", "comicvine"]);
expect(automationSettings).toMatchObject({ id: 1, isbn_priority_enabled: 1 }); expect(automationSettings).toMatchObject({ id: 1, isbn_priority_enabled: 1 });
expect(progressRow).toEqual({ read_status: "read", completed_at: now, read_status_source: "progress" });
database.onModuleDestroy(); database.onModuleDestroy();
}); });

View File

@ -95,6 +95,9 @@ export class DatabaseService implements OnModuleDestroy {
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE, book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
locator TEXT NOT NULL, locator TEXT NOT NULL,
percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100), 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, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
UNIQUE(user_id, book_id) UNIQUE(user_id, book_id)
@ -165,6 +168,7 @@ export class DatabaseService implements OnModuleDestroy {
this.ensureBooksMetadataColumns(); this.ensureBooksMetadataColumns();
this.ensureSeriesModel(); this.ensureSeriesModel();
this.ensureReaderPreferencesTable(); this.ensureReaderPreferencesTable();
this.ensureProgressReadStatusColumns();
this.ensureMetadataSourceConfigSupportsComicProviders(); this.ensureMetadataSourceConfigSupportsComicProviders();
this.ensureMetadataSourceConfigColumns(); this.ensureMetadataSourceConfigColumns();
this.ensureAutomationSettingsColumns(); 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 { private ensureMetadataSourceConfigColumns(): void {
const names = this.columnNames("metadata_source_config"); const names = this.columnNames("metadata_source_config");
const now = sqlString(this.now()); const now = sqlString(this.now());

View File

@ -102,6 +102,9 @@ export const progress = sqliteTable(
.references(() => books.id, { onDelete: "cascade" }), .references(() => books.id, { onDelete: "cascade" }),
locator: text("locator").notNull(), locator: text("locator").notNull(),
percent: integer("percent").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(), createdAt: text("created_at").notNull(),
updatedAt: text("updated_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 { 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 { AuthGuard } from "../auth/auth.guard.js";
import { CurrentUser, CurrentUserParam } from "../auth/current-user.js"; import { CurrentUser, CurrentUserParam } from "../auth/current-user.js";
import { ZodValidationPipe } from "../common/zod-validation.pipe.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); 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 { Injectable, NotFoundException } from "@nestjs/common";
import { desc, eq, sql } from "drizzle-orm"; 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 { DatabaseService } from "../database/database.service.js";
import { books, progress } from "../database/schema.js"; import { books, progress } from "../database/schema.js";
const READ_COMPLETION_THRESHOLD = 95;
@Injectable() @Injectable()
export class ProgressService { export class ProgressService {
constructor(private readonly database: DatabaseService) {} constructor(private readonly database: DatabaseService) {}
upsert(userId: number, bookId: number, input: UpdateProgressDto) { upsert(userId: number, bookId: number, input: UpdateProgressDto) {
const book = this.database.db.select({ id: books.id }).from(books).where(eq(books.id, bookId)).get(); this.assertBookExists(bookId);
if (!book) {
throw new NotFoundException("Book not found");
}
const now = this.database.now(); 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 this.database.sqlite
.prepare( .prepare(
` `
INSERT INTO progress(user_id, book_id, locator, percent, created_at, updated_at) INSERT INTO progress(user_id, book_id, locator, percent, read_status, completed_at, read_status_source, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, 'progress', ?, ?)
ON CONFLICT(user_id, book_id) DO UPDATE SET ON CONFLICT(user_id, book_id) DO UPDATE SET
locator = excluded.locator, locator = excluded.locator,
percent = excluded.percent, 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 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); return this.get(userId, bookId);
} }
@ -43,6 +75,9 @@ export class ProgressService {
bookId: progress.bookId, bookId: progress.bookId,
locator: progress.locator, locator: progress.locator,
percent: progress.percent, percent: progress.percent,
readStatus: progress.readStatus,
completedAt: progress.completedAt,
readStatusSource: progress.readStatusSource,
updatedAt: progress.updatedAt updatedAt: progress.updatedAt
}) })
.from(progress) .from(progress)
@ -59,14 +94,30 @@ export class ProgressService {
bookId: progress.bookId, bookId: progress.bookId,
locator: progress.locator, locator: progress.locator,
percent: progress.percent, percent: progress.percent,
readStatus: progress.readStatus,
completedAt: progress.completedAt,
readStatusSource: progress.readStatusSource,
updatedAt: progress.updatedAt updatedAt: progress.updatedAt
} }
}) })
.from(progress) .from(progress)
.innerJoin(books, eq(progress.bookId, books.id)) .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)) .orderBy(desc(progress.updatedAt))
.limit(20) .limit(20)
.all(); .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

@ -14,6 +14,7 @@ import type {
UpdateAccountDto, UpdateAccountDto,
UpdateMetadataSourcesConfigDto, UpdateMetadataSourcesConfigDto,
UpdateProgressDto, UpdateProgressDto,
UpdateReadStatusDto,
UserDto UserDto
} from "@readabook/shared"; } from "@readabook/shared";
import { import {
@ -198,7 +199,30 @@ export const api = {
return request<ProgressDto>(`/progress/${bookId}`, { return request<ProgressDto>(`/progress/${bookId}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(input), 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> { async readerPreferences(bookId: number): Promise<ReaderPreferencesDto> {

View File

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

View File

@ -1,4 +1,6 @@
import type { ReactNode } from "react"; 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 }) { export function Panel({ children, className = "" }: { children: ReactNode; className?: string }) {
return <section className={`panel ${className}`}>{children}</section>; 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>; 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 }) { export function Meter({ value }: { value: number }) {
return ( return (
<span className="meter" aria-label={`${Math.round(value)}%`}> <span className="meter" aria-label={`${Math.round(value)}%`}>

View File

@ -1,10 +1,10 @@
import { useEffect, useState } from "react"; 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 type { BookDto, ProgressDto } from "@readabook/shared";
import { api } from "../api/client"; import { api } from "../api/client";
import { cleanBookDescription } from "../book/description"; import { cleanBookDescription } from "../book/description";
import { bookDisplayTitle, bookMetadataState, bookMetadataStateLabel, bookSeriesInfo, displayPublishedDate } from "../book/metadata"; 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"; import { navigate } from "../router";
export function BookPage({ bookId }: { bookId: number }) { export function BookPage({ bookId }: { bookId: number }) {
@ -33,6 +33,27 @@ export function BookPage({ bookId }: { bookId: number }) {
} }
} }
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(() => { useEffect(() => {
let alive = true; let alive = true;
loadBook().finally(() => { loadBook().finally(() => {
@ -65,6 +86,7 @@ export function BookPage({ bookId }: { bookId: number }) {
const series = bookSeriesInfo(book); const series = bookSeriesInfo(book);
const metadataState = bookMetadataState(book); const metadataState = bookMetadataState(book);
const readStatus = progress?.readStatus ?? book.readStatus;
const publishedDate = displayPublishedDate(book.publishedDate); const publishedDate = displayPublishedDate(book.publishedDate);
const detailFacts = [ const detailFacts = [
{ label: "Auteur", value: book.author }, { label: "Auteur", value: book.author },
@ -84,6 +106,7 @@ export function BookPage({ bookId }: { bookId: number }) {
<ErrorRibbon message={error} /> <ErrorRibbon message={error} />
<div className="book-card-meta"> <div className="book-card-meta">
<FormatPill format={book.format} /> <FormatPill format={book.format} />
<ReadStatusBadge readStatus={readStatus} />
<span>{book.language ?? "langue inconnue"}</span> <span>{book.language ?? "langue inconnue"}</span>
<span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span> <span className={`metadata-pill metadata-${metadataState}`}>{bookMetadataStateLabel(book)}</span>
</div> </div>
@ -108,6 +131,10 @@ export function BookPage({ bookId }: { bookId: number }) {
<LibraryBig size={18} /> <LibraryBig size={18} />
Rayon Rayon
</button> </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 && ( {series && (
<button className="ghost-button" onClick={() => navigate(`/catalog/series/${encodeURIComponent(series.title)}`)}> <button className="ghost-button" onClick={() => navigate(`/catalog/series/${encodeURIComponent(series.title)}`)}>
<LibraryBig size={18} /> <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

@ -3,7 +3,7 @@ import { BookOpen, LibraryBig, ScanLine } from "lucide-react";
import { api } from "../api/client"; import { api } from "../api/client";
import type { DashboardData } from "../api/types"; import type { DashboardData } from "../api/types";
import { bookDisplayTitle, bookVolumeLabel } from "../book/metadata"; import { bookDisplayTitle, bookVolumeLabel } from "../book/metadata";
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui"; import { EmptyState, LoadingState, Meter, Panel, ReadStatusBadge } from "../components/ui";
import { navigate } from "../router"; import { navigate } from "../router";
export function HomePage() { export function HomePage() {
@ -39,6 +39,7 @@ export function HomePage() {
</span> </span>
<span className="home-book-copy"> <span className="home-book-copy">
{volumeLabel && <span className="home-book-volume">{volumeLabel}</span>} {volumeLabel && <span className="home-book-volume">{volumeLabel}</span>}
<ReadStatusBadge readStatus={item.readStatus} />
<strong>{bookDisplayTitle(item)}</strong> <strong>{bookDisplayTitle(item)}</strong>
<span>{item.author ?? "Auteur inconnu"}</span> <span>{item.author ?? "Auteur inconnu"}</span>
{options.progressPercent !== undefined && <Meter value={options.progressPercent} />} {options.progressPercent !== undefined && <Meter value={options.progressPercent} />}

View File

@ -293,6 +293,11 @@ h2 {
white-space: nowrap; white-space: nowrap;
} }
.book-card-actions .read-status-action {
flex-basis: 180px;
white-space: normal;
}
.format-pill { .format-pill {
flex: 0 0 auto; flex: 0 0 auto;
padding: 4px 7px; padding: 4px 7px;
@ -339,6 +344,22 @@ h2 {
color: var(--ink); 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 { .metadata-pill {
width: max-content; width: max-content;
max-width: 100%; max-width: 100%;

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { BootstrapAdminSchema, BookQuerySchema } from "./index.js"; import { BookSchema, BootstrapAdminSchema, BookQuerySchema, ProgressSchema, UpdateReadStatusSchema } from "./index.js";
describe("shared contracts", () => { describe("shared contracts", () => {
it("validates bootstrap admin payloads", () => { it("validates bootstrap admin payloads", () => {
@ -12,4 +12,43 @@ describe("shared contracts", () => {
expect(BookQuerySchema.parse({}).limit).toBeUndefined(); expect(BookQuerySchema.parse({}).limit).toBeUndefined();
expect(BookQuerySchema.parse({ limit: "50" }).limit).toBe(50); 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 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({ export const BookSchema = z.object({
id: z.number().int().positive(), id: z.number().int().positive(),
libraryId: z.number().int().positive(), libraryId: z.number().int().positive(),
@ -117,6 +130,9 @@ export const BookSchema = z.object({
series: SeriesSchema.nullable().optional(), series: SeriesSchema.nullable().optional(),
scanStatus: z.enum(["idle", "running", "succeeded", "failed"]).default("idle"), scanStatus: z.enum(["idle", "running", "succeeded", "failed"]).default("idle"),
enrichmentStatus: 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(), fileSize: z.number().int().nonnegative(),
fileMtime: z.string(), fileMtime: z.string(),
createdAt: z.string(), createdAt: z.string(),
@ -137,6 +153,9 @@ export const ProgressSchema = z.object({
bookId: z.number().int().positive(), bookId: z.number().int().positive(),
locator: z.string().min(1), locator: z.string().min(1),
percent: z.number().min(0).max(100), percent: z.number().min(0).max(100),
readStatus: ReadStatusSchema.optional(),
completedAt: z.string().nullable().optional(),
readStatusSource: ReadStatusSourceSchema.nullable().optional(),
updatedAt: z.string() updatedAt: z.string()
}); });
export type ProgressDto = z.infer<typeof ProgressSchema>; export type ProgressDto = z.infer<typeof ProgressSchema>;
@ -147,6 +166,11 @@ export const UpdateProgressSchema = z.object({
}); });
export type UpdateProgressDto = z.infer<typeof UpdateProgressSchema>; 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({ export const ReaderPreferencesSchema = z.object({
mode: z.enum(["paged", "scrolled", "horizontal", "vertical"]).default("paged"), mode: z.enum(["paged", "scrolled", "horizontal", "vertical"]).default("paged"),
fit: z.enum(["page", "width", "height", "auto"]).nullable().default(null), fit: z.enum(["page", "width", "height", "auto"]).nullable().default(null),