From 05f5f1cd16ba60fb02458e13b3ebca37c42ad475 Mon Sep 17 00:00:00 2001 From: Git Agent Date: Sun, 30 Aug 2026 10:37:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(books):=20marquage=20des=20livres=20comme?= =?UTF-8?q?=20lus=20=E2=80=94=20statut=20lu/non=20lu=20par=20utilisateur?= =?UTF-8?q?=20sur=20progress=20(colonnes=20read=5Fstatus/completed=5Fat/re?= =?UTF-8?q?ad=5Fstatus=5Fsource,=20migration=20idempotente=20avec=20backfi?= =?UTF-8?q?ll=20=C3=A0=2095=20%),=20endpoint=20PUT=20/progress/:bookId/sta?= =?UTF-8?q?tus,=20compl=C3=A9tion=20automatique=20au=20fil=20de=20la=20pro?= =?UTF-8?q?gression,=20=C3=A9tat=20lu=20expos=C3=A9=20sur=20catalogue/rech?= =?UTF-8?q?erche/fiche/s=C3=A9ries,=20=C3=A9tag=C3=A8re=20=C2=AB=20continu?= =?UTF-8?q?er=20=C2=BB=20limit=C3=A9e=20aux=20lectures=20en=20cours,=20bad?= =?UTF-8?q?ge=20Lu=20sur=20cartes/fiche/accueil=20et=20bouton=20Marquer=20?= =?UTF-8?q?comme=20lu/non=20lu=20sur=20la=20fiche,=20avec=20tests=20api/sh?= =?UTF-8?q?ared/web=20=E2=80=94=20=C3=A9volution=20#52?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/books/books.controller.ts | 13 +- apps/api/src/books/books.service.test.ts | 85 +++++++++- apps/api/src/books/books.service.ts | 87 ++++++++-- apps/api/src/books/series.controller.ts | 5 +- .../api/src/database/database.service.test.ts | 12 ++ apps/api/src/database/database.service.ts | 33 ++++ apps/api/src/database/schema.ts | 3 + .../src/progress/progress.controller.test.ts | 29 ++++ apps/api/src/progress/progress.controller.ts | 11 +- .../api/src/progress/progress.service.test.ts | 158 ++++++++++++++++++ apps/api/src/progress/progress.service.ts | 69 +++++++- apps/web/src/api/client.ts | 26 ++- apps/web/src/components/BookCard.tsx | 3 +- apps/web/src/components/ui.tsx | 12 ++ apps/web/src/pages/BookPage.tsx | 31 +++- apps/web/src/pages/BookReadStatus.test.ts | 41 +++++ apps/web/src/pages/HomePage.tsx | 3 +- apps/web/src/styles/app.css | 21 +++ packages/shared/src/index.test.ts | 41 ++++- packages/shared/src/index.ts | 24 +++ 20 files changed, 664 insertions(+), 43 deletions(-) create mode 100644 apps/api/src/progress/progress.controller.test.ts create mode 100644 apps/api/src/progress/progress.service.test.ts create mode 100644 apps/web/src/pages/BookReadStatus.test.ts diff --git a/apps/api/src/books/books.controller.ts b/apps/api/src/books/books.controller.ts index e54b89a..5f64d86 100644 --- a/apps/api/src/books/books.controller.ts +++ b/apps/api/src/books/books.controller.ts @@ -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") diff --git a/apps/api/src/books/books.service.test.ts b/apps/api/src/books/books.service.test.ts index 3903156..478d3f8 100644 --- a/apps/api/src/books/books.service.test.ts +++ b/apps/api/src/books/books.service.test.ts @@ -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); diff --git a/apps/api/src/books/books.service.ts b/apps/api/src/books/books.service.ts index 73e7571..4e4e985 100644 --- a/apps/api/src/books/books.service.ts +++ b/apps/api/src/books/books.service.ts @@ -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>).map((row) => this.mapBookRow(row)); + return this.mapBookRows(rows as Array>, userId); } const rows = this.database.sqlite @@ -59,18 +67,18 @@ export class BooksService { ` ) .all(`${q.replace(/"/g, '""')}*`, limit, offset); - return (rows as Array>).map((row) => this.mapBookRow(row)); + return this.mapBookRows(rows as Array>, 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) { + private mapBookRows(rows: Array>, 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, 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 { + 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 { if (typeof value !== "string") return {}; try { diff --git a/apps/api/src/books/series.controller.ts b/apps/api/src/books/series.controller.ts index 7eb3e9a..97c5955 100644 --- a/apps/api/src/books/series.controller.ts +++ b/apps/api/src/books/series.controller.ts @@ -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); } } diff --git a/apps/api/src/database/database.service.test.ts b/apps/api/src/database/database.service.test.ts index d2303bd..ecad67d 100644 --- a/apps/api/src/database/database.service.test.ts +++ b/apps/api/src/database/database.service.test.ts @@ -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(); }); diff --git a/apps/api/src/database/database.service.ts b/apps/api/src/database/database.service.ts index ce3d873..be5c56d 100644 --- a/apps/api/src/database/database.service.ts +++ b/apps/api/src/database/database.service.ts @@ -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()); diff --git a/apps/api/src/database/schema.ts b/apps/api/src/database/schema.ts index 5783122..0f903e8 100644 --- a/apps/api/src/database/schema.ts +++ b/apps/api/src/database/schema.ts @@ -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() }, diff --git a/apps/api/src/progress/progress.controller.test.ts b/apps/api/src/progress/progress.controller.test.ts new file mode 100644 index 0000000..e3349d3 --- /dev/null +++ b/apps/api/src/progress/progress.controller.test.ts @@ -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" }); + }); +}); diff --git a/apps/api/src/progress/progress.controller.ts b/apps/api/src/progress/progress.controller.ts index 5f8839e..6acc05d 100644 --- a/apps/api/src/progress/progress.controller.ts +++ b/apps/api/src/progress/progress.controller.ts @@ -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); + } } diff --git a/apps/api/src/progress/progress.service.test.ts b/apps/api/src/progress/progress.service.test.ts new file mode 100644 index 0000000..e40b3c1 --- /dev/null +++ b/apps/api/src/progress/progress.service.test.ts @@ -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; + } +} diff --git a/apps/api/src/progress/progress.service.ts b/apps/api/src/progress/progress.service.ts index 1aa4d57..aa6907c 100644 --- a/apps/api/src/progress/progress.service.ts +++ b/apps/api/src/progress/progress.service.ts @@ -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"; } diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 1fdf1a0..cddc7d7 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -14,6 +14,7 @@ import type { UpdateAccountDto, UpdateMetadataSourcesConfigDto, UpdateProgressDto, + UpdateReadStatusDto, UserDto } from "@readabook/shared"; import { @@ -198,7 +199,30 @@ export const api = { return request(`/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 { + const updatedAt = new Date().toISOString(); + return request(`/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 { diff --git a/apps/web/src/components/BookCard.tsx b/apps/web/src/components/BookCard.tsx index a6725fc..27f1fe5 100644 --- a/apps/web/src/components/BookCard.tsx +++ b/apps/web/src/components/BookCard.tsx @@ -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
+ {volumeLabel && {volumeLabel}} {book.language ?? "langue inconnue"}
diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx index 03868f4..f03a22e 100644 --- a/apps/web/src/components/ui.tsx +++ b/apps/web/src/components/ui.tsx @@ -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
{children}
; @@ -32,6 +34,16 @@ export function FormatPill({ format }: { format: "epub" | "pdf" | "cbz" | "cbr" return {format.toUpperCase()}; } +export function ReadStatusBadge({ readStatus }: { readStatus?: ReadStatus }) { + if (readStatus !== "read") return null; + return ( + + + Lu + + ); +} + export function Meter({ value }: { value: number }) { return ( diff --git a/apps/web/src/pages/BookPage.tsx b/apps/web/src/pages/BookPage.tsx index 5b41915..ad9cd4c 100644 --- a/apps/web/src/pages/BookPage.tsx +++ b/apps/web/src/pages/BookPage.tsx @@ -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 } 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 }) { @@ -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(() => { let alive = true; loadBook().finally(() => { @@ -65,6 +86,7 @@ export function BookPage({ bookId }: { bookId: number }) { 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 }, @@ -84,6 +106,7 @@ export function BookPage({ bookId }: { bookId: number }) {
+ {book.language ?? "langue inconnue"} {bookMetadataStateLabel(book)}
@@ -108,6 +131,10 @@ export function BookPage({ bookId }: { bookId: number }) { Rayon + {series && (