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:
Git Agent
2026-08-23 18:07:29 +02:00
parent 5de46a6f6d
commit 18d73a4db6
21 changed files with 829 additions and 56 deletions

View 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 &amp; second.</p><p><strong>Suite</strong>&nbsp;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.");
});
});

View 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(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/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.";
}

View File

@ -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}`)}>

View File

@ -212,6 +212,10 @@ h2 {
color: var(--ink-muted);
}
.book-description {
white-space: pre-line;
}
.book-card-description {
display: -webkit-box;
overflow: hidden;