Ajoute les notices sources et le renommage fusionné des auteurs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
LIMONIER Mathieu
2026-08-20 14:27:42 +02:00
co-authored by Copilot
parent 6b8f08ef26
commit e28d5bce32
24 changed files with 935 additions and 3 deletions
+1
View File
@@ -50,6 +50,7 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
{
livre.Property(l => l.Titre).IsRequired();
livre.Property(l => l.TitreNormalise).IsRequired();
livre.Property(l => l.UrlNotice);
livre.HasIndex(l => l.Isbn);
// Le tri et la recherche passent par là : sans index, chaque frappe balaie la table.
@@ -0,0 +1,532 @@
// <auto-generated />
using System;
using MaBibli.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
[DbContext(typeof(MaBibliDbContext))]
[Migration("20260820115411_NoticeBibliographique")]
partial class NoticeBibliographique
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CleRegroupement")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CleRegroupement")
.IsUnique();
b.HasIndex("NomNormalise");
b.ToTable("Auteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.BibliographieMasquee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur", "AuteurId");
b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
.IsUnique();
b.ToTable("BibliographiesMasquees");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("SerieId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LivreId");
b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId");
b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId")
.IsUnique()
.HasFilter("\"LivreId\" IS NOT NULL");
b.ToTable("ElementsSerie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("TypeDocument")
.HasColumnType("INTEGER");
b.Property<string>("UrlNotice")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Isbn");
b.HasIndex("TitreNormalise");
b.ToTable("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
b.ToTable("LivreAuteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Annee")
.HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("AuteurNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre")
.IsUnique();
b.ToTable("LivresSouhaites");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateParution")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RevueId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RevueId", "NumeroNormalise")
.IsUnique();
b.ToTable("NumerosRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours")
.IsUnique()
.HasFilter("\"DateRetour\" IS NULL");
b.ToTable("Prets");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurAId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurBId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AuteurAId", "AuteurBId")
.IsUnique();
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TitreNormalise")
.IsUnique();
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
.IsUnique()
.HasFilter("\"Issn\" IS NOT NULL");
b.ToTable("Revues");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("SerieParenteId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SerieParenteId");
b.HasIndex("TitreNormalise")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateMaj")
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur");
b.HasIndex("LivreId", "Utilisateur")
.IsUnique();
b.ToTable("StatutsLecture");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany()
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MaBibli.Shared.Entites.Serie", "Serie")
.WithMany("Elements")
.HasForeignKey("SerieId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Serie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
.WithMany("Livres")
.HasForeignKey("AuteurId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Auteurs")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Auteur");
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
.WithMany("Numeros")
.HasForeignKey("RevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Revue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Prets")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente")
.WithMany("SousSeries")
.HasForeignKey("SerieParenteId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SerieParente");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Statuts")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Navigation("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Navigation("Auteurs");
b.Navigation("Prets");
b.Navigation("Statuts");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Navigation("Numeros");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Navigation("Elements");
b.Navigation("SousSeries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <inheritdoc />
public partial class NoticeBibliographique : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "UrlNotice",
table: "Livres",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "UrlNotice",
table: "Livres");
}
}
}
@@ -139,6 +139,9 @@ namespace MaBibli.Api.Data.Migrations
b.Property<int>("TypeDocument")
.HasColumnType("INTEGER");
b.Property<string>("UrlNotice")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Isbn");
+25
View File
@@ -186,6 +186,31 @@ public static class LivresEndpoints
.Produces<AuteurDto>()
.Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/{id:int}", async (
int id,
RenommerAuteur demande,
bool? confirmerFusion,
IServiceAuteurs service,
CancellationToken ct) =>
{
var resultat = await service.RenommerAsync(
id, demande.Nom, confirmerFusion ?? false, ct);
if (resultat.Auteur is null && resultat.Collision is null)
{
return Results.NotFound();
}
return resultat.Collision is not null
? Results.Conflict(resultat)
: Results.Ok(resultat);
})
.WithName("RenommerAuteur")
.WithSummary("Renomme un auteur ou fusionne sa fiche avec une fiche existante.")
.Produces<ResultatRenommageAuteur>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
groupe.MapGet("/rapprochements", async (IServiceAuteurs service, CancellationToken ct) =>
Results.Ok(await service.SuggestionsAsync(ct)))
.WithName("ListerRapprochements")
@@ -18,6 +18,10 @@ public interface IServiceAuteurs
Task<AuteurDto?> ObtenirAsync(int id, CancellationToken ct = default);
/// <summary>Renomme une fiche, ou signale la fiche cible à fusionner.</summary>
Task<ResultatRenommageAuteur> RenommerAsync(
int id, string nom, bool confirmerFusion = false, CancellationToken ct = default);
/// <summary>Rapprochements ambigus à soumettre à l'utilisateur, refus déjà écartés.</summary>
Task<IReadOnlyList<SuggestionRapprochement>> SuggestionsAsync(CancellationToken ct = default);
@@ -139,6 +143,62 @@ public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
})
.FirstOrDefaultAsync(ct);
public async Task<ResultatRenommageAuteur> RenommerAsync(
int id, string nom, bool confirmerFusion = false, CancellationToken ct = default)
{
var auteur = await db.Auteurs.FirstOrDefaultAsync(a => a.Id == id, ct);
if (auteur is null)
{
return new ResultatRenommageAuteur();
}
nom = nom.Trim();
var cle = RapprochementAuteurs.Cle(nom);
if (cle.Length == 0)
{
return new ResultatRenommageAuteur
{
Auteur = new AuteurDto { Id = id, Nom = auteur.Nom },
};
}
var collision = await db.Auteurs
.AsNoTracking()
.FirstOrDefaultAsync(a => a.Id != id && a.CleRegroupement == cle, ct);
if (collision is not null)
{
var dto = new AuteurDto
{
Id = collision.Id,
Nom = collision.Nom,
NombreDeLivres = await db.LivreAuteurs.CountAsync(l => l.AuteurId == collision.Id, ct),
};
if (!confirmerFusion)
{
return new ResultatRenommageAuteur { Collision = dto };
}
await FusionnerAsync(id, collision.Id, ct);
return new ResultatRenommageAuteur { Auteur = dto };
}
auteur.Nom = nom;
auteur.RecalculerFormes();
await db.SaveChangesAsync(ct);
return new ResultatRenommageAuteur
{
Auteur = new AuteurDto
{
Id = auteur.Id,
Nom = auteur.Nom,
NombreDeLivres = await db.LivreAuteurs.CountAsync(l => l.AuteurId == auteur.Id, ct),
},
};
}
public async Task<IReadOnlyList<SuggestionRapprochement>> SuggestionsAsync(
CancellationToken ct = default)
{
@@ -217,6 +277,15 @@ public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
// la clé primaire du lien est (LivreId, AuteurId).
if (!dejaLies.Add(lien.LivreId))
{
var existant = cible.Livres.FirstOrDefault(l => l.LivreId == lien.LivreId);
if (existant is not null)
{
existant.Position = Math.Min(existant.Position, lien.Position);
if (existant.Role == RoleAuteur.NonPrecise)
{
existant.Role = lien.Role;
}
}
db.LivreAuteurs.Remove(lien);
continue;
}
@@ -227,6 +296,7 @@ public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
LivreId = lien.LivreId,
AuteurId = cible.Id,
Position = lien.Position,
Role = lien.Role,
});
}
@@ -132,6 +132,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
Format = saisie.Format,
TypeDocument = saisie.TypeDocument,
CoverUrl = Vide(saisie.CoverUrl),
UrlNotice = Vide(saisie.UrlNotice),
DateAjout = DateTime.UtcNow,
// Renseigné par le serveur à partir de l'utilisateur authentifié, jamais par le client.
@@ -171,6 +172,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
livre.Format = saisie.Format;
livre.TypeDocument = saisie.TypeDocument;
livre.CoverUrl = Vide(saisie.CoverUrl);
livre.UrlNotice = Vide(saisie.UrlNotice);
livre.RecalculerFormes();
// DateAjout et AjoutePar ne sont jamais réécrits : ce sont des traces de la saisie
@@ -494,6 +496,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
TypeDocument = livre.TypeDocument,
Statut = statut,
CoverUrl = livre.CoverUrl,
UrlNotice = livre.UrlNotice,
DateAjout = livre.DateAjout,
AjoutePar = livre.AjoutePar,
PreteA = pretOuvert?.Emprunteur,
+9 -1
View File
@@ -62,6 +62,8 @@ public static class BnfSruParser
continue;
}
var identifiant = record.Element(Srw + "recordIdentifier")?.Value?.Trim();
candidats.Add(new CandidatLivre
{
Titre = titre,
@@ -72,13 +74,19 @@ public static class BnfSruParser
CoverUrl = urlCouverture,
Source = SourceMetadonnees.Bnf,
IsbnInterroge = isbnInterroge,
IdentifiantSource = record.Element(Srw + "recordIdentifier")?.Value?.Trim(),
IdentifiantSource = identifiant,
UrlNotice = UrlNotice(identifiant),
});
}
return candidats;
}
private static string? UrlNotice(string? identifiant) =>
string.IsNullOrWhiteSpace(identifiant)
? null
: $"https://catalogue.bnf.fr/{identifiant}";
/// <summary>
/// Lit la <b>première</b> notice d'une réponse SRU interrogée par <c>bib.issn</c>.
/// </summary>
@@ -83,9 +83,15 @@ public static class OpenLibraryMapper
Source = SourceMetadonnees.OpenLibrary,
IsbnInterroge = isbnInterroge,
IdentifiantSource = edition.Key,
UrlNotice = UrlNotice(edition.Key),
};
}
private static string? UrlNotice(string? cle) =>
string.IsNullOrWhiteSpace(cle)
? null
: $"https://openlibrary.org/{cle.TrimStart('/')}";
private static IReadOnlyList<string> Liste(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? [] : [valeur];