diff --git a/CLAUDE.md b/CLAUDE.md index 0766649..bc52d87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1981,3 +1981,30 @@ exception explicite à la règle générale « griser, ne jamais masquer ». Enfin, plusieurs œuvres non possédées peuvent être cochées puis ajoutées aux envies en une seule action d'écran. Chaque ajout reprend le même rapprochement et les mêmes validations que l'ajout unitaire ; les doublons sont rapportés sans effacer la sélection des autres œuvres. + +## Lot F — enrichir la fiche d'un livre (F1 et F3, 2026-08-20) + +### F1 : lien vers la notice source + +`Livre.UrlNotice` conserve l'URL de la notice utilisée pour préremplir une fiche depuis un +lookup ISBN. La BnF expose dans `srw:recordIdentifier` un ARK stable +(`ark:/12148/cb…`) : il est transformé en `https://catalogue.bnf.fr/{ark}`. OpenLibrary expose +la clé d'édition (`/books/OL…M`) : elle devient +`https://openlibrary.org/{clé}`. Ce sont des pages de notice directement consultables, et non +les endpoints JSON. + +L'URL traverse `CandidatLivre`, `EnregistrementLivre` et le client jusqu'à la création du livre. +Une saisie manuelle laisse la colonne nulle. Les livres déjà en base n'ont pas cette information : +aucun rétro-remplissage n'est possible puisque la notice choisie n'a pas été conservée à l'époque. +La fiche affiche « Voir la notice source » uniquement quand l'URL existe. + +### F3 : renommer et fusionner un auteur + +`PUT /api/auteurs/{id}` recalcule `NomNormalise` et `CleRegroupement`. Si la clé de regroupement +est déjà portée par une autre fiche, l'API répond `409` avec la fiche cible ; l'écran explique +la fusion et demande confirmation. La confirmation réutilise la fusion existante : tous les liens +`LivreAuteur` sont réattribués, les doublons de lien pour un même livre sont réunis en conservant +la position la plus précoce et un rôle renseigné plutôt que `NonPrecise`, puis la fiche source et +ses refus deviennent obsolètes. Sans collision, le renommage est direct. Cette opération est +volontairement distincte de la renormalisation automatique : c'est un choix explicite de +l'utilisateur. diff --git a/IDEES.md b/IDEES.md index 6fe81e8..44d746b 100644 --- a/IDEES.md +++ b/IDEES.md @@ -233,7 +233,9 @@ L'écran `/souhaits/ajout` rend aujourd'hui une liste plate. ## Lot F — Enrichir la fiche d'un livre -- **F1. Un lien vers la notice source** (BnF, OpenLibrary) sur la fiche. Peu coûteux, et c'est le +- ~~**F1. Un lien vers la notice source** (BnF, OpenLibrary) sur la fiche.~~ Traité le 2026-08-20 : + l'URL est conservée à la création quand le livre vient d'un lookup ISBN et affichée sur la fiche. + Peu coûteux, et c'est le recours quand une donnée paraît fausse. ⚠️ Suppose de **conserver l'identifiant de la notice** au lookup, ce que le modèle ne fait pas aujourd'hui : ce n'est donc pas un simple lien à afficher, mais une colonne de plus. @@ -241,7 +243,9 @@ L'écran `/souhaits/ajout` rend aujourd'hui une liste plate. table de tags, saisie, filtre, et la question de la **portée** (commune ? personnelle ?). Une source automatique est douteuse — le Dublin Core BnF porte `dc:subject`, mais avec un vocabulaire Rameau qui ne ressemble pas à ces mots-là. À instruire séparément du reste du lot. -- **F3. Renommer un auteur**, la correction se répercutant sur tous ses livres. ⚠️ Le n-n est déjà +- ~~**F3. Renommer un auteur**, la correction se répercutant sur tous ses livres.~~ Traité le + 2026-08-20 : le renommage recalcule les formes et propose une fusion confirmable en cas de + collision. ⚠️ Le n-n est déjà en place, donc un seul `Auteur.Nom` à changer — mais **`NomNormalise` et `CleRegroupement` se recalculent**, et la clé porte un index **unique**. Renommer « Hamilton » en « Peter F. Hamilton » peut donc **entrer en collision** avec une fiche existante : le geste est alors une **fusion**, diff --git a/MaBibli.Api/Data/MaBibliDbContext.cs b/MaBibli.Api/Data/MaBibliDbContext.cs index 3d7aaf4..a0754bf 100644 --- a/MaBibli.Api/Data/MaBibliDbContext.cs +++ b/MaBibli.Api/Data/MaBibliDbContext.cs @@ -50,6 +50,7 @@ public class MaBibliDbContext(DbContextOptions 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. diff --git a/MaBibli.Api/Data/Migrations/20260820115411_NoticeBibliographique.Designer.cs b/MaBibli.Api/Data/Migrations/20260820115411_NoticeBibliographique.Designer.cs new file mode 100644 index 0000000..bad7692 --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260820115411_NoticeBibliographique.Designer.cs @@ -0,0 +1,532 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CleRegroupement") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Nom") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SerieId") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeDocument") + .HasColumnType("INTEGER"); + + b.Property("UrlNotice") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Isbn"); + + b.HasIndex("TitreNormalise"); + + b.ToTable("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "AuteurId"); + + b.HasIndex("AuteurId"); + + b.ToTable("LivreAuteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Annee") + .HasColumnType("TEXT"); + + b.Property("Auteur") + .HasColumnType("TEXT"); + + b.Property("AuteurNormalise") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Rang") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("DateParution") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Numero") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NumeroNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevueId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RevueId", "NumeroNormalise") + .IsUnique(); + + b.ToTable("NumerosRevue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DatePret") + .HasColumnType("TEXT"); + + b.Property("DateRetour") + .HasColumnType("TEXT"); + + b.Property("Emprunteur") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurAId") + .HasColumnType("INTEGER"); + + b.Property("AuteurBId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuteurAId", "AuteurBId") + .IsUnique(); + + b.ToTable("RapprochementsRefuses"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Issn") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SerieParenteId") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateMaj") + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Statut") + .HasColumnType("INTEGER"); + + b.Property("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 + } + } +} + diff --git a/MaBibli.Api/Data/Migrations/20260820115411_NoticeBibliographique.cs b/MaBibli.Api/Data/Migrations/20260820115411_NoticeBibliographique.cs new file mode 100644 index 0000000..b173b2f --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260820115411_NoticeBibliographique.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + /// + public partial class NoticeBibliographique : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UrlNotice", + table: "Livres", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "UrlNotice", + table: "Livres"); + } + } +} + diff --git a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs index 325589b..9c434a7 100644 --- a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs +++ b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs @@ -139,6 +139,9 @@ namespace MaBibli.Api.Data.Migrations b.Property("TypeDocument") .HasColumnType("INTEGER"); + b.Property("UrlNotice") + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("Isbn"); diff --git a/MaBibli.Api/Endpoints/LivresEndpoints.cs b/MaBibli.Api/Endpoints/LivresEndpoints.cs index bf2fbb9..bbdf50b 100644 --- a/MaBibli.Api/Endpoints/LivresEndpoints.cs +++ b/MaBibli.Api/Endpoints/LivresEndpoints.cs @@ -186,6 +186,31 @@ public static class LivresEndpoints .Produces() .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() + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); + groupe.MapGet("/rapprochements", async (IServiceAuteurs service, CancellationToken ct) => Results.Ok(await service.SuggestionsAsync(ct))) .WithName("ListerRapprochements") diff --git a/MaBibli.Api/Services/Catalogue/ServiceAuteurs.cs b/MaBibli.Api/Services/Catalogue/ServiceAuteurs.cs index e57776d..a2ee93b 100644 --- a/MaBibli.Api/Services/Catalogue/ServiceAuteurs.cs +++ b/MaBibli.Api/Services/Catalogue/ServiceAuteurs.cs @@ -18,6 +18,10 @@ public interface IServiceAuteurs Task ObtenirAsync(int id, CancellationToken ct = default); + /// Renomme une fiche, ou signale la fiche cible à fusionner. + Task RenommerAsync( + int id, string nom, bool confirmerFusion = false, CancellationToken ct = default); + /// Rapprochements ambigus à soumettre à l'utilisateur, refus déjà écartés. Task> SuggestionsAsync(CancellationToken ct = default); @@ -139,6 +143,62 @@ public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs }) .FirstOrDefaultAsync(ct); + public async Task 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> 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, }); } diff --git a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs index 5c07c68..d62dc83 100644 --- a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs +++ b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs @@ -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, diff --git a/MaBibli.Api/Services/Isbn/BnfSruParser.cs b/MaBibli.Api/Services/Isbn/BnfSruParser.cs index c8ade46..a688bc0 100644 --- a/MaBibli.Api/Services/Isbn/BnfSruParser.cs +++ b/MaBibli.Api/Services/Isbn/BnfSruParser.cs @@ -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}"; + /// /// Lit la première notice d'une réponse SRU interrogée par bib.issn. /// diff --git a/MaBibli.Api/Services/Isbn/OpenLibraryMapper.cs b/MaBibli.Api/Services/Isbn/OpenLibraryMapper.cs index cb32471..c07df54 100644 --- a/MaBibli.Api/Services/Isbn/OpenLibraryMapper.cs +++ b/MaBibli.Api/Services/Isbn/OpenLibraryMapper.cs @@ -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 Liste(string? valeur) => string.IsNullOrWhiteSpace(valeur) ? [] : [valeur]; diff --git a/MaBibli.Client/Pages/AjoutIsbn.razor b/MaBibli.Client/Pages/AjoutIsbn.razor index 886dfc0..3903da2 100644 --- a/MaBibli.Client/Pages/AjoutIsbn.razor +++ b/MaBibli.Client/Pages/AjoutIsbn.razor @@ -431,6 +431,7 @@ Auteur = candidat.Auteur, Editeur = candidat.Editeur, CoverUrl = candidat.CoverUrl, + UrlNotice = candidat.UrlNotice, Format = Format.Physique, Statut = Statut.ALire, }; diff --git a/MaBibli.Client/Pages/Auteurs.razor b/MaBibli.Client/Pages/Auteurs.razor index 8bfec29..88e1564 100644 --- a/MaBibli.Client/Pages/Auteurs.razor +++ b/MaBibli.Client/Pages/Auteurs.razor @@ -79,15 +79,61 @@ else tout ce qu'il a ecrit et marquer ce qui manque. *@ Bibliographie + @if (_auteurRenomme == auteur.Id) + { + + + + } + else + { + + } } } +@if (_collisionRenommage is not null) +{ + +} + @code { private IReadOnlyList? _auteurs; private IReadOnlyList? _suggestions; private bool _enCours; + private bool _renommageEnCours; + private int? _auteurRenomme; + private string _nomRenomme = string.Empty; + private AuteurDto? _collisionRenommage; private string? _erreur; protected override void OnInitialized() => Reseau.Change += SurChangementReseau; @@ -128,6 +174,55 @@ else private Task RefuserAsync(SuggestionRapprochement suggestion) => DeciderAsync(() => Api.RefuserRapprochementAsync(suggestion.Source.Id, suggestion.Cible.Id)); + private void CommencerRenommage(AuteurDto auteur) + { + _auteurRenomme = auteur.Id; + _nomRenomme = auteur.Nom; + _collisionRenommage = null; + _erreur = null; + } + + private void AnnulerRenommage() + { + _auteurRenomme = null; + _collisionRenommage = null; + _nomRenomme = string.Empty; + } + + private Task RenommerAsync() => EnvoyerRenommageAsync(false); + + private Task ConfirmerFusionAsync() => EnvoyerRenommageAsync(true); + + private async Task EnvoyerRenommageAsync(bool confirmerFusion) + { + if (_auteurRenomme is null || string.IsNullOrWhiteSpace(_nomRenomme)) + { + _erreur = "Le nom de l'auteur est obligatoire."; + return; + } + + _renommageEnCours = true; + _erreur = null; + var resultat = await Api.RenommerAuteurAsync( + _auteurRenomme.Value, _nomRenomme, confirmerFusion); + _renommageEnCours = false; + + if (resultat is null) + { + _erreur = "Le renommage n'a pas pu être enregistré."; + return; + } + + if (resultat.Collision is not null) + { + _collisionRenommage = resultat.Collision; + return; + } + + AnnulerRenommage(); + await ChargerAsync(); + } + private async Task DeciderAsync(Func> action) { _enCours = true; diff --git a/MaBibli.Client/Pages/FicheLivre.razor b/MaBibli.Client/Pages/FicheLivre.razor index 3baeaed..44b6ebb 100644 --- a/MaBibli.Client/Pages/FicheLivre.razor +++ b/MaBibli.Client/Pages/FicheLivre.razor @@ -149,6 +149,11 @@ else {
Type
@Libelles.TypeDocument(_livre.TypeDocument)
} + + @if (!string.IsNullOrWhiteSpace(_livre.UrlNotice)) + { + + } @@ -329,6 +334,7 @@ else TypeDocument = livre.TypeDocument, Statut = livre.Statut, CoverUrl = livre.CoverUrl, + UrlNotice = livre.UrlNotice, }; /// diff --git a/MaBibli.Client/Pages/Serie.razor b/MaBibli.Client/Pages/Serie.razor index 55f7371..bac9bda 100644 --- a/MaBibli.Client/Pages/Serie.razor +++ b/MaBibli.Client/Pages/Serie.razor @@ -646,6 +646,7 @@ else Auteurs = candidat.Auteurs.Select(a => new AuteurSaisi(a)).ToList(), Editeur = candidat.Editeur, CoverUrl = candidat.CoverUrl, + UrlNotice = candidat.UrlNotice, }; var resultat = await Api.CreerAsync(saisie); _enCours = false; diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs index c8408eb..38113d4 100644 --- a/MaBibli.Client/Services/ServiceLivresApi.cs +++ b/MaBibli.Client/Services/ServiceLivresApi.cs @@ -240,6 +240,41 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat return instantane?.Donnees.FirstOrDefault(a => a.Id == id); } + public async Task RenommerAuteurAsync( + int id, string nom, bool confirmerFusion = false, CancellationToken ct = default) + { + if (!reseau.EnLigne) + { + return null; + } + + var suffixe = confirmerFusion ? "?confirmerFusion=true" : string.Empty; + try + { + var reponse = await http.PutAsJsonAsync( + $"api/auteurs/{id}{suffixe}", new RenommerAuteur(nom), Json, ct); + reseau.SignalerSuccesReseau(); + + if (reponse.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + + if (reponse.StatusCode == HttpStatusCode.Conflict + || reponse.IsSuccessStatusCode) + { + return await LireAsync(reponse, ct); + } + + return null; + } + catch (HttpRequestException) + { + reseau.SignalerEchecReseau(); + return null; + } + } + /// /// Rapprochements d'auteurs ambigus, que le serveur refuse d'appliquer seul. /// diff --git a/MaBibli.Shared/Dtos/CandidatLivre.cs b/MaBibli.Shared/Dtos/CandidatLivre.cs index facbf75..ffcf028 100644 --- a/MaBibli.Shared/Dtos/CandidatLivre.cs +++ b/MaBibli.Shared/Dtos/CandidatLivre.cs @@ -60,4 +60,7 @@ public record CandidatLivre /// Identifiant chez la source : ark BnF (ark:/12148/cb…) ou clé OpenLibrary (/books/OL…M). public string? IdentifiantSource { get; init; } + + /// URL de consultation de la notice chez la source. + public string? UrlNotice { get; init; } } diff --git a/MaBibli.Shared/Dtos/EnregistrementLivre.cs b/MaBibli.Shared/Dtos/EnregistrementLivre.cs index b6be8a2..12ff921 100644 --- a/MaBibli.Shared/Dtos/EnregistrementLivre.cs +++ b/MaBibli.Shared/Dtos/EnregistrementLivre.cs @@ -46,6 +46,11 @@ public record EnregistrementLivre public string? CoverUrl { get; set; } + /// + /// Notice ayant servi au préremplissage, ou null pour une saisie manuelle. + /// + public string? UrlNotice { get; set; } + /// /// Vue « une seule ligne » de , pour les champs de saisie simples. /// @@ -103,6 +108,19 @@ public record EnregistrementLivre /// public record AuteurSaisi(string Nom, RoleAuteur Role = RoleAuteur.NonPrecise); +/// Demande de renommage d'une fiche auteur. +public record RenommerAuteur(string Nom); + +/// Résultat d'un renommage, avec collision éventuelle à confirmer. +public record ResultatRenommageAuteur +{ + public AuteurDto? Auteur { get; init; } + + public AuteurDto? Collision { get; init; } + + public bool EstCollision => Collision is not null; +} + /// Charge utile du changement de statut de lecture — l'action la plus fréquente. /// /// Le statut posé est toujours celui de l'appelant : il n'y a pas de champ « utilisateur », diff --git a/MaBibli.Shared/Dtos/LivreDto.cs b/MaBibli.Shared/Dtos/LivreDto.cs index 29b319a..923943f 100644 --- a/MaBibli.Shared/Dtos/LivreDto.cs +++ b/MaBibli.Shared/Dtos/LivreDto.cs @@ -58,6 +58,8 @@ public record LivreDto public string? CoverUrl { get; init; } + public string? UrlNotice { get; init; } + public required DateTime DateAjout { get; init; } /// diff --git a/MaBibli.Shared/Entites/Livre.cs b/MaBibli.Shared/Entites/Livre.cs index 9bae6ef..db2a14e 100644 --- a/MaBibli.Shared/Entites/Livre.cs +++ b/MaBibli.Shared/Entites/Livre.cs @@ -40,6 +40,9 @@ public class Livre /// URL de la couverture (OpenLibrary), pas de fichier stocké localement. public string? CoverUrl { get; set; } + /// URL de la notice bibliographique qui a servi à préremplir la fiche. + public string? UrlNotice { get; set; } + public DateTime DateAjout { get; set; } /// diff --git a/MaBibli.Tests/BnfSruParserTests.cs b/MaBibli.Tests/BnfSruParserTests.cs index 2402a61..973f0ab 100644 --- a/MaBibli.Tests/BnfSruParserTests.cs +++ b/MaBibli.Tests/BnfSruParserTests.cs @@ -36,6 +36,7 @@ public class BnfSruParserTests Assert.Equal("9782070612758", c.IsbnInterroge); Assert.Equal("https://couverture", c.CoverUrl); Assert.StartsWith("ark:/12148/", c.IdentifiantSource); + Assert.Equal($"https://catalogue.bnf.fr/{c.IdentifiantSource}", c.UrlNotice); } [Fact] diff --git a/MaBibli.Tests/OpenLibraryMapperTests.cs b/MaBibli.Tests/OpenLibraryMapperTests.cs index 28b871d..44fe9af 100644 --- a/MaBibli.Tests/OpenLibraryMapperTests.cs +++ b/MaBibli.Tests/OpenLibraryMapperTests.cs @@ -43,6 +43,7 @@ public class OpenLibraryMapperTests Assert.Equal("March 2007", c.Annee); Assert.Equal("fre", c.Langue); Assert.Equal(SourceMetadonnees.OpenLibrary, c.Source); + Assert.Equal("https://openlibrary.org/books/OL9567312M", c.UrlNotice); Assert.Equal("/books/OL9567312M", c.IdentifiantSource); Assert.Equal("https://couverture", c.CoverUrl); } diff --git a/MaBibli.Tests/ServiceAuteursTests.cs b/MaBibli.Tests/ServiceAuteursTests.cs index a274870..cac12cd 100644 --- a/MaBibli.Tests/ServiceAuteursTests.cs +++ b/MaBibli.Tests/ServiceAuteursTests.cs @@ -189,6 +189,45 @@ public class ServiceAuteursTests : IDisposable Assert.Single(livre.Auteurs); } + [Fact] + public async Task Renommer_un_auteur_recalcule_ses_formes() + { + var livre = await AjouterAsync("Pandora's Star", "Hamilton"); + + var resultat = await _auteurs.RenommerAsync( + livre.Auteurs.Single().Id, "Peter F. Hamilton"); + + Assert.False(resultat.EstCollision); + Assert.Equal("Peter F. Hamilton", resultat.Auteur!.Nom); + + var auteur = await _db.Auteurs.SingleAsync(); + Assert.Equal("peter f hamilton", auteur.NomNormalise); + Assert.Equal("f hamilton peter", auteur.CleRegroupement); + } + + [Fact] + public async Task Renommer_avec_collision_demande_puis_fusionne() + { + var court = await AjouterAsync("Judas Unchained", "Hamilton"); + var complet = await AjouterAsync("Pandora's Star", "Peter F. Hamilton"); + var idCourt = court.Auteurs.Single().Id; + var idComplet = complet.Auteurs.Single().Id; + + var avertissement = await _auteurs.RenommerAsync(idCourt, "Peter F. Hamilton"); + + Assert.True(avertissement.EstCollision); + Assert.Equal(idComplet, avertissement.Collision!.Id); + Assert.Equal(2, await _db.Auteurs.CountAsync()); + + var fusion = await _auteurs.RenommerAsync( + idCourt, "Peter F. Hamilton", confirmerFusion: true); + + Assert.False(fusion.EstCollision); + Assert.Single(await _db.Auteurs.ToListAsync()); + Assert.Equal(2, await _db.LivreAuteurs.CountAsync()); + Assert.Equal(2, (await _auteurs.ListerAsync()).Single().NombreDeLivres); + } + public void Dispose() { _db.Dispose(); diff --git a/MaBibli.Tests/ServiceCatalogueTests.cs b/MaBibli.Tests/ServiceCatalogueTests.cs index 4422b4a..c13df74 100644 --- a/MaBibli.Tests/ServiceCatalogueTests.cs +++ b/MaBibli.Tests/ServiceCatalogueTests.cs @@ -70,6 +70,25 @@ public class ServiceCatalogueTests : IDisposable Assert.Equal("9782253004226", resultat.Livre!.Isbn); } + [Fact] + public async Task Creer_conserve_lurl_de_la_notice_source() + { + var resultat = await _service.CreerAsync( + new EnregistrementLivre + { + Titre = "Germinal", + UrlNotice = "https://catalogue.bnf.fr/ark:/12148/cb34676750b", + }, + "mathieu"); + + Assert.Equal( + "https://catalogue.bnf.fr/ark:/12148/cb34676750b", + resultat.Livre!.UrlNotice); + Assert.Equal( + resultat.Livre.UrlNotice, + (await _db.Livres.SingleAsync()).UrlNotice); + } + [Fact] public async Task Creer_refuse_un_titre_vide() {