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:
@@ -5,6 +5,40 @@ import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 EditSeriesFormProps {
|
||||
libraryId: string;
|
||||
seriesName: string;
|
||||
@@ -14,6 +48,8 @@ interface EditSeriesFormProps {
|
||||
currentBookLanguage: string | null;
|
||||
currentDescription: string | null;
|
||||
currentStartYear: number | null;
|
||||
currentTotalVolumes: number | null;
|
||||
currentLockedFields: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function EditSeriesForm({
|
||||
@@ -25,6 +61,8 @@ export function EditSeriesForm({
|
||||
currentBookLanguage,
|
||||
currentDescription,
|
||||
currentStartYear,
|
||||
currentTotalVolumes,
|
||||
currentLockedFields,
|
||||
}: EditSeriesFormProps) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -41,12 +79,20 @@ export function EditSeriesForm({
|
||||
const [publisherInputEl, setPublisherInputEl] = useState<HTMLInputElement | null>(null);
|
||||
const [description, setDescription] = useState(currentDescription ?? "");
|
||||
const [startYear, setStartYear] = useState(currentStartYear?.toString() ?? "");
|
||||
const [totalVolumes, setTotalVolumes] = useState(currentTotalVolumes?.toString() ?? "");
|
||||
|
||||
// Lock states
|
||||
const [lockedFields, setLockedFields] = useState<Record<string, boolean>>(currentLockedFields);
|
||||
|
||||
// Propagation aux livres — opt-in via bouton
|
||||
const [bookAuthor, setBookAuthor] = useState(currentBookAuthor ?? "");
|
||||
const [bookLanguage, setBookLanguage] = useState(currentBookLanguage ?? "");
|
||||
const [showApplyToBooks, setShowApplyToBooks] = useState(false);
|
||||
|
||||
const toggleLock = (field: string) => {
|
||||
setLockedFields((prev) => ({ ...prev, [field]: !prev[field] }));
|
||||
};
|
||||
|
||||
const addAuthor = () => {
|
||||
const v = authorInput.trim();
|
||||
if (v && !authors.includes(v)) {
|
||||
@@ -95,12 +141,14 @@ export function EditSeriesForm({
|
||||
setPublisherInput("");
|
||||
setDescription(currentDescription ?? "");
|
||||
setStartYear(currentStartYear?.toString() ?? "");
|
||||
setTotalVolumes(currentTotalVolumes?.toString() ?? "");
|
||||
setLockedFields(currentLockedFields);
|
||||
setShowApplyToBooks(false);
|
||||
setBookAuthor(currentBookAuthor ?? "");
|
||||
setBookLanguage(currentBookLanguage ?? "");
|
||||
setError(null);
|
||||
setIsOpen(false);
|
||||
}, [seriesName, currentAuthors, currentPublishers, currentDescription, currentStartYear, currentBookAuthor, currentBookLanguage]);
|
||||
}, [seriesName, currentAuthors, currentPublishers, currentDescription, currentStartYear, currentTotalVolumes, currentBookAuthor, currentBookLanguage, currentLockedFields]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -133,6 +181,8 @@ export function EditSeriesForm({
|
||||
publishers: finalPublishers,
|
||||
description: description.trim() || null,
|
||||
start_year: startYear.trim() ? parseInt(startYear.trim(), 10) : null,
|
||||
total_volumes: totalVolumes.trim() ? parseInt(totalVolumes.trim(), 10) : null,
|
||||
locked_fields: lockedFields,
|
||||
};
|
||||
if (showApplyToBooks) {
|
||||
body.author = bookAuthor.trim() || null;
|
||||
@@ -205,7 +255,10 @@ export function EditSeriesForm({
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<FormLabel>Année de début</FormLabel>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Année de début</FormLabel>
|
||||
<LockButton locked={!!lockedFields.start_year} onToggle={() => toggleLock("start_year")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
type="number"
|
||||
min="1900"
|
||||
@@ -217,9 +270,27 @@ export function EditSeriesForm({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Nombre de volumes</FormLabel>
|
||||
<LockButton locked={!!lockedFields.total_volumes} onToggle={() => toggleLock("total_volumes")} disabled={isPending} />
|
||||
</div>
|
||||
<FormInput
|
||||
type="number"
|
||||
min="1"
|
||||
value={totalVolumes}
|
||||
onChange={(e) => setTotalVolumes(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="ex : 12"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* 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">
|
||||
@@ -302,7 +373,10 @@ export function EditSeriesForm({
|
||||
|
||||
{/* Éditeurs — multi-valeur */}
|
||||
<FormField className="sm:col-span-2">
|
||||
<FormLabel>Éditeur(s)</FormLabel>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Éditeur(s)</FormLabel>
|
||||
<LockButton locked={!!lockedFields.publishers} onToggle={() => toggleLock("publishers")} disabled={isPending} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{publishers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -348,7 +422,10 @@ export function EditSeriesForm({
|
||||
</FormField>
|
||||
|
||||
<FormField className="sm:col-span-2">
|
||||
<FormLabel>Description</FormLabel>
|
||||
<div className="flex items-center gap-1">
|
||||
<FormLabel>Description</FormLabel>
|
||||
<LockButton locked={!!lockedFields.description} onToggle={() => toggleLock("description")} disabled={isPending} />
|
||||
</div>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
@@ -360,6 +437,16 @@ export function EditSeriesForm({
|
||||
</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>}
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
Reference in New Issue
Block a user