feat: add external metadata sync system with multiple providers

Add a complete metadata synchronization system allowing users to search
and sync series/book metadata from external providers (Google Books,
Open Library, ComicVine, AniList, Bédéthèque). Each library can use a
different provider. Matching requires manual approval with detailed sync
reports showing what was updated or skipped (locked fields protection).

Key changes:
- DB migrations: external_metadata_links, external_book_metadata tables,
  library metadata_provider column, locked_fields, total_volumes, book
  metadata fields (summary, isbn, publish_date)
- Rust API: MetadataProvider trait + 5 provider implementations,
  7 metadata endpoints (search, match, approve, reject, links, missing,
  delete), sync report system, provider language preference support
- Backoffice: MetadataSearchModal, ProviderIcon, SafeHtml components,
  settings UI for provider/language config, enriched book detail page,
  edit forms with locked fields support, API proxy routes
- OpenAPI/Swagger documentation for all new endpoints and schemas

Closes #3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 14:59:24 +01:00
parent a99bfb5a91
commit c9ccf5cd90
42 changed files with 5492 additions and 198 deletions

View File

@@ -6,6 +6,40 @@ import { useRouter } from "next/navigation";
import { BookDto } from "@/lib/api";
import { FormField, FormLabel, FormInput } from "./ui/Form";
function LockButton({
locked,
onToggle,
disabled,
}: {
locked: boolean;
onToggle: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onToggle}
disabled={disabled}
className={`p-1 rounded transition-colors ${
locked
? "text-amber-500 hover:text-amber-600"
: "text-muted-foreground/40 hover:text-muted-foreground"
}`}
title={locked ? "Champ verrouillé (protégé des synchros)" : "Cliquer pour verrouiller ce champ"}
>
{locked ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
interface EditBookFormProps {
book: BookDto;
}
@@ -23,6 +57,14 @@ export function EditBookForm({ book }: EditBookFormProps) {
const [series, setSeries] = useState(book.series ?? "");
const [volume, setVolume] = useState(book.volume?.toString() ?? "");
const [language, setLanguage] = useState(book.language ?? "");
const [summary, setSummary] = useState(book.summary ?? "");
const [isbn, setIsbn] = useState(book.isbn ?? "");
const [publishDate, setPublishDate] = useState(book.publish_date ?? "");
const [lockedFields, setLockedFields] = useState<Record<string, boolean>>(book.locked_fields ?? {});
const toggleLock = (field: string) => {
setLockedFields((prev) => ({ ...prev, [field]: !prev[field] }));
};
const addAuthor = () => {
const v = authorInput.trim();
@@ -51,6 +93,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
setSeries(book.series ?? "");
setVolume(book.volume?.toString() ?? "");
setLanguage(book.language ?? "");
setSummary(book.summary ?? "");
setIsbn(book.isbn ?? "");
setPublishDate(book.publish_date ?? "");
setLockedFields(book.locked_fields ?? {});
setError(null);
setIsOpen(false);
}, [book]);
@@ -85,6 +131,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
series: series.trim() || null,
volume: volume.trim() ? parseInt(volume.trim(), 10) : null,
language: language.trim() || null,
summary: summary.trim() || null,
isbn: isbn.trim() || null,
publish_date: publishDate.trim() || null,
locked_fields: lockedFields,
}),
});
if (!res.ok) {
@@ -130,7 +180,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
<form onSubmit={handleSubmit} className="p-5 space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField className="sm:col-span-2">
<FormLabel required>Titre</FormLabel>
<div className="flex items-center gap-1">
<FormLabel required>Titre</FormLabel>
<LockButton locked={!!lockedFields.title} onToggle={() => toggleLock("title")} disabled={isPending} />
</div>
<FormInput
value={title}
onChange={(e) => setTitle(e.target.value)}
@@ -141,7 +194,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
{/* Auteurs — multi-valeur */}
<FormField className="sm:col-span-2">
<FormLabel>Auteur(s)</FormLabel>
<div className="flex items-center gap-1">
<FormLabel>Auteur(s)</FormLabel>
<LockButton locked={!!lockedFields.authors} onToggle={() => toggleLock("authors")} disabled={isPending} />
</div>
<div className="space-y-2">
{authors.length > 0 && (
<div className="flex flex-wrap gap-1.5">
@@ -187,7 +243,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
</FormField>
<FormField>
<FormLabel>Langue</FormLabel>
<div className="flex items-center gap-1">
<FormLabel>Langue</FormLabel>
<LockButton locked={!!lockedFields.language} onToggle={() => toggleLock("language")} disabled={isPending} />
</div>
<FormInput
value={language}
onChange={(e) => setLanguage(e.target.value)}
@@ -197,7 +256,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
</FormField>
<FormField>
<FormLabel>Série</FormLabel>
<div className="flex items-center gap-1">
<FormLabel>Série</FormLabel>
<LockButton locked={!!lockedFields.series} onToggle={() => toggleLock("series")} disabled={isPending} />
</div>
<FormInput
value={series}
onChange={(e) => setSeries(e.target.value)}
@@ -207,7 +269,10 @@ export function EditBookForm({ book }: EditBookFormProps) {
</FormField>
<FormField>
<FormLabel>Volume</FormLabel>
<div className="flex items-center gap-1">
<FormLabel>Volume</FormLabel>
<LockButton locked={!!lockedFields.volume} onToggle={() => toggleLock("volume")} disabled={isPending} />
</div>
<FormInput
type="number"
min="1"
@@ -217,8 +282,59 @@ export function EditBookForm({ book }: EditBookFormProps) {
placeholder="Numéro de volume"
/>
</FormField>
<FormField>
<div className="flex items-center gap-1">
<FormLabel>ISBN</FormLabel>
<LockButton locked={!!lockedFields.isbn} onToggle={() => toggleLock("isbn")} disabled={isPending} />
</div>
<FormInput
value={isbn}
onChange={(e) => setIsbn(e.target.value)}
disabled={isPending}
placeholder="ISBN"
/>
</FormField>
<FormField>
<div className="flex items-center gap-1">
<FormLabel>Date de publication</FormLabel>
<LockButton locked={!!lockedFields.publish_date} onToggle={() => toggleLock("publish_date")} disabled={isPending} />
</div>
<FormInput
value={publishDate}
onChange={(e) => setPublishDate(e.target.value)}
disabled={isPending}
placeholder="ex : 2023-01-15"
/>
</FormField>
<FormField className="sm:col-span-2">
<div className="flex items-center gap-1">
<FormLabel>Description</FormLabel>
<LockButton locked={!!lockedFields.summary} onToggle={() => toggleLock("summary")} disabled={isPending} />
</div>
<textarea
value={summary}
onChange={(e) => setSummary(e.target.value)}
disabled={isPending}
placeholder="Résumé / description du livre"
rows={4}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y"
/>
</FormField>
</div>
{/* Lock legend */}
{Object.values(lockedFields).some(Boolean) && (
<p className="text-xs text-amber-500 flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Les champs verrouillés ne seront pas écrasés par les synchros de métadonnées externes.
</p>
)}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}