fix(web,api): lecteur PDF — options pdf.js, assets cmaps/fonts et recherche sans limite par défaut

This commit is contained in:
Git Agent
2026-08-24 14:15:25 +02:00
parent f8c8ffd45c
commit b3bd678291
14 changed files with 336 additions and 31 deletions

View File

@ -1,13 +1,15 @@
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
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 { BooksService } from "./books.service.js";
const previousDatabasePath = process.env.DATABASE_PATH;
const previousStorageDir = process.env.STORAGE_DIR;
const realBooksPath = "/home/anthony/Documents/Projects/ReadaBook/Books";
const tempDirs: string[] = [];
afterEach(() => {
@ -19,6 +21,59 @@ afterEach(() => {
});
describe("BooksService", () => {
it.runIf(canLoadBetterSqlite() && canReadRealBooksCorpus())("exposes every persisted real corpus book through the default catalogue query", () => {
const database = createDatabase();
try {
const service = new BooksService(database);
const now = database.now();
const library = database.db
.insert(libraries)
.values({ name: "Real corpus", path: realBooksPath, enabled: true, createdAt: now, updatedAt: now })
.returning()
.get();
const files = realCorpusBookFiles();
for (const [index, filePath] of files.entries()) {
database.db
.insert(books)
.values({
libraryId: library.id,
seriesId: null,
title: `Corpus ${String(index + 1).padStart(3, "0")}`,
author: null,
description: null,
isbn: null,
isbn13: null,
identifiersJson: null,
localMetadataJson: null,
language: null,
publisher: null,
publishedDate: null,
volumeNumber: null,
volumeLabel: null,
format: bookFormatFromPath(filePath),
filePath,
coverPath: null,
metadataStatus: "none",
metadataProvenanceJson: JSON.stringify({ title: "local" }),
scanStatus: "succeeded",
enrichmentStatus: "idle",
fileSize: statSync(filePath).size,
fileMtime: statSync(filePath).mtime.toISOString(),
createdAt: now,
updatedAt: now
})
.run();
}
expect(files.length).toBeGreaterThan(50);
expect(service.count()).toBe(files.length);
expect(service.list(BookQuerySchema.parse({}))).toHaveLength(files.length);
} finally {
database.onModuleDestroy();
}
});
it.runIf(canLoadBetterSqlite())("exposes metadata status and parsed provenance on book API rows", () => {
const database = createDatabase();
const service = new BooksService(database);
@ -178,3 +233,32 @@ function canLoadBetterSqlite(): boolean {
return false;
}
}
function canReadRealBooksCorpus(): boolean {
try {
return realCorpusBookFiles().length > 0;
} catch {
return false;
}
}
function realCorpusBookFiles(root = realBooksPath): string[] {
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
const path = join(root, entry.name);
if (entry.isDirectory()) return realCorpusBookFiles(path);
if (!entry.isFile()) return [];
return isBookFile(path) ? [path] : [];
});
}
function isBookFile(filePath: string): boolean {
return [".epub", ".pdf", ".cbz", ".cbr"].includes(extname(filePath).toLowerCase());
}
function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" | "cbr" {
const extension = extname(filePath).toLowerCase();
if (extension === ".epub") return "epub";
if (extension === ".cbz") return "cbz";
if (extension === ".cbr") return "cbr";
return "pdf";
}

View File

@ -20,18 +20,33 @@ export class BooksService {
if (query.q) {
return this.search(query.q, query.limit, query.offset);
}
return this.database.db
const statement = this.database.db
.select()
.from(books)
.where(filters.length ? and(...filters) : undefined)
.orderBy(books.title)
.limit(query.limit)
.offset(query.offset)
.all()
.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));
}
search(q: string, limit = 50, offset = 0) {
search(q: string, limit?: number, offset = 0) {
if (limit === undefined) {
const rows = this.database.sqlite
.prepare(
`
SELECT books.*
FROM book_fts
JOIN books ON books.id = book_fts.rowid
WHERE book_fts MATCH ?
ORDER BY bm25(book_fts)
`
)
.all(`${q.replace(/"/g, '""')}*`);
return (rows as Array<Record<string, unknown>>).map((row) => this.mapBookRow(row));
}
const rows = this.database.sqlite
.prepare(
`