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:
@ -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")
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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()
|
||||||
},
|
},
|
||||||
|
|||||||
29
apps/api/src/progress/progress.controller.test.ts
Normal file
29
apps/api/src/progress/progress.controller.test.ts
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
158
apps/api/src/progress/progress.service.test.ts
Normal file
158
apps/api/src/progress/progress.service.test.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)}%`}>
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
41
apps/web/src/pages/BookReadStatus.test.ts
Normal file
41
apps/web/src/pages/BookReadStatus.test.ts
Normal 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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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} />}
|
||||||
|
|||||||
@ -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%;
|
||||||
|
|||||||
@ -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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user