merge: fix/reader-worker-metadata-search dans develop (worker pdf.js, shell lecteur, enrichissement par hints)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -5,11 +5,12 @@ import { AuthModule } from "./auth/auth.module.js";
|
|||||||
import { BooksModule } from "./books/books.module.js";
|
import { BooksModule } from "./books/books.module.js";
|
||||||
import { DatabaseModule } from "./database/database.module.js";
|
import { DatabaseModule } from "./database/database.module.js";
|
||||||
import { ProgressModule } from "./progress/progress.module.js";
|
import { ProgressModule } from "./progress/progress.module.js";
|
||||||
|
import { ReaderModule } from "./reader/reader.module.js";
|
||||||
import { ScannerModule } from "./scanner/scanner.module.js";
|
import { ScannerModule } from "./scanner/scanner.module.js";
|
||||||
import { HealthController } from "./health.controller.js";
|
import { HealthController } from "./health.controller.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ScannerModule, AutomationModule],
|
imports: [DatabaseModule, AuthModule, AdminModule, BooksModule, ProgressModule, ReaderModule, ScannerModule, AutomationModule],
|
||||||
controllers: [HealthController]
|
controllers: [HealthController]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@ -58,6 +58,7 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
isbn TEXT,
|
isbn TEXT,
|
||||||
isbn13 TEXT,
|
isbn13 TEXT,
|
||||||
identifiers_json TEXT,
|
identifiers_json TEXT,
|
||||||
|
local_metadata_json TEXT,
|
||||||
language TEXT,
|
language TEXT,
|
||||||
publisher TEXT,
|
publisher TEXT,
|
||||||
published_date TEXT,
|
published_date TEXT,
|
||||||
@ -143,6 +144,7 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
`);
|
`);
|
||||||
this.ensureBooksSupportsComicArchives();
|
this.ensureBooksSupportsComicArchives();
|
||||||
this.ensureBooksMetadataColumns();
|
this.ensureBooksMetadataColumns();
|
||||||
|
this.ensureReaderPreferencesTable();
|
||||||
this.ensureMetadataSourceConfigColumns();
|
this.ensureMetadataSourceConfigColumns();
|
||||||
this.ensureAutomationSettingsColumns();
|
this.ensureAutomationSettingsColumns();
|
||||||
this.ensureMetadataDefaults();
|
this.ensureMetadataDefaults();
|
||||||
@ -174,6 +176,7 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
isbn TEXT,
|
isbn TEXT,
|
||||||
isbn13 TEXT,
|
isbn13 TEXT,
|
||||||
identifiers_json TEXT,
|
identifiers_json TEXT,
|
||||||
|
local_metadata_json TEXT,
|
||||||
language TEXT,
|
language TEXT,
|
||||||
publisher TEXT,
|
publisher TEXT,
|
||||||
published_date TEXT,
|
published_date TEXT,
|
||||||
@ -186,11 +189,11 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
INSERT INTO books (
|
INSERT INTO books (
|
||||||
id, library_id, title, author, description, isbn, isbn13, identifiers_json, language, publisher, published_date,
|
id, library_id, title, author, description, isbn, isbn13, identifiers_json, local_metadata_json, 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
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
id, library_id, title, author, description, isbn, NULL, NULL, language, publisher, published_date,
|
id, library_id, title, author, description, isbn, NULL, NULL, NULL, 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
|
||||||
FROM books_legacy_format;
|
FROM books_legacy_format;
|
||||||
DROP TABLE books_legacy_format;
|
DROP TABLE books_legacy_format;
|
||||||
@ -232,7 +235,27 @@ export class DatabaseService implements OnModuleDestroy {
|
|||||||
if (!names.has("identifiers_json")) {
|
if (!names.has("identifiers_json")) {
|
||||||
this.sqlite.exec("ALTER TABLE books ADD COLUMN identifiers_json TEXT");
|
this.sqlite.exec("ALTER TABLE books ADD COLUMN identifiers_json TEXT");
|
||||||
}
|
}
|
||||||
|
if (!names.has("local_metadata_json")) {
|
||||||
|
this.sqlite.exec("ALTER TABLE books ADD COLUMN local_metadata_json TEXT");
|
||||||
|
}
|
||||||
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13)");
|
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13)");
|
||||||
|
this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_local_metadata_idx ON books(local_metadata_json)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureReaderPreferencesTable(): void {
|
||||||
|
this.sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS reader_preferences (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE,
|
||||||
|
mode TEXT NOT NULL CHECK (mode IN ('paged','scrolled','horizontal','vertical')),
|
||||||
|
fit TEXT CHECK (fit IN ('page','width','height','auto')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(user_id, book_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS reader_preferences_user_idx ON reader_preferences(user_id);
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureMetadataSourceConfigColumns(): void {
|
private ensureMetadataSourceConfigColumns(): void {
|
||||||
|
|||||||
@ -36,6 +36,7 @@ export const books = sqliteTable(
|
|||||||
isbn: text("isbn"),
|
isbn: text("isbn"),
|
||||||
isbn13: text("isbn13"),
|
isbn13: text("isbn13"),
|
||||||
identifiersJson: text("identifiers_json"),
|
identifiersJson: text("identifiers_json"),
|
||||||
|
localMetadataJson: text("local_metadata_json"),
|
||||||
language: text("language"),
|
language: text("language"),
|
||||||
publisher: text("publisher"),
|
publisher: text("publisher"),
|
||||||
publishedDate: text("published_date"),
|
publishedDate: text("published_date"),
|
||||||
@ -50,6 +51,24 @@ export const books = sqliteTable(
|
|||||||
(table) => ({ filePathIdx: uniqueIndex("books_file_path_unique").on(table.filePath) })
|
(table) => ({ filePathIdx: uniqueIndex("books_file_path_unique").on(table.filePath) })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const readerPreferences = sqliteTable(
|
||||||
|
"reader_preferences",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: integer("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
bookId: integer("book_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => books.id, { onDelete: "cascade" }),
|
||||||
|
mode: text("mode", { enum: ["paged", "scrolled", "horizontal", "vertical"] }).notNull(),
|
||||||
|
fit: text("fit", { enum: ["page", "width", "height", "auto"] }),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
updatedAt: text("updated_at").notNull()
|
||||||
|
},
|
||||||
|
(table) => ({ userBookIdx: uniqueIndex("reader_preferences_user_book_unique").on(table.userId, table.bookId) })
|
||||||
|
);
|
||||||
|
|
||||||
export const progress = sqliteTable(
|
export const progress = sqliteTable(
|
||||||
"progress",
|
"progress",
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { XMLParser } from "fast-xml-parser";
|
import { XMLParser } from "fast-xml-parser";
|
||||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
|
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
|
||||||
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
|
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
|
||||||
|
|
||||||
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", removeNSPrefix: true });
|
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", removeNSPrefix: true });
|
||||||
@ -11,30 +11,52 @@ export class BnfProvider implements MetadataProvider {
|
|||||||
|
|
||||||
async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise<MetadataMatch | null> {
|
async lookup(lookup: MetadataLookup, _config: MetadataProviderConfig): Promise<MetadataMatch | null> {
|
||||||
const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10;
|
const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10;
|
||||||
const query = isbn
|
if (!isbn) {
|
||||||
? `bib.isbn all "${isbn}"`
|
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, _config);
|
||||||
: `bib.title all "${lookup.title.replace(/"/g, " ")}"`;
|
return matches[0] ?? null;
|
||||||
|
}
|
||||||
|
const query = `bib.isbn all "${isbn}"`;
|
||||||
|
const matches = await this.searchSru(query, 1, lookup.identifiers.isbn13);
|
||||||
|
return matches[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchByMetadata(query: MetadataSearchQuery, _config: MetadataProviderConfig): Promise<MetadataMatch[]> {
|
||||||
|
const title = query.title.replace(/"/g, " ");
|
||||||
|
const author = query.author?.replace(/"/g, " ");
|
||||||
|
const sruQuery = [`bib.title all "${title}"`, author ? `bib.author all "${author}"` : null, query.year ? `bib.date all "${query.year}"` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" and ");
|
||||||
|
return this.searchSru(sruQuery, 5, query.isbn ? toIsbn13(query.isbn) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async searchSru(query: string, maximumRecords: number, expectedIsbn13: string | null): Promise<MetadataMatch[]> {
|
||||||
const url = new URL("https://catalogue.bnf.fr/api/SRU");
|
const url = new URL("https://catalogue.bnf.fr/api/SRU");
|
||||||
url.searchParams.set("version", "1.2");
|
url.searchParams.set("version", "1.2");
|
||||||
url.searchParams.set("operation", "searchRetrieve");
|
url.searchParams.set("operation", "searchRetrieve");
|
||||||
url.searchParams.set("query", query);
|
url.searchParams.set("query", query);
|
||||||
url.searchParams.set("maximumRecords", "1");
|
url.searchParams.set("maximumRecords", String(maximumRecords));
|
||||||
|
|
||||||
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return [];
|
||||||
const parsed = parser.parse(await response.text());
|
const parsed = parser.parse(await response.text());
|
||||||
const record = parsed?.searchRetrieveResponse?.records?.record?.recordData?.record;
|
const records = asArray(parsed?.searchRetrieveResponse?.records?.record)
|
||||||
if (!record) return null;
|
.map((entry) => (entry.recordData as Record<string, unknown> | undefined)?.record)
|
||||||
const fields = asArray(record.datafield);
|
.filter((record): record is Record<string, unknown> => Boolean(record));
|
||||||
return {
|
if (!records.length) return [];
|
||||||
title: subfield(fields, "200", "a") ?? undefined,
|
return records
|
||||||
author: subfield(fields, "200", "f") ?? ([subfield(fields, "700", "b"), subfield(fields, "700", "a")].filter(Boolean).join(" ") || null),
|
.map((record) => {
|
||||||
description: subfield(fields, "330", "a"),
|
const fields = asArray(record.datafield);
|
||||||
isbn: bestIsbn(fields, lookup.identifiers.isbn13),
|
return {
|
||||||
language: subfield(fields, "101", "a"),
|
title: subfield(fields, "200", "a") ?? undefined,
|
||||||
publisher: subfield(fields, "210", "c") ?? subfield(fields, "214", "c"),
|
author: subfield(fields, "200", "f") ?? ([subfield(fields, "700", "b"), subfield(fields, "700", "a")].filter(Boolean).join(" ") || null),
|
||||||
publishedDate: cleanDate(subfield(fields, "210", "d") ?? subfield(fields, "214", "d"))
|
description: subfield(fields, "330", "a"),
|
||||||
};
|
isbn: bestIsbn(fields, expectedIsbn13),
|
||||||
|
language: subfield(fields, "101", "a"),
|
||||||
|
publisher: subfield(fields, "210", "c") ?? subfield(fields, "214", "c"),
|
||||||
|
publishedDate: cleanDate(subfield(fields, "210", "d") ?? subfield(fields, "214", "d"))
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) => Number(Boolean(right.isbn)) - Number(Boolean(left.isbn)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,7 +82,11 @@ function bestIsbn(fields: Array<Record<string, unknown>>, expectedIsbn13: string
|
|||||||
.filter((item) => item["@_code"] === "a")
|
.filter((item) => item["@_code"] === "a")
|
||||||
.map((item) => String(item["#text"] ?? "").replace(/[^0-9X]/gi, ""))
|
.map((item) => String(item["#text"] ?? "").replace(/[^0-9X]/gi, ""))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return values.find((candidate) => toIsbn13(candidate) === expectedIsbn13) ?? values.find((candidate) => toIsbn13(candidate)) ?? null;
|
return (
|
||||||
|
values.find((candidate) => Boolean(expectedIsbn13) && toIsbn13(candidate) === expectedIsbn13) ??
|
||||||
|
values.find((candidate) => Boolean(toIsbn13(candidate))) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanDate(value: string | null): string | null {
|
function cleanDate(value: string | null): string | null {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
|
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
|
||||||
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
|
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -8,9 +8,11 @@ export class GoogleBooksProvider implements MetadataProvider {
|
|||||||
|
|
||||||
async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null> {
|
async lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null> {
|
||||||
const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10;
|
const isbn = lookup.identifiers.isbn13 ?? lookup.identifiers.isbn10;
|
||||||
const query = isbn
|
if (!isbn) {
|
||||||
? `isbn:${isbn}`
|
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, config);
|
||||||
: `intitle:${lookup.title}${lookup.author ? `+inauthor:${lookup.author}` : ""}`;
|
return matches[0] ?? null;
|
||||||
|
}
|
||||||
|
const query = `isbn:${isbn}`;
|
||||||
const url = new URL("https://www.googleapis.com/books/v1/volumes");
|
const url = new URL("https://www.googleapis.com/books/v1/volumes");
|
||||||
url.searchParams.set("q", query);
|
url.searchParams.set("q", query);
|
||||||
url.searchParams.set("maxResults", "1");
|
url.searchParams.set("maxResults", "1");
|
||||||
@ -32,6 +34,30 @@ export class GoogleBooksProvider implements MetadataProvider {
|
|||||||
isbn: isbnFromIndustryIdentifiers(info.industryIdentifiers, lookup.identifiers.isbn13)
|
isbn: isbnFromIndustryIdentifiers(info.industryIdentifiers, lookup.identifiers.isbn13)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise<MetadataMatch[]> {
|
||||||
|
const url = new URL("https://www.googleapis.com/books/v1/volumes");
|
||||||
|
url.searchParams.set("q", `intitle:${query.title}${query.author ? `+inauthor:${query.author}` : ""}`);
|
||||||
|
url.searchParams.set("maxResults", "5");
|
||||||
|
url.searchParams.set("printType", "books");
|
||||||
|
if (config.apiKey) url.searchParams.set("key", config.apiKey);
|
||||||
|
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(4000) });
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const data = (await response.json()) as { items?: Array<{ volumeInfo?: Record<string, unknown> }> };
|
||||||
|
return (data.items ?? [])
|
||||||
|
.map((item) => item.volumeInfo)
|
||||||
|
.filter((info): info is Record<string, unknown> => Boolean(info))
|
||||||
|
.map((info) => ({
|
||||||
|
title: stringValue(info.title) ?? undefined,
|
||||||
|
author: arrayJoin(info.authors),
|
||||||
|
description: stringValue(info.description),
|
||||||
|
language: stringValue(info.language),
|
||||||
|
publisher: stringValue(info.publisher),
|
||||||
|
publishedDate: stringValue(info.publishedDate),
|
||||||
|
isbn: isbnFromIndustryIdentifiers(info.industryIdentifiers, query.isbn ? toIsbn13(query.isbn) : null)
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringValue(value: unknown): string | null {
|
function stringValue(value: unknown): string | null {
|
||||||
@ -45,7 +71,7 @@ function arrayJoin(value: unknown): string | null {
|
|||||||
function isbnFromIndustryIdentifiers(value: unknown, expectedIsbn13: string | null): string | null {
|
function isbnFromIndustryIdentifiers(value: unknown, expectedIsbn13: string | null): string | null {
|
||||||
if (!Array.isArray(value)) return null;
|
if (!Array.isArray(value)) return null;
|
||||||
const entries = value as Array<{ type?: unknown; identifier?: unknown }>;
|
const entries = value as Array<{ type?: unknown; identifier?: unknown }>;
|
||||||
const matching = entries.find((entry) => toIsbn13(stringValue(entry.identifier) ?? "") === expectedIsbn13)?.identifier;
|
const matching = entries.find((entry) => Boolean(expectedIsbn13) && toIsbn13(stringValue(entry.identifier) ?? "") === expectedIsbn13)?.identifier;
|
||||||
if (matching) return stringValue(matching);
|
if (matching) return stringValue(matching);
|
||||||
const isbn13 = entries.find((entry) => entry.type === "ISBN_13")?.identifier;
|
const isbn13 = entries.find((entry) => entry.type === "ISBN_13")?.identifier;
|
||||||
const isbn10 = entries.find((entry) => entry.type === "ISBN_10")?.identifier;
|
const isbn10 = entries.find((entry) => entry.type === "ISBN_10")?.identifier;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
|
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LocalMetadataProvider implements MetadataProvider {
|
export class LocalMetadataProvider implements MetadataProvider {
|
||||||
@ -13,4 +13,15 @@ export class LocalMetadataProvider implements MetadataProvider {
|
|||||||
identifiers: lookup.identifiers
|
identifiers: lookup.identifiers
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchByMetadata(query: MetadataSearchQuery, _config: MetadataProviderConfig): Promise<MetadataMatch[]> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: query.title,
|
||||||
|
author: query.author,
|
||||||
|
isbn: query.isbn ?? null,
|
||||||
|
publishedDate: query.year ?? null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "../metadata.types.js";
|
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "../metadata.types.js";
|
||||||
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
|
import { toIsbn13 } from "../use-cases/extract-identifiers.js";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -11,26 +11,45 @@ export class OpenLibraryProvider implements MetadataProvider {
|
|||||||
if (isbn) {
|
if (isbn) {
|
||||||
return this.lookupIsbn(isbn, lookup.identifiers.isbn13);
|
return this.lookupIsbn(isbn, lookup.identifiers.isbn13);
|
||||||
}
|
}
|
||||||
const query = `title:${lookup.title}${lookup.author ? ` author:${lookup.author}` : ""}`;
|
if (lookup.sourceId) {
|
||||||
|
return this.lookupEdition(lookup.sourceId, lookup.identifiers.isbn13);
|
||||||
|
}
|
||||||
|
const matches = await this.searchByMetadata({ title: lookup.title, author: lookup.author, year: lookup.local.year }, _config);
|
||||||
|
return matches[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchByMetadata(query: MetadataSearchQuery, _config: MetadataProviderConfig): Promise<MetadataMatch[]> {
|
||||||
const url = new URL("https://openlibrary.org/search.json");
|
const url = new URL("https://openlibrary.org/search.json");
|
||||||
url.searchParams.set("q", query);
|
url.searchParams.set("title", query.title);
|
||||||
url.searchParams.set("limit", "1");
|
if (query.author) url.searchParams.set("author", query.author);
|
||||||
|
if (query.year) url.searchParams.set("first_publish_year", query.year);
|
||||||
|
url.searchParams.set("limit", "5");
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
|
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
|
||||||
signal: AbortSignal.timeout(4000)
|
signal: AbortSignal.timeout(4000)
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return [];
|
||||||
const data = (await response.json()) as { docs?: Array<Record<string, unknown>> };
|
const data = (await response.json()) as { docs?: Array<Record<string, unknown>> };
|
||||||
const doc = data.docs?.[0];
|
return (data.docs ?? []).map((doc) => ({
|
||||||
if (!doc) return null;
|
|
||||||
return {
|
|
||||||
title: stringValue(doc.title) ?? undefined,
|
title: stringValue(doc.title) ?? undefined,
|
||||||
|
sourceId: firstArrayValue(doc.edition_key) ?? stringValue(doc.cover_edition_key),
|
||||||
author: arrayJoin(doc.author_name),
|
author: arrayJoin(doc.author_name),
|
||||||
language: firstArrayValue(doc.language),
|
language: firstArrayValue(doc.language),
|
||||||
publisher: firstArrayValue(doc.publisher),
|
publisher: firstArrayValue(doc.publisher),
|
||||||
publishedDate: String(doc.first_publish_year ?? "") || null,
|
publishedDate: String(doc.first_publish_year ?? "") || null,
|
||||||
isbn: bestIsbn(doc.isbn, lookup.identifiers.isbn13)
|
isbn: bestIsbn(doc.isbn, query.isbn ? toIsbn13(query.isbn) : null)
|
||||||
};
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lookupEdition(sourceId: string, expectedIsbn13: string | null): Promise<MetadataMatch | null> {
|
||||||
|
const editionKey = sourceId.replace(/^\/?books\//, "");
|
||||||
|
if (!editionKey) return null;
|
||||||
|
const response = await fetch(`https://openlibrary.org/books/${encodeURIComponent(editionKey)}.json`, {
|
||||||
|
headers: { "User-Agent": "ReadaBook/0.1 local-library-manager" },
|
||||||
|
signal: AbortSignal.timeout(4000)
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return this.editionToMatch((await response.json()) as Record<string, unknown>, expectedIsbn13);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async lookupIsbn(isbn: string, expectedIsbn13: string | null): Promise<MetadataMatch | null> {
|
private async lookupIsbn(isbn: string, expectedIsbn13: string | null): Promise<MetadataMatch | null> {
|
||||||
@ -39,7 +58,10 @@ export class OpenLibraryProvider implements MetadataProvider {
|
|||||||
signal: AbortSignal.timeout(4000)
|
signal: AbortSignal.timeout(4000)
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const edition = (await response.json()) as Record<string, unknown>;
|
return this.editionToMatch((await response.json()) as Record<string, unknown>, expectedIsbn13);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async editionToMatch(edition: Record<string, unknown>, expectedIsbn13: string | null): Promise<MetadataMatch> {
|
||||||
const author = await this.lookupAuthorName(edition.authors);
|
const author = await this.lookupAuthorName(edition.authors);
|
||||||
return {
|
return {
|
||||||
title: stringValue(edition.title) ?? undefined,
|
title: stringValue(edition.title) ?? undefined,
|
||||||
@ -81,7 +103,11 @@ function arrayJoin(value: unknown): string | null {
|
|||||||
function bestIsbn(value: unknown, expectedIsbn13: string | null): string | null {
|
function bestIsbn(value: unknown, expectedIsbn13: string | null): string | null {
|
||||||
if (!Array.isArray(value)) return null;
|
if (!Array.isArray(value)) return null;
|
||||||
const values = value.map(String);
|
const values = value.map(String);
|
||||||
return values.find((candidate) => toIsbn13(candidate) === expectedIsbn13) ?? values.find((candidate) => toIsbn13(candidate)) ?? null;
|
return (
|
||||||
|
values.find((candidate) => Boolean(expectedIsbn13) && toIsbn13(candidate) === expectedIsbn13) ??
|
||||||
|
values.find((candidate) => Boolean(toIsbn13(candidate))) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function asStringArray(value: unknown): string[] {
|
function asStringArray(value: unknown): string[] {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ describe("ISBN normalization", () => {
|
|||||||
expect(normalizeIsbn("0-306-40615-2")).toBe("0306406152");
|
expect(normalizeIsbn("0-306-40615-2")).toBe("0306406152");
|
||||||
expect(normalizeIsbn("978-0-306-40615-7")).toBe("9780306406157");
|
expect(normalizeIsbn("978-0-306-40615-7")).toBe("9780306406157");
|
||||||
expect(normalizeIsbn("978-0-306-40615-8")).toBeNull();
|
expect(normalizeIsbn("978-0-306-40615-8")).toBeNull();
|
||||||
|
expect(normalizeIsbn("5030931067112")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converts ISBN-10 to ISBN-13", () => {
|
it("converts ISBN-10 to ISBN-13", () => {
|
||||||
|
|||||||
43
apps/api/src/metadata/local-metadata-hints.test.ts
Normal file
43
apps/api/src/metadata/local-metadata-hints.test.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ExtractLocalMetadataHints } from "./use-cases/extract-local-metadata-hints.js";
|
||||||
|
import { ScoreMetadataMatch } from "./use-cases/score-metadata-match.js";
|
||||||
|
|
||||||
|
describe("local metadata hints", () => {
|
||||||
|
it("extracts title, author and year hints from a book without ISBN", () => {
|
||||||
|
const hints = new ExtractLocalMetadataHints().fromMetadataAndFile(
|
||||||
|
{
|
||||||
|
title: "Harry Potter et le Prince de Sang Mele",
|
||||||
|
author: null,
|
||||||
|
description: null,
|
||||||
|
isbn: null,
|
||||||
|
language: null,
|
||||||
|
publisher: null,
|
||||||
|
publishedDate: null,
|
||||||
|
coverPath: null
|
||||||
|
},
|
||||||
|
"/library/Harry Potter et le Prince de Sang Mele (J. K. Rowling) 2005.epub"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hints).toMatchObject({
|
||||||
|
title: "Harry Potter et le Prince de Sang Mele",
|
||||||
|
author: "J. K. Rowling",
|
||||||
|
year: "2005",
|
||||||
|
isbn: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("metadata match scoring", () => {
|
||||||
|
it("keeps the best remote match for locally extracted title and author", () => {
|
||||||
|
const best = new ScoreMetadataMatch().best(
|
||||||
|
{ title: "Harry Potter et le Prince de Sang Mele", author: "J. K. Rowling", year: "2005" },
|
||||||
|
[
|
||||||
|
{ title: "Harry Potter et la chambre des secrets", author: "J. K. Rowling", publishedDate: "1998" },
|
||||||
|
{ title: "Harry Potter et le Prince de sang-mêlé", author: "J.K. Rowling", publishedDate: "2005" }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(best?.match.title).toBe("Harry Potter et le Prince de sang-mêlé");
|
||||||
|
expect(best?.score).toBeGreaterThan(0.7);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -8,7 +8,20 @@ const lookup: MetadataLookup = {
|
|||||||
title: "Harry Potter et la Chambre des Secrets",
|
title: "Harry Potter et la Chambre des Secrets",
|
||||||
author: "J. K. Rowling",
|
author: "J. K. Rowling",
|
||||||
filePath: "/library/HP/Harry Potter et la Chambre des Secrets (J.K. Rowling).epub",
|
filePath: "/library/HP/Harry Potter et la Chambre des Secrets (J.K. Rowling).epub",
|
||||||
identifiers: { isbn10: null, isbn13: "9782070612376", candidates: ["9782070612376"] }
|
identifiers: { isbn10: null, isbn13: "9782070612376", candidates: ["9782070612376"] },
|
||||||
|
local: {
|
||||||
|
title: "Harry Potter et la Chambre des Secrets",
|
||||||
|
author: "J. K. Rowling",
|
||||||
|
year: null,
|
||||||
|
isbn: "9782070612376",
|
||||||
|
fileTitle: "Harry Potter et la Chambre des Secrets (J.K. Rowling)",
|
||||||
|
raw: {
|
||||||
|
title: "Harry Potter et la Chambre des Secrets",
|
||||||
|
author: "J. K. Rowling",
|
||||||
|
publishedDate: null,
|
||||||
|
fileName: "Harry Potter et la Chambre des Secrets (J.K. Rowling)"
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const config: MetadataProviderConfig = { provider: "openlibrary", enabled: true, priority: 1, apiKey: null };
|
const config: MetadataProviderConfig = { provider: "openlibrary", enabled: true, priority: 1, apiKey: null };
|
||||||
@ -55,6 +68,67 @@ describe("metadata providers", () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("queries OpenLibrary by local metadata when ISBN is missing", async () => {
|
||||||
|
const fetchMock = vi.fn(async () =>
|
||||||
|
jsonResponse({
|
||||||
|
docs: [
|
||||||
|
{
|
||||||
|
title: "Harry Potter et le Prince de sang-mêlé",
|
||||||
|
author_name: ["J. K. Rowling"],
|
||||||
|
first_publish_year: 2005,
|
||||||
|
publisher: ["Gallimard jeunesse"],
|
||||||
|
cover_edition_key: "OL24333986M",
|
||||||
|
isbn: ["9782070612383"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await new OpenLibraryProvider().searchByMetadata(
|
||||||
|
{ title: "Harry Potter et le Prince de Sang Mele", author: "J. K. Rowling", year: "2005" },
|
||||||
|
config
|
||||||
|
);
|
||||||
|
const url = new URL(String((fetchMock.mock.calls[0] as unknown[])[0]));
|
||||||
|
|
||||||
|
expect(url.searchParams.get("title")).toBe("Harry Potter et le Prince de Sang Mele");
|
||||||
|
expect(url.searchParams.get("author")).toBe("J. K. Rowling");
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
title: "Harry Potter et le Prince de sang-mêlé",
|
||||||
|
sourceId: "OL24333986M",
|
||||||
|
author: "J. K. Rowling",
|
||||||
|
publishedDate: "2005"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("looks up OpenLibrary edition details from a search result source id", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
title: "Harry Potter et le prince de sang-mele",
|
||||||
|
authors: [{ key: "/authors/OL23919A" }],
|
||||||
|
languages: [{ key: "/languages/fre" }],
|
||||||
|
publishers: ["Gallimard jeunesse"],
|
||||||
|
publish_date: "2005",
|
||||||
|
isbn_13: ["9782070612383"],
|
||||||
|
description: { value: "Sixième année à Poudlard." }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ name: "J. K. Rowling" }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await new OpenLibraryProvider().lookup({ ...lookup, sourceId: "OL24333986M", identifiers: { isbn10: null, isbn13: null, candidates: [] } }, config);
|
||||||
|
|
||||||
|
expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toBe("https://openlibrary.org/books/OL24333986M.json");
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
title: "Harry Potter et le prince de sang-mele",
|
||||||
|
author: "J. K. Rowling",
|
||||||
|
isbn: "9782070612383",
|
||||||
|
description: "Sixième année à Poudlard."
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("parses BnF SRU UNIMARC records returned for ISBN lookup", async () => {
|
it("parses BnF SRU UNIMARC records returned for ISBN lookup", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@ -90,6 +164,42 @@ describe("metadata providers", () => {
|
|||||||
isbn: "9782070612376"
|
isbn: "9782070612376"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers BnF title search records with a valid book ISBN over non-book EAN records", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () =>
|
||||||
|
textResponse(`<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<srw:searchRetrieveResponse xmlns:srw="http://www.loc.gov/zing/srw/">
|
||||||
|
<srw:records>
|
||||||
|
<srw:record><srw:recordData>
|
||||||
|
<mxc:record xmlns:mxc="info:lc/xmlns/marcxchange-v2">
|
||||||
|
<mxc:datafield tag="073"><mxc:subfield code="a">5030931067112</mxc:subfield></mxc:datafield>
|
||||||
|
<mxc:datafield tag="200"><mxc:subfield code="a">Harry Potter et le prince de sang-mêlé</mxc:subfield><mxc:subfield code="f">Electronic arts</mxc:subfield></mxc:datafield>
|
||||||
|
</mxc:record>
|
||||||
|
</srw:recordData></srw:record>
|
||||||
|
<srw:record><srw:recordData>
|
||||||
|
<mxc:record xmlns:mxc="info:lc/xmlns/marcxchange-v2">
|
||||||
|
<mxc:datafield tag="010"><mxc:subfield code="a">274419736X</mxc:subfield></mxc:datafield>
|
||||||
|
<mxc:datafield tag="200"><mxc:subfield code="a">Harry Potter et le prince de sang-mêlé</mxc:subfield><mxc:subfield code="f">J. K. Rowling</mxc:subfield></mxc:datafield>
|
||||||
|
</mxc:record>
|
||||||
|
</srw:recordData></srw:record>
|
||||||
|
</srw:records>
|
||||||
|
</srw:searchRetrieveResponse>`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await new BnfProvider().searchByMetadata(
|
||||||
|
{ title: "Harry Potter et le prince de sang mele", author: null },
|
||||||
|
{ ...config, provider: "bnf" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
author: "J. K. Rowling",
|
||||||
|
isbn: "274419736X"
|
||||||
|
});
|
||||||
|
expect(result[1]?.isbn).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function jsonResponse(body: unknown, status = 200): Response {
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
|||||||
233
apps/api/src/metadata/metadata.service.test.ts
Normal file
233
apps/api/src/metadata/metadata.service.test.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { DatabaseService } from "../database/database.service.js";
|
||||||
|
import { BookMetadata } from "../scanner/metadata.js";
|
||||||
|
import { MetadataService } from "./metadata.service.js";
|
||||||
|
import { MetadataLookup, MetadataMatch, MetadataProvider, MetadataProviderConfig, MetadataSearchQuery } from "./metadata.types.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;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MetadataService", () => {
|
||||||
|
it.runIf(canLoadBetterSqlite())("backfills description from provider lookup after a metadata search hit yields an ISBN", async () => {
|
||||||
|
const database = createDatabase();
|
||||||
|
const localProvider = providerStub("local");
|
||||||
|
const openLibrarySearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise<MetadataMatch[]>>(async () => [
|
||||||
|
{
|
||||||
|
title: "Harry Potter et le Prince de sang-mêlé",
|
||||||
|
author: "J. K. Rowling",
|
||||||
|
isbn: "9782070612383",
|
||||||
|
publishedDate: "2005"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise<MetadataMatch | null>>(async (lookup) => {
|
||||||
|
if (lookup.identifiers.isbn13 !== "9782070612383") return null;
|
||||||
|
return {
|
||||||
|
title: "Harry Potter et le Prince de sang-mêlé",
|
||||||
|
author: "J. K. Rowling",
|
||||||
|
isbn: "9782070612383",
|
||||||
|
publishedDate: "2005",
|
||||||
|
description: "Harry Potter découvre l'héritage du Prince de Sang-Mêlé."
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const openLibraryProvider: MetadataProvider = {
|
||||||
|
id: "openlibrary",
|
||||||
|
searchByMetadata: openLibrarySearch,
|
||||||
|
lookup: openLibraryLookup
|
||||||
|
};
|
||||||
|
const service = new MetadataService(
|
||||||
|
database,
|
||||||
|
localProvider as never,
|
||||||
|
openLibraryProvider as never,
|
||||||
|
providerStub("googlebooks") as never,
|
||||||
|
providerStub("bnf") as never
|
||||||
|
);
|
||||||
|
const localMetadata: BookMetadata = {
|
||||||
|
title: "Harry Potter et le Prince de Sang Mele",
|
||||||
|
author: "J. K. Rowling",
|
||||||
|
description: null,
|
||||||
|
isbn: null,
|
||||||
|
language: null,
|
||||||
|
publisher: null,
|
||||||
|
publishedDate: null,
|
||||||
|
coverPath: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.enrichMetadata(localMetadata, "/library/HP/Harry Potter et le Prince de Sang Mele.epub", {
|
||||||
|
remote: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openLibrarySearch).toHaveBeenCalledOnce();
|
||||||
|
expect(openLibraryLookup).toHaveBeenCalledOnce();
|
||||||
|
expect(openLibraryLookup.mock.calls[0]?.[0].identifiers.isbn13).toBe("9782070612383");
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
title: "Harry Potter et le Prince de sang-mêlé",
|
||||||
|
isbn: "9782070612383",
|
||||||
|
isbn13: "9782070612383",
|
||||||
|
description: "Harry Potter découvre l'héritage du Prince de Sang-Mêlé."
|
||||||
|
});
|
||||||
|
|
||||||
|
database.onModuleDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.runIf(canLoadBetterSqlite())("looks up details from a title search hit even when the hit has no ISBN", async () => {
|
||||||
|
const database = createDatabase();
|
||||||
|
const localProvider = providerStub("local");
|
||||||
|
const openLibrarySearch = vi.fn<(_: MetadataSearchQuery, __: MetadataProviderConfig) => Promise<MetadataMatch[]>>(async () => [
|
||||||
|
{
|
||||||
|
title: "Harry Potter et le prince de sang-mele",
|
||||||
|
sourceId: "OL24333986M",
|
||||||
|
publishedDate: "2005"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const openLibraryLookup = vi.fn<(_: MetadataLookup, __: MetadataProviderConfig) => Promise<MetadataMatch | null>>(async (lookup) => {
|
||||||
|
if (lookup.sourceId !== "OL24333986M") return null;
|
||||||
|
return {
|
||||||
|
title: "Harry Potter et le prince de sang-mêlé",
|
||||||
|
author: "J. K. Rowling",
|
||||||
|
isbn: "9782070612383",
|
||||||
|
publishedDate: "2005",
|
||||||
|
description: "Sixième année à Poudlard."
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const openLibraryProvider: MetadataProvider = {
|
||||||
|
id: "openlibrary",
|
||||||
|
searchByMetadata: openLibrarySearch,
|
||||||
|
lookup: openLibraryLookup
|
||||||
|
};
|
||||||
|
const service = new MetadataService(
|
||||||
|
database,
|
||||||
|
localProvider as never,
|
||||||
|
openLibraryProvider as never,
|
||||||
|
providerStub("googlebooks") as never,
|
||||||
|
providerStub("bnf") as never
|
||||||
|
);
|
||||||
|
const localMetadata: BookMetadata = {
|
||||||
|
title: "Harry Potter et le prince de sang mele",
|
||||||
|
author: null,
|
||||||
|
description: null,
|
||||||
|
isbn: null,
|
||||||
|
language: null,
|
||||||
|
publisher: null,
|
||||||
|
publishedDate: null,
|
||||||
|
coverPath: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.enrichMetadata(localMetadata, "/library/Harry Potter et le prince de sang mele.epub", {
|
||||||
|
remote: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openLibrarySearch).toHaveBeenCalledOnce();
|
||||||
|
expect(openLibraryLookup).toHaveBeenCalledOnce();
|
||||||
|
expect(openLibraryLookup.mock.calls[0]?.[0].sourceId).toBe("OL24333986M");
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
title: "Harry Potter et le prince de sang-mêlé",
|
||||||
|
author: "J. K. Rowling",
|
||||||
|
isbn: "9782070612383",
|
||||||
|
isbn13: "9782070612383",
|
||||||
|
description: "Sixième année à Poudlard."
|
||||||
|
});
|
||||||
|
|
||||||
|
database.onModuleDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.runIf(canLoadBetterSqlite())("replaces an ambiguous title-only identification when a later provider supplies a described record", async () => {
|
||||||
|
const database = createDatabase();
|
||||||
|
database.sqlite.prepare("UPDATE metadata_source_config SET enabled = 1 WHERE provider = 'bnf'").run();
|
||||||
|
const openLibraryProvider: MetadataProvider = {
|
||||||
|
id: "openlibrary",
|
||||||
|
lookup: async () => null,
|
||||||
|
searchByMetadata: async () => [
|
||||||
|
{
|
||||||
|
title: "Daredevil",
|
||||||
|
author: "Rosemary Carter",
|
||||||
|
isbn: "9780373105601",
|
||||||
|
publisher: "Harlequin Books",
|
||||||
|
publishedDate: "1982"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const bnfProvider: MetadataProvider = {
|
||||||
|
id: "bnf",
|
||||||
|
lookup: async () => null,
|
||||||
|
searchByMetadata: async () => [
|
||||||
|
{
|
||||||
|
title: "Daredevil",
|
||||||
|
author: "scénario, Roy Thomas, Gary Friedrich",
|
||||||
|
isbn: "9782809476255",
|
||||||
|
description: "Daredevil affronte l'Homme aux échasses.",
|
||||||
|
language: "fre",
|
||||||
|
publisher: "Panini comics",
|
||||||
|
publishedDate: "2019"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const service = new MetadataService(
|
||||||
|
database,
|
||||||
|
providerStub("local") as never,
|
||||||
|
openLibraryProvider as never,
|
||||||
|
providerStub("googlebooks") as never,
|
||||||
|
bnfProvider as never
|
||||||
|
);
|
||||||
|
const localMetadata: BookMetadata = {
|
||||||
|
title: "Daredevil",
|
||||||
|
author: null,
|
||||||
|
description: null,
|
||||||
|
isbn: null,
|
||||||
|
language: null,
|
||||||
|
publisher: null,
|
||||||
|
publishedDate: null,
|
||||||
|
coverPath: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.enrichMetadata(localMetadata, "/library/Daredevil.cbz", { remote: true });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
title: "Daredevil",
|
||||||
|
author: "scénario, Roy Thomas, Gary Friedrich",
|
||||||
|
isbn: "9782809476255",
|
||||||
|
isbn13: "9782809476255",
|
||||||
|
description: "Daredevil affronte l'Homme aux échasses."
|
||||||
|
});
|
||||||
|
|
||||||
|
database.onModuleDestroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createDatabase(): DatabaseService {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "readabook-metadata-service-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
process.env.DATABASE_PATH = join(dir, "readabook.sqlite");
|
||||||
|
process.env.STORAGE_DIR = join(dir, "storage");
|
||||||
|
return new DatabaseService();
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerStub(id: MetadataProvider["id"]): MetadataProvider {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
lookup: async () => null,
|
||||||
|
searchByMetadata: async () => []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canLoadBetterSqlite(): boolean {
|
||||||
|
try {
|
||||||
|
const database = createDatabase();
|
||||||
|
database.onModuleDestroy();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,13 +11,17 @@ import { BnfProvider } from "./adapters/bnf.provider.js";
|
|||||||
import { GoogleBooksProvider } from "./adapters/google-books.provider.js";
|
import { GoogleBooksProvider } from "./adapters/google-books.provider.js";
|
||||||
import { LocalMetadataProvider } from "./adapters/local.provider.js";
|
import { LocalMetadataProvider } from "./adapters/local.provider.js";
|
||||||
import { OpenLibraryProvider } from "./adapters/open-library.provider.js";
|
import { OpenLibraryProvider } from "./adapters/open-library.provider.js";
|
||||||
import { MetadataMatch, MetadataProvider, MetadataProviderConfig } from "./metadata.types.js";
|
import { BookIdentifiers, LocalMetadataHints, MetadataMatch, MetadataProvider, MetadataProviderConfig } from "./metadata.types.js";
|
||||||
import { ExtractIdentifiers, toIsbn13 } from "./use-cases/extract-identifiers.js";
|
import { ExtractIdentifiers, toIsbn13 } from "./use-cases/extract-identifiers.js";
|
||||||
|
import { ExtractLocalMetadataHints } from "./use-cases/extract-local-metadata-hints.js";
|
||||||
import { ResolveProviderChain } from "./use-cases/resolve-provider-chain.js";
|
import { ResolveProviderChain } from "./use-cases/resolve-provider-chain.js";
|
||||||
|
import { ScoreMetadataMatch } from "./use-cases/score-metadata-match.js";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MetadataService {
|
export class MetadataService {
|
||||||
private readonly extractIdentifiers = new ExtractIdentifiers();
|
private readonly extractIdentifiers = new ExtractIdentifiers();
|
||||||
|
private readonly extractLocalMetadataHints = new ExtractLocalMetadataHints();
|
||||||
|
private readonly scoreMetadataMatch = new ScoreMetadataMatch();
|
||||||
private readonly resolveProviderChain: ResolveProviderChain;
|
private readonly resolveProviderChain: ResolveProviderChain;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -67,8 +71,13 @@ export class MetadataService {
|
|||||||
return this.getSourcesConfig();
|
return this.getSourcesConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
async enrichMetadata(localMetadata: BookMetadata, filePath: string, options: { remote: boolean }): Promise<BookMetadata & { isbn13: string | null; identifiersJson: string }> {
|
async enrichMetadata(
|
||||||
|
localMetadata: BookMetadata,
|
||||||
|
filePath: string,
|
||||||
|
options: { remote: boolean }
|
||||||
|
): Promise<BookMetadata & { isbn13: string | null; identifiersJson: string; localMetadataJson: string }> {
|
||||||
const identifiers = this.extractIdentifiers.fromMetadataAndFile(localMetadata, filePath);
|
const identifiers = this.extractIdentifiers.fromMetadataAndFile(localMetadata, filePath);
|
||||||
|
const local = this.extractLocalMetadataHints.fromMetadataAndFile(localMetadata, filePath);
|
||||||
const configs = this.getProviderConfigs();
|
const configs = this.getProviderConfigs();
|
||||||
const chain = options.remote
|
const chain = options.remote
|
||||||
? this.resolveProviderChain.resolve(configs)
|
? this.resolveProviderChain.resolve(configs)
|
||||||
@ -77,16 +86,42 @@ export class MetadataService {
|
|||||||
|
|
||||||
for (const { provider, config } of chain) {
|
for (const { provider, config } of chain) {
|
||||||
try {
|
try {
|
||||||
const match = await provider.lookup(
|
const hasIsbn = Boolean(identifiers.isbn13 ?? identifiers.isbn10);
|
||||||
{
|
const match =
|
||||||
title: merged.title,
|
provider.id === "local" || hasIsbn
|
||||||
author: merged.author,
|
? await provider.lookup(
|
||||||
filePath,
|
{
|
||||||
identifiers
|
title: merged.title,
|
||||||
},
|
author: merged.author,
|
||||||
config
|
filePath,
|
||||||
);
|
sourceId: null,
|
||||||
if (match) merged = mergeMetadata(merged, match);
|
identifiers,
|
||||||
|
local
|
||||||
|
},
|
||||||
|
config
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (match) {
|
||||||
|
merged = mergeMetadata(merged, match);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!options.remote || provider.id === "local") continue;
|
||||||
|
const query = {
|
||||||
|
title: local.title,
|
||||||
|
author: local.author,
|
||||||
|
year: local.year,
|
||||||
|
isbn: identifiers.isbn13 ?? identifiers.isbn10 ?? local.isbn
|
||||||
|
};
|
||||||
|
const best = this.scoreMetadataMatch.best(query, await provider.searchByMetadata(query, config));
|
||||||
|
if (best) {
|
||||||
|
if (!isActionableSearchMatch(best.match)) continue;
|
||||||
|
merged = shouldReplaceAmbiguousIdentification(localMetadata, merged, best.match)
|
||||||
|
? mergeMetadata({ ...localMetadata, author: null, isbn: null, description: null, language: null, publisher: null, publishedDate: null }, best.match)
|
||||||
|
: mergeMetadata(merged, best.match);
|
||||||
|
|
||||||
|
const detailedMatch = await this.lookupSearchMatchDetails(provider, config, filePath, identifiers, local, merged, best.match);
|
||||||
|
if (detailedMatch) merged = mergeMetadata(merged, detailedMatch);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Provider failures must not block local ingestion.
|
// Provider failures must not block local ingestion.
|
||||||
}
|
}
|
||||||
@ -97,7 +132,8 @@ export class MetadataService {
|
|||||||
...merged,
|
...merged,
|
||||||
isbn: merged.isbn ?? isbn13 ?? identifiers.isbn10,
|
isbn: merged.isbn ?? isbn13 ?? identifiers.isbn10,
|
||||||
isbn13,
|
isbn13,
|
||||||
identifiersJson: JSON.stringify(identifiers)
|
identifiersJson: JSON.stringify(identifiers),
|
||||||
|
localMetadataJson: JSON.stringify(local)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,6 +160,7 @@ export class MetadataService {
|
|||||||
isbn: enriched.isbn,
|
isbn: enriched.isbn,
|
||||||
isbn13: enriched.isbn13,
|
isbn13: enriched.isbn13,
|
||||||
identifiersJson: enriched.identifiersJson,
|
identifiersJson: enriched.identifiersJson,
|
||||||
|
localMetadataJson: enriched.localMetadataJson,
|
||||||
language: enriched.language,
|
language: enriched.language,
|
||||||
publisher: enriched.publisher,
|
publisher: enriched.publisher,
|
||||||
publishedDate: enriched.publishedDate,
|
publishedDate: enriched.publishedDate,
|
||||||
@ -151,6 +188,48 @@ export class MetadataService {
|
|||||||
private getAutomationRow(): typeof automationSettings.$inferSelect {
|
private getAutomationRow(): typeof automationSettings.$inferSelect {
|
||||||
return this.database.db.select().from(automationSettings).where(eq(automationSettings.id, 1)).get()!;
|
return this.database.db.select().from(automationSettings).where(eq(automationSettings.id, 1)).get()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async lookupSearchMatchDetails(
|
||||||
|
provider: MetadataProvider,
|
||||||
|
config: MetadataProviderConfig,
|
||||||
|
filePath: string,
|
||||||
|
identifiers: BookIdentifiers,
|
||||||
|
local: LocalMetadataHints,
|
||||||
|
merged: BookMetadata,
|
||||||
|
match: MetadataMatch
|
||||||
|
): Promise<MetadataMatch | null> {
|
||||||
|
const derivedIdentifiers = {
|
||||||
|
...identifiers,
|
||||||
|
isbn13: identifiers.isbn13 ?? (match.isbn ? toIsbn13(match.isbn) : null),
|
||||||
|
isbn10: identifiers.isbn10 ?? match.isbn ?? null,
|
||||||
|
candidates: [...new Set([...identifiers.candidates, ...(match.isbn ? [match.isbn] : [])])]
|
||||||
|
};
|
||||||
|
const hasNewIdentifier = derivedIdentifiers.isbn13 !== identifiers.isbn13 || derivedIdentifiers.isbn10 !== identifiers.isbn10;
|
||||||
|
const hasLookupTarget = hasNewIdentifier || Boolean(match.sourceId);
|
||||||
|
const needsDetails = !merged.description && hasLookupTarget;
|
||||||
|
if (!hasLookupTarget && !needsDetails) return null;
|
||||||
|
|
||||||
|
return provider.lookup(
|
||||||
|
{
|
||||||
|
title: merged.title,
|
||||||
|
author: merged.author,
|
||||||
|
filePath,
|
||||||
|
sourceId: match.sourceId,
|
||||||
|
identifiers: derivedIdentifiers,
|
||||||
|
local
|
||||||
|
},
|
||||||
|
config
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActionableSearchMatch(match: MetadataMatch): boolean {
|
||||||
|
return Boolean(match.isbn ?? match.sourceId ?? match.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldReplaceAmbiguousIdentification(local: BookMetadata, current: BookMetadata, next: MetadataMatch): boolean {
|
||||||
|
if (local.author || local.isbn || !current.isbn || !next.isbn || current.isbn === next.isbn) return false;
|
||||||
|
return Boolean(next.description);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeMetadata(current: BookMetadata, next: MetadataMatch): BookMetadata {
|
function mergeMetadata(current: BookMetadata, next: MetadataMatch): BookMetadata {
|
||||||
|
|||||||
@ -12,10 +12,34 @@ export type MetadataLookup = {
|
|||||||
title: string;
|
title: string;
|
||||||
author: string | null;
|
author: string | null;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
sourceId?: string | null;
|
||||||
identifiers: BookIdentifiers;
|
identifiers: BookIdentifiers;
|
||||||
|
local: LocalMetadataHints;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocalMetadataHints = {
|
||||||
|
title: string;
|
||||||
|
author: string | null;
|
||||||
|
year: string | null;
|
||||||
|
isbn: string | null;
|
||||||
|
fileTitle: string;
|
||||||
|
raw: {
|
||||||
|
title: string;
|
||||||
|
author: string | null;
|
||||||
|
publishedDate: string | null;
|
||||||
|
fileName: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MetadataSearchQuery = {
|
||||||
|
title: string;
|
||||||
|
author: string | null;
|
||||||
|
year?: string | null;
|
||||||
|
isbn?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MetadataMatch = Partial<BookMetadata> & {
|
export type MetadataMatch = Partial<BookMetadata> & {
|
||||||
|
sourceId?: string | null;
|
||||||
identifiers?: Partial<BookIdentifiers>;
|
identifiers?: Partial<BookIdentifiers>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -29,4 +53,5 @@ export type MetadataProviderConfig = {
|
|||||||
export interface MetadataProvider {
|
export interface MetadataProvider {
|
||||||
readonly id: MetadataProviderId;
|
readonly id: MetadataProviderId;
|
||||||
lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null>;
|
lookup(lookup: MetadataLookup, config: MetadataProviderConfig): Promise<MetadataMatch | null>;
|
||||||
|
searchByMetadata(query: MetadataSearchQuery, config: MetadataProviderConfig): Promise<MetadataMatch[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import { ResolveProviderChain } from "./use-cases/resolve-provider-chain.js";
|
|||||||
|
|
||||||
const provider = (id: MetadataProvider["id"]): MetadataProvider => ({
|
const provider = (id: MetadataProvider["id"]): MetadataProvider => ({
|
||||||
id,
|
id,
|
||||||
lookup: async () => null
|
lookup: async () => null,
|
||||||
|
searchByMetadata: async () => []
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ResolveProviderChain", () => {
|
describe("ResolveProviderChain", () => {
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export class ExtractIdentifiers {
|
|||||||
export function normalizeIsbn(value: string): string | null {
|
export function normalizeIsbn(value: string): string | null {
|
||||||
const compact = value.replace(/[^0-9X]/gi, "").toUpperCase();
|
const compact = value.replace(/[^0-9X]/gi, "").toUpperCase();
|
||||||
if (compact.length === 10 && isValidIsbn10(compact)) return compact;
|
if (compact.length === 10 && isValidIsbn10(compact)) return compact;
|
||||||
if (compact.length === 13 && isValidIsbn13(compact)) return compact;
|
if (compact.length === 13 && /^97[89]/.test(compact) && isValidIsbn13(compact)) return compact;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
import { basename, extname } from "node:path";
|
||||||
|
import { BookMetadata } from "../../scanner/metadata.js";
|
||||||
|
import { LocalMetadataHints } from "../metadata.types.js";
|
||||||
|
|
||||||
|
export class ExtractLocalMetadataHints {
|
||||||
|
fromMetadataAndFile(metadata: BookMetadata, filePath: string): LocalMetadataHints {
|
||||||
|
const fileName = basename(filePath, extname(filePath));
|
||||||
|
const parsed = parseFileName(fileName);
|
||||||
|
const title = cleanTitle(metadata.title) || parsed.title || fileName;
|
||||||
|
const author = cleanValue(metadata.author) ?? parsed.author;
|
||||||
|
const year = yearFrom(metadata.publishedDate) ?? parsed.year;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
year,
|
||||||
|
isbn: cleanValue(metadata.isbn),
|
||||||
|
fileTitle: parsed.title ?? fileName,
|
||||||
|
raw: {
|
||||||
|
title: metadata.title,
|
||||||
|
author: metadata.author,
|
||||||
|
publishedDate: metadata.publishedDate,
|
||||||
|
fileName
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFileName(fileName: string): { title: string | null; author: string | null; year: string | null } {
|
||||||
|
let value = fileName.replace(/[_]+/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
const year = yearFrom(value);
|
||||||
|
if (year) value = value.replace(new RegExp(`\\b${year}\\b`), " ");
|
||||||
|
|
||||||
|
const parenthetical = [...value.matchAll(/\(([^()]{2,120})\)/g)].map((match) => match[1].trim());
|
||||||
|
const authorFromParentheses = parenthetical.find((item) => looksLikeAuthor(item)) ?? null;
|
||||||
|
value = value.replace(/\([^()]*\)/g, " ");
|
||||||
|
|
||||||
|
const split = value.match(/^(.+?)\s+-\s+(.+)$/);
|
||||||
|
const title = cleanTitle(split?.[1] ?? value);
|
||||||
|
const author = cleanValue(split?.[2]) ?? authorFromParentheses;
|
||||||
|
return { title, author, year };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanTitle(value: string | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const cleaned = value
|
||||||
|
.replace(/\[[^\]]*\]/g, " ")
|
||||||
|
.replace(/\b(epub|pdf|retail|ebook|scan)\b/gi, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
return cleaned || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanValue(value: string | null | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const cleaned = value.replace(/\s+/g, " ").trim();
|
||||||
|
return cleaned || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yearFrom(value: string | null): string | null {
|
||||||
|
return value?.match(/\b(1[5-9]\d{2}|20\d{2})\b/)?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeAuthor(value: string): boolean {
|
||||||
|
return /[A-Za-zÀ-ÖØ-öø-ÿ]/.test(value) && (value.includes(".") || value.includes(" ") || /^[A-Z][a-z]+$/.test(value));
|
||||||
|
}
|
||||||
58
apps/api/src/metadata/use-cases/score-metadata-match.ts
Normal file
58
apps/api/src/metadata/use-cases/score-metadata-match.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { MetadataMatch, MetadataSearchQuery } from "../metadata.types.js";
|
||||||
|
|
||||||
|
export type ScoredMetadataMatch = {
|
||||||
|
match: MetadataMatch;
|
||||||
|
score: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ScoreMetadataMatch {
|
||||||
|
score(query: MetadataSearchQuery, match: MetadataMatch): number {
|
||||||
|
let score = 0;
|
||||||
|
const titleScore = similarity(normalize(query.title), normalize(match.title ?? ""));
|
||||||
|
score += titleScore * 0.7;
|
||||||
|
|
||||||
|
if (query.author && match.author) {
|
||||||
|
score += similarity(normalize(query.author), normalize(match.author)) * 0.2;
|
||||||
|
} else if (!query.author) {
|
||||||
|
score += 0.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryYear = query.year ?? null;
|
||||||
|
const matchYear = match.publishedDate?.match(/\b(1[5-9]\d{2}|20\d{2})\b/)?.[1] ?? null;
|
||||||
|
if (queryYear && matchYear) score += queryYear === matchYear ? 0.1 : -0.1;
|
||||||
|
|
||||||
|
if (query.isbn && match.isbn && query.isbn.replace(/\D/g, "") === match.isbn.replace(/\D/g, "")) {
|
||||||
|
score += 0.25;
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.min(1, score));
|
||||||
|
}
|
||||||
|
|
||||||
|
best(query: MetadataSearchQuery, matches: MetadataMatch[], minimumScore = 0.55): ScoredMetadataMatch | null {
|
||||||
|
const scored = matches
|
||||||
|
.map((match) => ({ match, score: this.score(query, match) }))
|
||||||
|
.sort((left, right) => right.score - left.score);
|
||||||
|
const best = scored[0];
|
||||||
|
return best && best.score >= minimumScore ? best : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.replace(/\b(le|la|les|the|a|an|de|du|des|et|and)\b/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function similarity(left: string, right: string): number {
|
||||||
|
if (!left || !right) return 0;
|
||||||
|
if (left === right) return 1;
|
||||||
|
const leftTokens = new Set(left.split(" "));
|
||||||
|
const rightTokens = new Set(right.split(" "));
|
||||||
|
const intersection = [...leftTokens].filter((token) => rightTokens.has(token)).length;
|
||||||
|
const union = new Set([...leftTokens, ...rightTokens]).size;
|
||||||
|
return union ? intersection / union : 0;
|
||||||
|
}
|
||||||
26
apps/api/src/reader/reader-preferences.controller.ts
Normal file
26
apps/api/src/reader/reader-preferences.controller.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common";
|
||||||
|
import { UpdateReaderPreferencesDto, UpdateReaderPreferencesSchema } 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 { ReaderPreferencesService } from "./reader-preferences.service.js";
|
||||||
|
|
||||||
|
@Controller("reader/preferences")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class ReaderPreferencesController {
|
||||||
|
constructor(private readonly preferences: ReaderPreferencesService) {}
|
||||||
|
|
||||||
|
@Get(":bookId")
|
||||||
|
get(@CurrentUserParam() user: CurrentUser, @Param("bookId") bookId: string) {
|
||||||
|
return this.preferences.find(user.id, Number(bookId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(":bookId")
|
||||||
|
update(
|
||||||
|
@CurrentUserParam() user: CurrentUser,
|
||||||
|
@Param("bookId") bookId: string,
|
||||||
|
@Body(new ZodValidationPipe(UpdateReaderPreferencesSchema)) body: UpdateReaderPreferencesDto
|
||||||
|
) {
|
||||||
|
return this.preferences.upsert(user.id, Number(bookId), body);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/api/src/reader/reader-preferences.service.ts
Normal file
47
apps/api/src/reader/reader-preferences.service.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { UpdateReaderPreferencesDto } from "@readabook/shared";
|
||||||
|
import { DatabaseService } from "../database/database.service.js";
|
||||||
|
import { books, readerPreferences } from "../database/schema.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReaderPreferencesService {
|
||||||
|
constructor(private readonly database: DatabaseService) {}
|
||||||
|
|
||||||
|
find(userId: number, bookId: number) {
|
||||||
|
const row = this.database.db
|
||||||
|
.select({
|
||||||
|
mode: readerPreferences.mode,
|
||||||
|
fit: readerPreferences.fit,
|
||||||
|
updatedAt: readerPreferences.updatedAt
|
||||||
|
})
|
||||||
|
.from(readerPreferences)
|
||||||
|
.where(sql`${readerPreferences.userId} = ${userId} AND ${readerPreferences.bookId} = ${bookId}`)
|
||||||
|
.get();
|
||||||
|
return row ?? { mode: "paged", fit: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert(userId: number, bookId: number, input: UpdateReaderPreferencesDto) {
|
||||||
|
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");
|
||||||
|
|
||||||
|
const current = this.find(userId, bookId);
|
||||||
|
const now = this.database.now();
|
||||||
|
const mode = input.mode ?? current.mode;
|
||||||
|
const fit = input.fit === undefined ? current.fit : input.fit;
|
||||||
|
|
||||||
|
this.database.sqlite
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO reader_preferences(user_id, book_id, mode, fit, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, book_id) DO UPDATE SET
|
||||||
|
mode = excluded.mode,
|
||||||
|
fit = excluded.fit,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.run(userId, bookId, mode, fit, now, now);
|
||||||
|
return this.find(userId, bookId);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/reader/reader.module.ts
Normal file
12
apps/api/src/reader/reader.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module.js";
|
||||||
|
import { DatabaseModule } from "../database/database.module.js";
|
||||||
|
import { ReaderPreferencesController } from "./reader-preferences.controller.js";
|
||||||
|
import { ReaderPreferencesService } from "./reader-preferences.service.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, DatabaseModule],
|
||||||
|
controllers: [ReaderPreferencesController],
|
||||||
|
providers: [ReaderPreferencesService]
|
||||||
|
})
|
||||||
|
export class ReaderModule {}
|
||||||
@ -109,6 +109,7 @@ export class ScannerService {
|
|||||||
isbn: metadata.isbn,
|
isbn: metadata.isbn,
|
||||||
isbn13: metadata.isbn13,
|
isbn13: metadata.isbn13,
|
||||||
identifiersJson: metadata.identifiersJson,
|
identifiersJson: metadata.identifiersJson,
|
||||||
|
localMetadataJson: metadata.localMetadataJson,
|
||||||
language: metadata.language,
|
language: metadata.language,
|
||||||
publisher: metadata.publisher,
|
publisher: metadata.publisher,
|
||||||
publishedDate: metadata.publishedDate,
|
publishedDate: metadata.publishedDate,
|
||||||
@ -137,6 +138,19 @@ export class ScannerService {
|
|||||||
isbn: null,
|
isbn: null,
|
||||||
isbn13: null,
|
isbn13: null,
|
||||||
identifiersJson: JSON.stringify({ candidates: [], isbn10: null, isbn13: null }),
|
identifiersJson: JSON.stringify({ candidates: [], isbn10: null, isbn13: null }),
|
||||||
|
localMetadataJson: JSON.stringify({
|
||||||
|
title: basename(filePath, extname(filePath)),
|
||||||
|
author: null,
|
||||||
|
year: null,
|
||||||
|
isbn: null,
|
||||||
|
fileTitle: basename(filePath, extname(filePath)),
|
||||||
|
raw: {
|
||||||
|
title: basename(filePath, extname(filePath)),
|
||||||
|
author: null,
|
||||||
|
publishedDate: null,
|
||||||
|
fileName: basename(filePath, extname(filePath))
|
||||||
|
}
|
||||||
|
}),
|
||||||
language: null,
|
language: null,
|
||||||
publisher: null,
|
publisher: null,
|
||||||
publishedDate: null,
|
publishedDate: null,
|
||||||
|
|||||||
@ -51,6 +51,22 @@ describe("api fallback helpers", () => {
|
|||||||
await expect(api.scanLibrary(42)).rejects.toThrow("offline");
|
await expect(api.scanLibrary(42)).rejects.toThrow("offline");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps reader preferences locally when the backend contract is absent", async () => {
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
vi.stubGlobal("localStorage", {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => storage.set(key, value),
|
||||||
|
removeItem: (key: string) => storage.delete(key),
|
||||||
|
clear: () => storage.clear()
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 404, statusText: "Not Found" }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await expect(api.readerPreferences(8)).resolves.toEqual({ mode: "horizontal", fit: "page" });
|
||||||
|
await expect(api.saveReaderPreferences(8, { mode: "vertical", fit: "width" })).resolves.toEqual({ mode: "vertical", fit: "width" });
|
||||||
|
expect(storage.get("readabook:reader-preferences:8")).toBe(JSON.stringify({ mode: "vertical", fit: "width" }));
|
||||||
|
});
|
||||||
|
|
||||||
it("sends metadata source updates to the admin endpoint", async () => {
|
it("sends metadata source updates to the admin endpoint", async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
new Response(JSON.stringify({ isbnPriorityEnabled: false, sources: [] }), {
|
new Response(JSON.stringify({ isbnPriorityEnabled: false, sources: [] }), {
|
||||||
|
|||||||
@ -26,9 +26,10 @@ import {
|
|||||||
mockProgress,
|
mockProgress,
|
||||||
mockUser
|
mockUser
|
||||||
} from "./mockData";
|
} from "./mockData";
|
||||||
import type { CbzPagesDto, ContinueItem, Session } from "./types";
|
import type { CbzPagesDto, ContinueItem, ReaderPreferencesDto, Session } from "./types";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
|
const READER_PREFERENCES_PREFIX = "readabook:reader-preferences:";
|
||||||
|
|
||||||
type RequestOptions = RequestInit & {
|
type RequestOptions = RequestInit & {
|
||||||
fallback?: unknown;
|
fallback?: unknown;
|
||||||
@ -111,6 +112,30 @@ function queryString(query: Partial<BookQueryDto>): string {
|
|||||||
return value ? `?${value}` : "";
|
return value ? `?${value}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readerPreferencesKey(bookId: number): string {
|
||||||
|
return `${READER_PREFERENCES_PREFIX}${bookId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLocalReaderPreferences(bookId: number): ReaderPreferencesDto {
|
||||||
|
if (typeof localStorage === "undefined") return { mode: "horizontal", fit: "page" };
|
||||||
|
const raw = localStorage.getItem(readerPreferencesKey(bookId));
|
||||||
|
if (!raw) return { mode: "horizontal", fit: "page" };
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<ReaderPreferencesDto>;
|
||||||
|
return {
|
||||||
|
mode: parsed.mode === "vertical" ? "vertical" : "horizontal",
|
||||||
|
fit: parsed.fit === "width" ? "width" : "page"
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { mode: "horizontal", fit: "page" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLocalReaderPreferences(bookId: number, preferences: ReaderPreferencesDto): void {
|
||||||
|
if (typeof localStorage === "undefined") return;
|
||||||
|
localStorage.setItem(readerPreferencesKey(bookId), JSON.stringify(preferences));
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
async session(): Promise<Session> {
|
async session(): Promise<Session> {
|
||||||
try {
|
try {
|
||||||
@ -176,6 +201,31 @@ export const api = {
|
|||||||
fallback: { bookId, ...input, updatedAt: new Date().toISOString() }
|
fallback: { bookId, ...input, updatedAt: new Date().toISOString() }
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async readerPreferences(bookId: number): Promise<ReaderPreferencesDto> {
|
||||||
|
try {
|
||||||
|
const preferences = await request<ReaderPreferencesDto>(`/reader/preferences/${bookId}`, {
|
||||||
|
fallback: readLocalReaderPreferences(bookId)
|
||||||
|
});
|
||||||
|
writeLocalReaderPreferences(bookId, preferences);
|
||||||
|
return preferences;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto;
|
||||||
|
return readLocalReaderPreferences(bookId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async saveReaderPreferences(bookId: number, input: ReaderPreferencesDto): Promise<ReaderPreferencesDto> {
|
||||||
|
writeLocalReaderPreferences(bookId, input);
|
||||||
|
try {
|
||||||
|
return await request<ReaderPreferencesDto>(`/reader/preferences/${bookId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
fallback: input
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiFallbackError) return error.fallback as ReaderPreferencesDto;
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
},
|
||||||
async continueReading(): Promise<ContinueItem[]> {
|
async continueReading(): Promise<ContinueItem[]> {
|
||||||
return request<ContinueItem[]>("/progress/continue", { fallback: mockContinue });
|
return request<ContinueItem[]>("/progress/continue", { fallback: mockContinue });
|
||||||
},
|
},
|
||||||
|
|||||||
@ -27,3 +27,12 @@ export type CbzPagesDto = {
|
|||||||
pageCount: number;
|
pageCount: number;
|
||||||
pages: Array<{ page: number; name: string }>;
|
pages: Array<{ page: number; name: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReaderMode = "horizontal" | "vertical";
|
||||||
|
|
||||||
|
export type ReaderFit = "page" | "width";
|
||||||
|
|
||||||
|
export type ReaderPreferencesDto = {
|
||||||
|
mode: ReaderMode;
|
||||||
|
fit?: ReaderFit;
|
||||||
|
};
|
||||||
|
|||||||
14
apps/web/src/book/description.test.ts
Normal file
14
apps/web/src/book/description.test.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { cleanBookDescription } from "./description";
|
||||||
|
|
||||||
|
describe("cleanBookDescription", () => {
|
||||||
|
it("renders catalog HTML as readable plain text", () => {
|
||||||
|
expect(cleanBookDescription("<p>Premier & second.</p><p><strong>Suite</strong> du texte.</p>")).toBe(
|
||||||
|
"Premier & second.\nSuite du texte."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back when the description is empty after cleanup", () => {
|
||||||
|
expect(cleanBookDescription("<p> </p>")).toBe("Notice absente du catalogue.");
|
||||||
|
});
|
||||||
|
});
|
||||||
28
apps/web/src/book/description.ts
Normal file
28
apps/web/src/book/description.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
const blockBreakPattern = /<\/(p|div|section|article|header|footer|blockquote|li|ul|ol|br|h[1-6])>/gi;
|
||||||
|
const tagPattern = /<[^>]*>/g;
|
||||||
|
|
||||||
|
function decodeEntities(value: string): string {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return value
|
||||||
|
.replace(/ /gi, " ")
|
||||||
|
.replace(/&/gi, "&")
|
||||||
|
.replace(/</gi, "<")
|
||||||
|
.replace(/>/gi, ">")
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'/gi, "'");
|
||||||
|
}
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.innerHTML = value;
|
||||||
|
return textarea.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanBookDescription(description?: string | null): string {
|
||||||
|
if (!description) return "Notice absente du catalogue.";
|
||||||
|
return decodeEntities(description.replace(blockBreakPattern, "\n").replace(tagPattern, " "))
|
||||||
|
.replace(/\r/g, "")
|
||||||
|
.replace(/[ \t]+\n/g, "\n")
|
||||||
|
.replace(/\n[ \t]+/g, "\n")
|
||||||
|
.replace(/[ \t]{2,}/g, " ")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim() || "Notice absente du catalogue.";
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
|
import { BookOpen, LibraryBig, RotateCcw } from "lucide-react";
|
||||||
import type { BookDto, ProgressDto } from "@readabook/shared";
|
import type { BookDto, ProgressDto } from "@readabook/shared";
|
||||||
import { api, getApiFallback } from "../api/client";
|
import { api, getApiFallback } from "../api/client";
|
||||||
|
import { cleanBookDescription } from "../book/description";
|
||||||
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
|
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
|
|
||||||
@ -64,7 +65,7 @@ export function BookPage({ bookId }: { bookId: number }) {
|
|||||||
</div>
|
</div>
|
||||||
<h1>{book.title}</h1>
|
<h1>{book.title}</h1>
|
||||||
<p className="lead">{book.author ?? "Auteur inconnu"}</p>
|
<p className="lead">{book.author ?? "Auteur inconnu"}</p>
|
||||||
<p>{book.description ?? "Notice absente du catalogue."}</p>
|
<p className="book-description">{cleanBookDescription(book.description)}</p>
|
||||||
{progress && <Meter value={progress.percent} />}
|
{progress && <Meter value={progress.percent} />}
|
||||||
<div className="book-card-actions">
|
<div className="book-card-actions">
|
||||||
<button className="primary-button" onClick={() => navigate(`/reader/${book.id}`)}>
|
<button className="primary-button" onClick={() => navigate(`/reader/${book.id}`)}>
|
||||||
|
|||||||
@ -1,21 +1,31 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ArrowLeft, RotateCcw, Save } from "lucide-react";
|
|
||||||
import type { BookDto } from "@readabook/shared";
|
import type { BookDto } from "@readabook/shared";
|
||||||
import { api, getApiFallback } from "../api/client";
|
import { api, getApiFallback } from "../api/client";
|
||||||
import { ErrorRibbon, Meter } from "../components/ui";
|
|
||||||
import { navigate } from "../router";
|
|
||||||
import { CbzReader } from "../reader/CbzReader";
|
import { CbzReader } from "../reader/CbzReader";
|
||||||
import { EpubReader } from "../reader/EpubReader";
|
import { EpubReader } from "../reader/EpubReader";
|
||||||
import { parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
import { parseCbrPageLocator, parseCbzPageLocator, parsePdfPageLocator, pdfPagePercent } from "../reader/locators";
|
||||||
import { PdfReader } from "../reader/PdfReader";
|
import { PdfReader } from "../reader/PdfReader";
|
||||||
|
import { ReaderShell, type ReaderControls } from "../reader/ReaderShell";
|
||||||
|
import { useReaderPreferences } from "../reader/useReaderPreferences";
|
||||||
import { useReaderProgress } from "../reader/useReaderProgress";
|
import { useReaderProgress } from "../reader/useReaderProgress";
|
||||||
|
|
||||||
|
const idleControls: ReaderControls = {
|
||||||
|
canPrevious: false,
|
||||||
|
canNext: false,
|
||||||
|
positionLabel: "Chargement",
|
||||||
|
onPrevious: () => undefined,
|
||||||
|
onNext: () => undefined
|
||||||
|
};
|
||||||
|
|
||||||
export function ReaderPage({ bookId }: { bookId: number }) {
|
export function ReaderPage({ bookId }: { bookId: number }) {
|
||||||
const [book, setBook] = useState<BookDto | null>(null);
|
const [book, setBook] = useState<BookDto | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [readerControls, setReaderControls] = useState<ReaderControls>(idleControls);
|
||||||
|
const [controlsVisible, setControlsVisible] = useState(true);
|
||||||
const { progress, saving, error: progressError, save } = useReaderProgress(bookId);
|
const { progress, saving, error: progressError, save } = useReaderProgress(bookId);
|
||||||
|
const { preferences, setMode, error: preferencesError } = useReaderPreferences(bookId);
|
||||||
|
|
||||||
async function loadBook() {
|
async function loadBook() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -35,6 +45,11 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
void loadBook();
|
void loadBook();
|
||||||
}, [bookId]);
|
}, [bookId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReaderControls(idleControls);
|
||||||
|
setControlsVisible(true);
|
||||||
|
}, [bookId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator);
|
const nextPage = parsePdfPageLocator(progress?.locator) ?? parseCbzPageLocator(progress?.locator) ?? parseCbrPageLocator(progress?.locator);
|
||||||
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
if (nextPage) setPage((current) => (current === nextPage ? current : nextPage));
|
||||||
@ -59,27 +74,22 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
[book?.format, save]
|
[book?.format, save]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const readerError = error ?? progressError ?? preferencesError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reader-page">
|
<ReaderShell
|
||||||
<header className="reader-topbar">
|
title={book?.title ?? "Ouverture du lecteur"}
|
||||||
<button className="ghost-button" onClick={() => navigate(backHref)}>
|
status={loading ? "Chargement" : saving ? "Sauvegarde" : "Progression synchronisee"}
|
||||||
<ArrowLeft size={17} />
|
backHref={backHref}
|
||||||
Fiche
|
progress={progress?.percent ?? 0}
|
||||||
</button>
|
error={readerError}
|
||||||
<div>
|
onRetry={error ? () => void loadBook() : undefined}
|
||||||
<strong>{book?.title ?? "Ouverture du lecteur"}</strong>
|
mode={preferences.mode}
|
||||||
<span>{loading ? "Chargement" : saving ? "Sauvegarde" : "Progression synchronisee"}</span>
|
onModeChange={setMode}
|
||||||
</div>
|
controls={readerControls}
|
||||||
{error ? (
|
controlsVisible={controlsVisible}
|
||||||
<button className="ghost-button icon-only" onClick={() => void loadBook()} aria-label="Reessayer">
|
onToggleControls={() => setControlsVisible((visible) => !visible)}
|
||||||
<RotateCcw size={18} />
|
>
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<Save size={18} />
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
<ErrorRibbon message={error ?? progressError} />
|
|
||||||
<Meter value={progress?.percent ?? 0} />
|
|
||||||
{!book ? (
|
{!book ? (
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
<span>{error ?? "Chargement du livre."}</span>
|
<span>{error ?? "Chargement du livre."}</span>
|
||||||
@ -88,12 +98,32 @@ export function ReaderPage({ bookId }: { bookId: number }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : book.format === "pdf" ? (
|
) : book.format === "pdf" ? (
|
||||||
<PdfReader url={fileUrl} page={page} backHref={backHref} onPageCommit={savePdfPage} />
|
<PdfReader
|
||||||
|
url={fileUrl}
|
||||||
|
page={page}
|
||||||
|
backHref={backHref}
|
||||||
|
mode={preferences.mode}
|
||||||
|
onPageCommit={savePdfPage}
|
||||||
|
onControlsChange={setReaderControls}
|
||||||
|
/>
|
||||||
) : book.format === "cbz" || book.format === "cbr" ? (
|
) : book.format === "cbz" || book.format === "cbr" ? (
|
||||||
<CbzReader bookId={book.id} page={page} onPageCommit={saveComicPage} />
|
<CbzReader
|
||||||
|
bookId={book.id}
|
||||||
|
page={page}
|
||||||
|
mode={preferences.mode}
|
||||||
|
onPageCommit={saveComicPage}
|
||||||
|
onControlsChange={setReaderControls}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EpubReader url={fileUrl} locator={progress?.locator} backHref={backHref} onLocatorChange={saveEpubLocator} />
|
<EpubReader
|
||||||
|
url={fileUrl}
|
||||||
|
locator={progress?.locator}
|
||||||
|
backHref={backHref}
|
||||||
|
mode={preferences.mode}
|
||||||
|
onLocatorChange={saveEpubLocator}
|
||||||
|
onControlsChange={setReaderControls}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ReaderShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,20 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { CbzPagesDto } from "../api/types";
|
import type { CbzPagesDto, ReaderMode } from "../api/types";
|
||||||
|
import type { ReaderControls } from "./ReaderShell";
|
||||||
|
|
||||||
export function CbzReader({
|
export function CbzReader({
|
||||||
bookId,
|
bookId,
|
||||||
page,
|
page,
|
||||||
onPageCommit
|
mode,
|
||||||
|
onPageCommit,
|
||||||
|
onControlsChange
|
||||||
}: {
|
}: {
|
||||||
bookId: number;
|
bookId: number;
|
||||||
page: number;
|
page: number;
|
||||||
|
mode: ReaderMode;
|
||||||
onPageCommit: (page: number, pages: number) => void;
|
onPageCommit: (page: number, pages: number) => void;
|
||||||
|
onControlsChange: (controls: ReaderControls) => void;
|
||||||
}) {
|
}) {
|
||||||
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
@ -37,13 +42,23 @@ export function CbzReader({
|
|||||||
const currentPage = Math.max(1, Math.min(page, pageCount));
|
const currentPage = Math.max(1, Math.min(page, pageCount));
|
||||||
const currentName = pages?.pages.find((item) => item.page === currentPage)?.name;
|
const currentName = pages?.pages.find((item) => item.page === currentPage)?.name;
|
||||||
|
|
||||||
function go(nextPage: number) {
|
const go = useCallback((nextPage: number) => {
|
||||||
setImageError(false);
|
setImageError(false);
|
||||||
onPageCommit(Math.max(1, Math.min(nextPage, pageCount)), pageCount);
|
onPageCommit(Math.max(1, Math.min(nextPage, pageCount)), pageCount);
|
||||||
}
|
}, [onPageCommit, pageCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onControlsChange({
|
||||||
|
canPrevious: !error && !imageError && currentPage > 1,
|
||||||
|
canNext: !error && !imageError && currentPage < pageCount,
|
||||||
|
positionLabel: pages ? `${currentPage} / ${pageCount}` : "Ouverture archive",
|
||||||
|
onPrevious: () => go(currentPage - 1),
|
||||||
|
onNext: () => go(currentPage + 1)
|
||||||
|
});
|
||||||
|
}, [currentPage, error, go, imageError, onControlsChange, pageCount, pages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cbz-reader">
|
<div className={`cbz-reader cbz-reader-${mode}`}>
|
||||||
{error || imageError ? (
|
{error || imageError ? (
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
<span>{error ?? "Page CBZ indisponible."}</span>
|
<span>{error ?? "Page CBZ indisponible."}</span>
|
||||||
@ -54,17 +69,6 @@ export function CbzReader({
|
|||||||
) : (
|
) : (
|
||||||
<img src={api.cbzPageUrl(bookId, currentPage)} alt={currentName ?? `Page ${currentPage}`} onError={() => setImageError(true)} />
|
<img src={api.cbzPageUrl(bookId, currentPage)} alt={currentName ?? `Page ${currentPage}`} onError={() => setImageError(true)} />
|
||||||
)}
|
)}
|
||||||
<div className="reader-stepper">
|
|
||||||
<button className="ghost-button" onClick={() => go(currentPage - 1)}>
|
|
||||||
Precedent
|
|
||||||
</button>
|
|
||||||
<span>
|
|
||||||
{currentPage} / {pageCount}
|
|
||||||
</span>
|
|
||||||
<button className="ghost-button" onClick={() => go(currentPage + 1)}>
|
|
||||||
Suivant
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
|
||||||
import { ReaderError, readerErrorMessage } from "./ReaderError";
|
import { ReaderError, readerErrorMessage } from "./ReaderError";
|
||||||
|
import type { ReaderControls } from "./ReaderShell";
|
||||||
|
import type { ReaderMode } from "../api/types";
|
||||||
|
|
||||||
type FoliateLocation = {
|
type FoliateLocation = {
|
||||||
cfi?: string;
|
cfi?: string;
|
||||||
@ -42,12 +43,16 @@ export function EpubReader({
|
|||||||
url,
|
url,
|
||||||
locator,
|
locator,
|
||||||
backHref,
|
backHref,
|
||||||
onLocatorChange
|
mode,
|
||||||
|
onLocatorChange,
|
||||||
|
onControlsChange
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
locator?: string;
|
locator?: string;
|
||||||
backHref: string;
|
backHref: string;
|
||||||
|
mode: ReaderMode;
|
||||||
onLocatorChange: (locator: string, percent: number) => void;
|
onLocatorChange: (locator: string, percent: number) => void;
|
||||||
|
onControlsChange: (controls: ReaderControls) => void;
|
||||||
}) {
|
}) {
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<FoliateView | null>(null);
|
const viewRef = useRef<FoliateView | null>(null);
|
||||||
@ -56,6 +61,16 @@ export function EpubReader({
|
|||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onControlsChange({
|
||||||
|
canPrevious: !loading && !error,
|
||||||
|
canNext: !loading && !error,
|
||||||
|
positionLabel: loading ? "Ouverture EPUB" : "Lecture integree",
|
||||||
|
onPrevious: () => void viewRef.current?.goLeft(),
|
||||||
|
onNext: () => void viewRef.current?.goRight()
|
||||||
|
});
|
||||||
|
}, [error, loading, onControlsChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
locatorRef.current = locator;
|
locatorRef.current = locator;
|
||||||
}, [locator]);
|
}, [locator]);
|
||||||
@ -108,6 +123,11 @@ export function EpubReader({
|
|||||||
};
|
};
|
||||||
}, [attempt, onLocatorChange, url]);
|
}, [attempt, onLocatorChange, url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
viewRef.current?.classList.toggle("epub-view-vertical", mode === "vertical");
|
||||||
|
viewRef.current?.classList.toggle("epub-view-horizontal", mode === "horizontal");
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="epub-reader">
|
<div className="epub-reader">
|
||||||
@ -124,24 +144,13 @@ export function EpubReader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="epub-reader">
|
<div className={`epub-reader epub-reader-${mode}`}>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="reader-fallback">
|
<div className="reader-fallback">
|
||||||
<span>Ouverture EPUB</span>
|
<span>Ouverture EPUB</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="epub-host" ref={hostRef} />
|
<div className="epub-host" ref={hostRef} />
|
||||||
<div className="reader-stepper">
|
|
||||||
<button className="ghost-button" onClick={() => void viewRef.current?.goLeft()} disabled={loading}>
|
|
||||||
<ArrowLeft size={16} />
|
|
||||||
Précédent
|
|
||||||
</button>
|
|
||||||
<span>{loading ? "Chargement" : "Lecture intégrée"}</span>
|
|
||||||
<button className="ghost-button" onClick={() => void viewRef.current?.goRight()} disabled={loading}>
|
|
||||||
Suivant
|
|
||||||
<ArrowRight size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,26 +1,60 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import * as pdfjs from "pdfjs-dist";
|
import * as pdfjs from "pdfjs-dist";
|
||||||
import { ReaderError, readerErrorMessage } from "./ReaderError";
|
import { ReaderError, readerErrorMessage } from "./ReaderError";
|
||||||
import { pdfWorkerSrc } from "./pdfWorker";
|
import { configurePdfWorker } from "./pdfWorker";
|
||||||
|
import type { ReaderControls } from "./ReaderShell";
|
||||||
|
import type { ReaderMode } from "../api/types";
|
||||||
|
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
|
configurePdfWorker(pdfjs);
|
||||||
|
|
||||||
export function PdfReader({
|
export function PdfReader({
|
||||||
url,
|
url,
|
||||||
page,
|
page,
|
||||||
backHref,
|
backHref,
|
||||||
onPageCommit
|
mode,
|
||||||
|
onPageCommit,
|
||||||
|
onControlsChange
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
page: number;
|
page: number;
|
||||||
backHref: string;
|
backHref: string;
|
||||||
|
mode: ReaderMode;
|
||||||
onPageCommit: (page: number, pages: number) => void;
|
onPageCommit: (page: number, pages: number) => void;
|
||||||
|
onControlsChange: (controls: ReaderControls) => void;
|
||||||
}) {
|
}) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const frameRef = useRef<HTMLDivElement>(null);
|
||||||
const [pages, setPages] = useState(1);
|
const [pages, setPages] = useState(1);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const [frameSize, setFrameSize] = useState({ width: 980, height: 900 });
|
||||||
|
|
||||||
|
const currentPage = Math.max(1, Math.min(page, pages));
|
||||||
|
const go = useCallback((nextPage: number) => onPageCommit(Math.max(1, Math.min(nextPage, pages)), pages), [onPageCommit, pages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onControlsChange({
|
||||||
|
canPrevious: !loading && !error && currentPage > 1,
|
||||||
|
canNext: !loading && !error && currentPage < pages,
|
||||||
|
positionLabel: loading ? "Ouverture PDF" : `${currentPage} / ${pages}`,
|
||||||
|
onPrevious: () => go(currentPage - 1),
|
||||||
|
onNext: () => go(currentPage + 1)
|
||||||
|
});
|
||||||
|
}, [currentPage, error, go, loading, onControlsChange, pages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const frame = frameRef.current;
|
||||||
|
if (!frame) return;
|
||||||
|
const updateSize = () => {
|
||||||
|
const rect = frame.getBoundingClientRect();
|
||||||
|
setFrameSize({ width: Math.max(320, rect.width), height: Math.max(320, rect.height) });
|
||||||
|
};
|
||||||
|
updateSize();
|
||||||
|
const observer = new ResizeObserver(updateSize);
|
||||||
|
observer.observe(frame);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@ -28,6 +62,7 @@ export function PdfReader({
|
|||||||
let renderTask: pdfjs.RenderTask | undefined;
|
let renderTask: pdfjs.RenderTask | undefined;
|
||||||
async function render() {
|
async function render() {
|
||||||
try {
|
try {
|
||||||
|
configurePdfWorker(pdfjs);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
||||||
@ -37,9 +72,17 @@ export function PdfReader({
|
|||||||
const pdfPage = await document.getPage(Math.max(1, Math.min(page, document.numPages)));
|
const pdfPage = await document.getPage(Math.max(1, Math.min(page, document.numPages)));
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const viewport = pdfPage.getViewport({ scale: Math.min(1.6, window.devicePixelRatio || 1) });
|
const baseViewport = pdfPage.getViewport({ scale: 1 });
|
||||||
canvas.width = viewport.width;
|
const fitScale =
|
||||||
canvas.height = viewport.height;
|
mode === "vertical"
|
||||||
|
? frameSize.width / baseViewport.width
|
||||||
|
: Math.min(frameSize.width / baseViewport.width, frameSize.height / baseViewport.height);
|
||||||
|
const renderScale = Math.max(0.35, Math.min(3, fitScale)) * Math.min(2, window.devicePixelRatio || 1);
|
||||||
|
const viewport = pdfPage.getViewport({ scale: renderScale });
|
||||||
|
canvas.width = Math.floor(viewport.width);
|
||||||
|
canvas.height = Math.floor(viewport.height);
|
||||||
|
canvas.style.width = `${Math.floor(viewport.width / Math.min(2, window.devicePixelRatio || 1))}px`;
|
||||||
|
canvas.style.height = `${Math.floor(viewport.height / Math.min(2, window.devicePixelRatio || 1))}px`;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
if (!context) return;
|
if (!context) return;
|
||||||
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
|
renderTask = pdfPage.render({ canvas, canvasContext: context, viewport });
|
||||||
@ -58,10 +101,10 @@ export function PdfReader({
|
|||||||
renderTask?.cancel();
|
renderTask?.cancel();
|
||||||
void loadingTask?.destroy();
|
void loadingTask?.destroy();
|
||||||
};
|
};
|
||||||
}, [url, page, attempt]);
|
}, [url, page, attempt, frameSize.height, frameSize.width, mode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pdf-reader">
|
<div className="pdf-reader" ref={frameRef}>
|
||||||
{error ? (
|
{error ? (
|
||||||
<ReaderError
|
<ReaderError
|
||||||
title="Lecture PDF indisponible"
|
title="Lecture PDF indisponible"
|
||||||
@ -81,17 +124,6 @@ export function PdfReader({
|
|||||||
<canvas ref={canvasRef} />
|
<canvas ref={canvasRef} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="reader-stepper">
|
|
||||||
<button className="ghost-button" onClick={() => onPageCommit(Math.max(1, page - 1), pages)} disabled={Boolean(error) || loading}>
|
|
||||||
Précédent
|
|
||||||
</button>
|
|
||||||
<span>
|
|
||||||
{page} / {pages}
|
|
||||||
</span>
|
|
||||||
<button className="ghost-button" onClick={() => onPageCommit(Math.min(pages, page + 1), pages)} disabled={Boolean(error) || loading}>
|
|
||||||
Suivant
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
145
apps/web/src/reader/ReaderShell.tsx
Normal file
145
apps/web/src/reader/ReaderShell.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { ArrowLeft, ArrowRight, Columns2, RotateCcw, Rows3, Save } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { ErrorRibbon, Meter } from "../components/ui";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
import type { ReaderMode } from "../api/types";
|
||||||
|
|
||||||
|
export type ReaderControls = {
|
||||||
|
canPrevious: boolean;
|
||||||
|
canNext: boolean;
|
||||||
|
positionLabel: string;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReaderShellProps = {
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
backHref: string;
|
||||||
|
progress: number;
|
||||||
|
error?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
mode: ReaderMode;
|
||||||
|
onModeChange: (mode: ReaderMode) => void;
|
||||||
|
controls: ReaderControls;
|
||||||
|
controlsVisible: boolean;
|
||||||
|
onToggleControls: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReaderShell({
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
backHref,
|
||||||
|
progress,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
mode,
|
||||||
|
onModeChange,
|
||||||
|
controls,
|
||||||
|
controlsVisible,
|
||||||
|
onToggleControls,
|
||||||
|
children
|
||||||
|
}: ReaderShellProps) {
|
||||||
|
return (
|
||||||
|
<div className={`reader-page reader-mode-${mode} ${controlsVisible ? "reader-controls-visible" : "reader-controls-hidden"}`}>
|
||||||
|
<header className="reader-topbar" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<button className="ghost-button" onClick={() => navigate(backHref)}>
|
||||||
|
<ArrowLeft size={17} />
|
||||||
|
Fiche
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<span>{status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="reader-toolbar">
|
||||||
|
<button
|
||||||
|
className={`ghost-button icon-only ${mode === "horizontal" ? "active" : ""}`}
|
||||||
|
onClick={() => onModeChange("horizontal")}
|
||||||
|
aria-label="Lecture horizontale"
|
||||||
|
title="Lecture horizontale"
|
||||||
|
>
|
||||||
|
<Columns2 size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`ghost-button icon-only ${mode === "vertical" ? "active" : ""}`}
|
||||||
|
onClick={() => onModeChange("vertical")}
|
||||||
|
aria-label="Lecture verticale"
|
||||||
|
title="Lecture verticale"
|
||||||
|
>
|
||||||
|
<Rows3 size={18} />
|
||||||
|
</button>
|
||||||
|
{error && onRetry ? (
|
||||||
|
<button className="ghost-button icon-only" onClick={onRetry} aria-label="Reessayer">
|
||||||
|
<RotateCcw size={18} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Save size={18} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="reader-status" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<ErrorRibbon message={error} />
|
||||||
|
<Meter value={progress} />
|
||||||
|
</div>
|
||||||
|
<div className="reader-stage" onClick={onToggleControls}>
|
||||||
|
{mode === "horizontal" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="reader-side-button reader-side-left"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
controls.onPrevious();
|
||||||
|
}}
|
||||||
|
disabled={!controls.canPrevious}
|
||||||
|
aria-label="Page precedente"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={22} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="reader-side-button reader-side-right"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
controls.onNext();
|
||||||
|
}}
|
||||||
|
disabled={!controls.canNext}
|
||||||
|
aria-label="Page suivante"
|
||||||
|
>
|
||||||
|
<ArrowRight size={22} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="reader-tap-zone reader-tap-left"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
controls.onPrevious();
|
||||||
|
}}
|
||||||
|
disabled={!controls.canPrevious}
|
||||||
|
aria-label="Page precedente"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="reader-tap-zone reader-tap-right"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
controls.onNext();
|
||||||
|
}}
|
||||||
|
disabled={!controls.canNext}
|
||||||
|
aria-label="Page suivante"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="reader-content">{children}</div>
|
||||||
|
</div>
|
||||||
|
<footer className="reader-stepper" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<button className="ghost-button" onClick={controls.onPrevious} disabled={!controls.canPrevious}>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Precedent
|
||||||
|
</button>
|
||||||
|
<span>{controls.positionLabel}</span>
|
||||||
|
<button className="ghost-button" onClick={controls.onNext} disabled={!controls.canNext}>
|
||||||
|
Suivant
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1 +1,17 @@
|
|||||||
export const pdfWorkerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
|
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?worker&url";
|
||||||
|
|
||||||
|
export const pdfWorkerSrc = pdfWorkerUrl;
|
||||||
|
|
||||||
|
let pdfWorkerPort: Worker | null = null;
|
||||||
|
|
||||||
|
export function configurePdfWorker(pdfjs: Pick<typeof import("pdfjs-dist"), "GlobalWorkerOptions">) {
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerSrc;
|
||||||
|
|
||||||
|
if (typeof window === "undefined" || !("Worker" in window)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfWorkerPort ??= new Worker(pdfWorkerSrc, { type: "module" });
|
||||||
|
pdfjs.GlobalWorkerOptions.workerPort = pdfWorkerPort;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { epubFileName } from "./EpubReader";
|
import { epubFileName } from "./EpubReader";
|
||||||
import { pdfWorkerSrc } from "./pdfWorker";
|
import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker";
|
||||||
import { readerErrorMessage } from "./ReaderError";
|
import { readerErrorMessage } from "./ReaderError";
|
||||||
|
|
||||||
describe("reader runtime helpers", () => {
|
describe("reader runtime helpers", () => {
|
||||||
@ -9,10 +9,23 @@ describe("reader runtime helpers", () => {
|
|||||||
expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub");
|
expect(epubFileName("http://readabook.local/files/example.epub")).toBe("example.epub");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps PDF.js worker source on the bundled module worker", () => {
|
it("keeps PDF.js worker fallback source on the bundled module worker", () => {
|
||||||
expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs");
|
expect(pdfWorkerSrc).toContain("pdf.worker.min.mjs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("configures the PDF.js worker fallback without creating a worker outside the browser", () => {
|
||||||
|
const pdfjs = {
|
||||||
|
GlobalWorkerOptions: {
|
||||||
|
workerPort: null,
|
||||||
|
workerSrc: ""
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(configurePdfWorker(pdfjs as unknown as Parameters<typeof configurePdfWorker>[0])).toBe(false);
|
||||||
|
expect(pdfjs.GlobalWorkerOptions.workerPort).toBeNull();
|
||||||
|
expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(pdfWorkerSrc);
|
||||||
|
});
|
||||||
|
|
||||||
it("normalizes reader technical errors", () => {
|
it("normalizes reader technical errors", () => {
|
||||||
expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed");
|
expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed");
|
||||||
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");
|
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");
|
||||||
|
|||||||
37
apps/web/src/reader/useReaderPreferences.ts
Normal file
37
apps/web/src/reader/useReaderPreferences.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { ReaderMode, ReaderPreferencesDto } from "../api/types";
|
||||||
|
|
||||||
|
const defaultPreferences: ReaderPreferencesDto = { mode: "horizontal", fit: "page" };
|
||||||
|
|
||||||
|
export function useReaderPreferences(bookId: number) {
|
||||||
|
const [preferences, setPreferences] = useState<ReaderPreferencesDto>(defaultPreferences);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setError(undefined);
|
||||||
|
api
|
||||||
|
.readerPreferences(bookId)
|
||||||
|
.then((next) => {
|
||||||
|
if (alive) setPreferences(next);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) setError("Preferences lecteur conservees sur cet appareil.");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [bookId]);
|
||||||
|
|
||||||
|
const setMode = useCallback(
|
||||||
|
(mode: ReaderMode) => {
|
||||||
|
const next = { ...preferences, mode };
|
||||||
|
setPreferences(next);
|
||||||
|
void api.saveReaderPreferences(bookId, next).catch(() => setError("Preferences lecteur conservees sur cet appareil."));
|
||||||
|
},
|
||||||
|
[bookId, preferences]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { preferences, setMode, error };
|
||||||
|
}
|
||||||
@ -212,6 +212,10 @@ h2 {
|
|||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.book-description {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
.book-card-description {
|
.book-card-description {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -643,13 +647,18 @@ select {
|
|||||||
|
|
||||||
.reader-page {
|
.reader-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
height: 100vh;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
overflow: hidden;
|
||||||
background: #120e0b;
|
background: #120e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-topbar {
|
.reader-topbar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@ -667,20 +676,65 @@ select {
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar .icon-only {
|
||||||
|
width: 40px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar .active {
|
||||||
|
border-color: rgba(213, 168, 77, 0.72);
|
||||||
|
color: var(--brass);
|
||||||
|
background: rgba(213, 168, 77, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-status {
|
||||||
|
position: relative;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-stage {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: stretch;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-mode-vertical .reader-stage {
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.pdf-reader,
|
.pdf-reader,
|
||||||
.epub-reader,
|
.epub-reader,
|
||||||
.cbz-reader {
|
.cbz-reader {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
gap: 10px;
|
min-height: 0;
|
||||||
min-height: calc(100vh - 120px);
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.epub-host {
|
.epub-host {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: min(100%, 980px);
|
width: min(100%, 980px);
|
||||||
height: calc(100vh - 170px);
|
height: 100%;
|
||||||
min-height: 460px;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.epub-view {
|
.epub-view {
|
||||||
@ -694,8 +748,9 @@ select {
|
|||||||
|
|
||||||
.pdf-reader canvas,
|
.pdf-reader canvas,
|
||||||
.cbz-reader img {
|
.cbz-reader img {
|
||||||
|
display: block;
|
||||||
max-width: min(100%, 980px);
|
max-width: min(100%, 980px);
|
||||||
max-height: calc(100vh - 170px);
|
max-height: 100%;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: #f7f0df;
|
background: #f7f0df;
|
||||||
@ -706,6 +761,26 @@ select {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-mode-vertical .pdf-reader,
|
||||||
|
.reader-mode-vertical .cbz-reader {
|
||||||
|
align-content: start;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-mode-vertical .pdf-reader canvas,
|
||||||
|
.reader-mode-vertical .cbz-reader img {
|
||||||
|
width: min(100%, 980px);
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-mode-horizontal .pdf-reader canvas,
|
||||||
|
.reader-mode-horizontal .cbz-reader img {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.reader-fallback {
|
.reader-fallback {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@ -717,9 +792,68 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reader-stepper {
|
.reader-stepper {
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(38, 26, 18, 0.84);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-stepper span {
|
||||||
|
min-width: 92px;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-side-button,
|
||||||
|
.reader-tap-zone {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 3;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-side-button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 58px;
|
||||||
|
color: var(--ink);
|
||||||
|
opacity: 0.66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-side-button:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-side-button:disabled,
|
||||||
|
.reader-tap-zone:disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-side-left,
|
||||||
|
.reader-tap-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-side-right,
|
||||||
|
.reader-tap-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-tap-zone {
|
||||||
|
display: none;
|
||||||
|
width: 34%;
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-error {
|
.reader-error {
|
||||||
@ -854,4 +988,80 @@ select {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-page {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-topbar {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-topbar div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-topbar strong {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 38vw;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-side-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-tap-zone {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-controls-hidden .reader-topbar,
|
||||||
|
.reader-controls-hidden .reader-status,
|
||||||
|
.reader-controls-hidden .reader-stepper {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-controls-hidden {
|
||||||
|
grid-template-rows: 0 0 minmax(0, 1fr) 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-controls-hidden .reader-topbar,
|
||||||
|
.reader-controls-hidden .reader-status,
|
||||||
|
.reader-controls-hidden .reader-stepper {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-topbar,
|
||||||
|
.reader-status,
|
||||||
|
.reader-stepper {
|
||||||
|
transition: opacity 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-stepper {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-stepper .ghost-button {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.epub-host {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-mode-vertical .pdf-reader canvas,
|
||||||
|
.reader-mode-vertical .cbz-reader img {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -128,6 +128,19 @@ export const UpdateProgressSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type UpdateProgressDto = z.infer<typeof UpdateProgressSchema>;
|
export type UpdateProgressDto = z.infer<typeof UpdateProgressSchema>;
|
||||||
|
|
||||||
|
export const ReaderPreferencesSchema = z.object({
|
||||||
|
mode: z.enum(["paged", "scrolled", "horizontal", "vertical"]).default("paged"),
|
||||||
|
fit: z.enum(["page", "width", "height", "auto"]).nullable().default(null),
|
||||||
|
updatedAt: z.string().optional()
|
||||||
|
});
|
||||||
|
export type ReaderPreferencesDto = z.infer<typeof ReaderPreferencesSchema>;
|
||||||
|
|
||||||
|
export const UpdateReaderPreferencesSchema = z.object({
|
||||||
|
mode: z.enum(["paged", "scrolled", "horizontal", "vertical"]).optional(),
|
||||||
|
fit: z.enum(["page", "width", "height", "auto"]).nullable().optional()
|
||||||
|
});
|
||||||
|
export type UpdateReaderPreferencesDto = z.infer<typeof UpdateReaderPreferencesSchema>;
|
||||||
|
|
||||||
export const JobSchema = z.object({
|
export const JobSchema = z.object({
|
||||||
id: z.number().int().positive(),
|
id: z.number().int().positive(),
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user