fix(api,web): enrichissement métadonnées — hints locaux et scoring des correspondances
Recherche de fiches par nom peu fiable : sans ISBN, les providers étaient interrogés avec des titres bruts peu discriminants. - extraction de hints locaux (titre/auteur/année/isbn du fichier et du nom de fichier) persistés dans books.local_metadata_json - nouveau use-case ScoreMetadataMatch : tri des résultats par score de correspondance avant sélection du meilleur candidat - providers (google-books, open-library, bnf, local) durcis et nourris par les hints ; colonne + index local_metadata_json avec migrations idempotentes (création et rebuild legacy) - web : nettoyage de la description de la fiche livre (cleanBookDescription, white-space pre-line) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
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 type { BookDto, ProgressDto } from "@readabook/shared";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { cleanBookDescription } from "../book/description";
|
||||
import { EmptyState, ErrorRibbon, FormatPill, LoadingState, Meter, Panel } from "../components/ui";
|
||||
import { navigate } from "../router";
|
||||
|
||||
@ -64,7 +65,7 @@ export function BookPage({ bookId }: { bookId: number }) {
|
||||
</div>
|
||||
<h1>{book.title}</h1>
|
||||
<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} />}
|
||||
<div className="book-card-actions">
|
||||
<button className="primary-button" onClick={() => navigate(`/reader/${book.id}`)}>
|
||||
|
||||
@ -212,6 +212,10 @@ h2 {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.book-description {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.book-card-description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user