From b594a4b2c140594ba5071bec3883eb375f19c620 Mon Sep 17 00:00:00 2001 From: mathieu Date: Tue, 18 Aug 2026 02:22:46 +0200 Subject: [PATCH] Sortir le statut de lecture et l'auteur de la table Livre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux changements de modele en une seule migration, plus la recherche qui en depend. Le statut de lecture devient personnel. Il etait une colonne de Livre, donc partage par tout le foyer, alors que deux membres lisent le meme exemplaire a des rythmes differents. Il vit desormais dans une table (LivreId, Utilisateur, Statut) avec unicite sur le couple. L'absence de ligne vaut « non commence » : sur une bibliotheque de foyer la plupart des couples n'ont aucun statut, et les materialiser tous multiplierait les lignes par le nombre de comptes pour n'exprimer qu'un vide. Rien n'est donc ecrit a la creation d'un livre. Piege rencontre : un Dictionary renvoyait la valeur 0 de l'enumeration — « À lire » — pour un livre sans ligne, rendant « non commence » indiscernable d'un choix explicite. Le dictionnaire est desormais typé Statut?. L'auteur devient une table. Deux formes normalisees y cohabitent, et ce n'est pas une redondance : NomNormalise garde l'ordre de saisie pour la recherche en sous-chaine, CleRegroupement trie les mots et porte l'index unique, donc l'invariant « un auteur, une fiche ». Les initiales echappent a la cle et sont traitees en memoire, sur une table qui compte au plus quelques centaines de lignes. Un livre peut avoir plusieurs auteurs — le lookup ISBN en renvoie quatre pour Introduction to Algorithms — d'ou la table de liaison, avec une position qui conserve l'ordre de la couverture. Les rapprochements ambigus ne sont jamais appliques seuls : l'API les liste, l'utilisateur accepte ou refuse, et les refus sont memorises pour que la suggestion ne revienne pas. Le couple refuse est range par identifiant croissant, donc un refus vaut dans les deux sens. Reprise des donnees existantes. L'ancien statut, commun, est rattache a AjoutePar — seule personne que la base associe au livre. Les statuts des livres sans AjoutePar sont perdus : les attribuer serait une invention. Les trois valeurs sont reprises telles quelles, « À lire » compris, parce que c'est ce que l'ancienne interface affichait. L'ancien champ auteur devient une fiche par valeur distincte. La migration ne peut pas tout faire : lower() de SQLite ne retire pas les accents, donc « Émile Zola » et « emile zola » y restent deux fiches. ServiceRenormalisation finit le travail en C# au demarrage, reunit ces variantes, applique aussi la regle des initiales — sans quoi une base heritee resterait eclatee la ou une saisie neuve aurait ete reunie d'emblee — et garde le nom d'affichage le plus presentable. Il est idempotent, et sert de filet si les regles de normalisation changent. L'ordre de la migration compte : les colonnes condamnees sont recopiees dans une table de transit avant d'etre supprimees, parce que supprimer une colonne sous SQLite reconstruit la table. Verifie sur une base a l'ancien schema contenant 9 livres, 2 prets et trois variantes de Zola : prets intacts, statuts rattaches, les trois Zola reunis sous « Émile Zola », « P.F. Hamilton » absorbe par « Peter F. Hamilton », « Hamilton » seul laisse en suggestion. Co-Authored-By: Claude Opus 5 --- MaBibli.Api/Data/MaBibliDbContext.cs | 67 ++++ ..._StatutPersonnelEtTableAuteurs.Designer.cs | 242 +++++++++++++++ ...818000335_StatutPersonnelEtTableAuteurs.cs | 278 +++++++++++++++++ .../MaBibliDbContextModelSnapshot.cs | 146 ++++++++- MaBibli.Api/Data/ServiceRenormalisation.cs | 205 ++++++++++++ MaBibli.Api/Endpoints/LivresEndpoints.cs | 98 +++++- MaBibli.Api/Program.cs | 12 +- .../Services/Catalogue/ServiceAuteurs.cs | 291 ++++++++++++++++++ .../Services/Catalogue/ServiceCatalogue.cs | 196 ++++++++++-- MaBibli.Api/Services/Isbn/BnfSruParser.cs | 28 +- .../Services/Isbn/OpenLibraryMapper.cs | 14 +- MaBibli.Client/Composants/ChoixStatut.razor | 40 +++ .../Composants/FormulaireLivre.razor | 27 +- MaBibli.Client/Pages/Auteurs.razor | 129 ++++++++ MaBibli.Client/Pages/Catalogue.razor | 70 +++-- MaBibli.Client/Pages/EditionLivre.razor | 5 +- MaBibli.Client/Services/ServiceLivresApi.cs | 45 ++- MaBibli.Shared/Catalogue/CritereLivres.cs | 20 +- MaBibli.Shared/Catalogue/FiltreLivres.cs | 50 ++- MaBibli.Shared/Dtos/CandidatLivre.cs | 20 +- MaBibli.Shared/Dtos/EnregistrementLivre.cs | 50 ++- MaBibli.Shared/Dtos/LivreDto.cs | 30 +- .../Dtos/RapprochementAuteursDto.cs | 27 ++ MaBibli.Shared/Entites/Auteur.cs | 85 +++++ MaBibli.Shared/Entites/Livre.cs | 31 +- MaBibli.Shared/Entites/StatutLecture.cs | 39 +++ MaBibli.Tests/BnfSruParserTests.cs | 75 +++++ MaBibli.Tests/FiltreLivresTests.cs | 127 ++++++-- MaBibli.Tests/OpenLibraryMapperTests.cs | 6 +- MaBibli.Tests/ServiceAuteursTests.cs | 198 ++++++++++++ MaBibli.Tests/ServiceCatalogueTests.cs | 174 +++++++++-- MaBibli.Tests/ServiceRenormalisationTests.cs | 195 ++++++++++++ 32 files changed, 2856 insertions(+), 164 deletions(-) create mode 100644 MaBibli.Api/Data/Migrations/20260818000335_StatutPersonnelEtTableAuteurs.Designer.cs create mode 100644 MaBibli.Api/Data/Migrations/20260818000335_StatutPersonnelEtTableAuteurs.cs create mode 100644 MaBibli.Api/Data/ServiceRenormalisation.cs create mode 100644 MaBibli.Api/Services/Catalogue/ServiceAuteurs.cs create mode 100644 MaBibli.Client/Composants/ChoixStatut.razor create mode 100644 MaBibli.Client/Pages/Auteurs.razor create mode 100644 MaBibli.Shared/Dtos/RapprochementAuteursDto.cs create mode 100644 MaBibli.Shared/Entites/Auteur.cs create mode 100644 MaBibli.Shared/Entites/StatutLecture.cs create mode 100644 MaBibli.Tests/ServiceAuteursTests.cs create mode 100644 MaBibli.Tests/ServiceRenormalisationTests.cs diff --git a/MaBibli.Api/Data/MaBibliDbContext.cs b/MaBibli.Api/Data/MaBibliDbContext.cs index 336672d..09f2187 100644 --- a/MaBibli.Api/Data/MaBibliDbContext.cs +++ b/MaBibli.Api/Data/MaBibliDbContext.cs @@ -7,6 +7,14 @@ public class MaBibliDbContext(DbContextOptions options) : DbCo { public DbSet Livres => Set(); + public DbSet Auteurs => Set(); + + public DbSet LivreAuteurs => Set(); + + public DbSet StatutsLecture => Set(); + + public DbSet RapprochementsRefuses => Set(); + public DbSet Prets => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -16,13 +24,72 @@ public class MaBibliDbContext(DbContextOptions options) : DbCo modelBuilder.Entity(livre => { livre.Property(l => l.Titre).IsRequired(); + livre.Property(l => l.TitreNormalise).IsRequired(); livre.HasIndex(l => l.Isbn); + + // Le tri et la recherche passent par là : sans index, chaque frappe balaie la table. + livre.HasIndex(l => l.TitreNormalise); + livre.HasMany(l => l.Prets) .WithOne(p => p.Livre) .HasForeignKey(p => p.LivreId) .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity(auteur => + { + auteur.Property(a => a.Nom).IsRequired(); + auteur.Property(a => a.NomNormalise).IsRequired(); + auteur.Property(a => a.CleRegroupement).IsRequired(); + + // Unicité de la clé de regroupement : « Zola, Émile » et « Émile Zola » produisent + // la même clé et ne peuvent donc pas coexister. C'est la garantie structurelle du + // regroupement — le code applicatif peut avoir un trou, la base non. + auteur.HasIndex(a => a.CleRegroupement).IsUnique(); + auteur.HasIndex(a => a.NomNormalise); + }); + + modelBuilder.Entity(lien => + { + lien.HasKey(la => new { la.LivreId, la.AuteurId }); + + lien.HasOne(la => la.Livre) + .WithMany(l => l.Auteurs) + .HasForeignKey(la => la.LivreId) + .OnDelete(DeleteBehavior.Cascade); + + // Restrict : supprimer un auteur encore rattaché à des livres les priverait de leur + // auteur en silence. On passe par une fusion, ou par la suppression des livres. + lien.HasOne(la => la.Auteur) + .WithMany(a => a.Livres) + .HasForeignKey(la => la.AuteurId) + .OnDelete(DeleteBehavior.Restrict); + + lien.HasIndex(la => la.AuteurId); + }); + + modelBuilder.Entity(statut => + { + statut.Property(s => s.Utilisateur).IsRequired(); + + statut.HasOne(s => s.Livre) + .WithMany(l => l.Statuts) + .HasForeignKey(s => s.LivreId) + .OnDelete(DeleteBehavior.Cascade); + + // Une personne, un statut par livre. L'unicité est ce qui rend l'écriture idempotente : + // on lit la ligne, on la met à jour, et il ne peut jamais y en avoir deux à départager. + statut.HasIndex(s => new { s.LivreId, s.Utilisateur }).IsUnique(); + statut.HasIndex(s => s.Utilisateur); + }); + + modelBuilder.Entity(refus => + { + // Le couple est rangé (petit identifiant d'abord) à l'écriture : l'unicité vaut donc + // dans les deux sens, et un refus ne peut pas être enregistré deux fois. + refus.HasIndex(r => new { r.AuteurAId, r.AuteurBId }).IsUnique(); + }); + modelBuilder.Entity(pret => { pret.Property(p => p.Emprunteur).IsRequired(); diff --git a/MaBibli.Api/Data/Migrations/20260818000335_StatutPersonnelEtTableAuteurs.Designer.cs b/MaBibli.Api/Data/Migrations/20260818000335_StatutPersonnelEtTableAuteurs.Designer.cs new file mode 100644 index 0000000..0cac4c6 --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260818000335_StatutPersonnelEtTableAuteurs.Designer.cs @@ -0,0 +1,242 @@ +// +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("20260818000335_StatutPersonnelEtTableAuteurs")] + partial class StatutPersonnelEtTableAuteurs + { + /// + 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.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.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.HasKey("LivreId", "AuteurId"); + + b.HasIndex("AuteurId"); + + b.ToTable("LivreAuteurs"); + }); + + 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("LivreId"); + + 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.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.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.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.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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MaBibli.Api/Data/Migrations/20260818000335_StatutPersonnelEtTableAuteurs.cs b/MaBibli.Api/Data/Migrations/20260818000335_StatutPersonnelEtTableAuteurs.cs new file mode 100644 index 0000000..5a20fac --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260818000335_StatutPersonnelEtTableAuteurs.cs @@ -0,0 +1,278 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + /// + /// Sort le statut de lecture et l'auteur de la table Livres. + /// + /// + /// Ce que deviennent les données existantes. + /// + /// + /// Livres.Statut était commun à tout le foyer. Il est rattaché à + /// AjoutePar, la seule personne que la base associe au livre. Les statuts des + /// livres sans AjoutePar (saisis sans identité) sont perdus : les attribuer à + /// quelqu'un serait une invention, et les garder pour tout le monde serait exactement le + /// défaut que cette migration corrige. Les trois valeurs sont reprises telles quelles, « À + /// lire » compris : c'est ce que l'ancienne interface affichait, et le taire changerait ce + /// que l'utilisateur voit. + /// + /// + /// Livres.Auteur devient une ligne de Auteurs par valeur distincte, plus un lien + /// en position 0. Le regroupement se fait ici sur une simple mise en minuscules — SQLite ne + /// sait pas retirer les accents. La normalisation complète, et la fusion des variantes qu'elle + /// révèle, sont faites en C# au démarrage suivant (voir ServiceRenormalisation). + /// + /// + /// L'ordre des opérations compte : tout est recopié avant que les anciennes colonnes + /// ne soient supprimées. + /// + public partial class StatutPersonnelEtTableAuteurs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Mise à l'abri du contenu des deux colonnes condamnées. Supprimer une colonne sous + // SQLite reconstruit la table : on le fait pendant que Livres n'a pas encore + // d'enfants à préserver, et on recopie ensuite depuis cette table de transit. + migrationBuilder.Sql( + """ + CREATE TABLE _RepriseLivres AS + SELECT Id, Auteur, Statut FROM Livres; + """); + + migrationBuilder.DropColumn( + name: "Auteur", + table: "Livres"); + + migrationBuilder.DropColumn( + name: "Statut", + table: "Livres"); + + migrationBuilder.AddColumn( + name: "TitreNormalise", + table: "Livres", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "Auteurs", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Nom = table.Column(type: "TEXT", nullable: false), + NomNormalise = table.Column(type: "TEXT", nullable: false), + CleRegroupement = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Auteurs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RapprochementsRefuses", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + AuteurAId = table.Column(type: "INTEGER", nullable: false), + AuteurBId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RapprochementsRefuses", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "StatutsLecture", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + LivreId = table.Column(type: "INTEGER", nullable: false), + Utilisateur = table.Column(type: "TEXT", nullable: false), + Statut = table.Column(type: "INTEGER", nullable: false), + DateMaj = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StatutsLecture", x => x.Id); + table.ForeignKey( + name: "FK_StatutsLecture_Livres_LivreId", + column: x => x.LivreId, + principalTable: "Livres", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "LivreAuteurs", + columns: table => new + { + LivreId = table.Column(type: "INTEGER", nullable: false), + AuteurId = table.Column(type: "INTEGER", nullable: false), + Position = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LivreAuteurs", x => new { x.LivreId, x.AuteurId }); + table.ForeignKey( + name: "FK_LivreAuteurs_Auteurs_AuteurId", + column: x => x.AuteurId, + principalTable: "Auteurs", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_LivreAuteurs_Livres_LivreId", + column: x => x.LivreId, + principalTable: "Livres", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Livres_TitreNormalise", + table: "Livres", + column: "TitreNormalise"); + + migrationBuilder.CreateIndex( + name: "IX_Auteurs_CleRegroupement", + table: "Auteurs", + column: "CleRegroupement", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Auteurs_NomNormalise", + table: "Auteurs", + column: "NomNormalise"); + + migrationBuilder.CreateIndex( + name: "IX_LivreAuteurs_AuteurId", + table: "LivreAuteurs", + column: "AuteurId"); + + migrationBuilder.CreateIndex( + name: "IX_RapprochementsRefuses_AuteurAId_AuteurBId", + table: "RapprochementsRefuses", + columns: new[] { "AuteurAId", "AuteurBId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_StatutsLecture_LivreId_Utilisateur", + table: "StatutsLecture", + columns: new[] { "LivreId", "Utilisateur" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_StatutsLecture_Utilisateur", + table: "StatutsLecture", + column: "Utilisateur"); + + // ── Reprise des données depuis la table de transit ────────────────────────────── + + // Le titre normalisé n'est qu'approché ici (lower() de SQLite ne touche pas aux + // accents) ; le passage C# du démarrage le recalculera correctement. + migrationBuilder.Sql("UPDATE Livres SET TitreNormalise = lower(trim(Titre));"); + + // Une fiche auteur par valeur distincte, à la casse près. Le nom d'affichage retenu + // est le premier dans l'ordre alphabétique : arbitraire, mais déterministe, et + // l'utilisateur peut le corriger depuis n'importe quel livre. + migrationBuilder.Sql( + """ + INSERT INTO Auteurs (Nom, NomNormalise, CleRegroupement) + SELECT min(trim(Auteur)), lower(trim(Auteur)), lower(trim(Auteur)) + FROM _RepriseLivres + WHERE Auteur IS NOT NULL AND trim(Auteur) <> '' + GROUP BY lower(trim(Auteur)); + """); + + migrationBuilder.Sql( + """ + INSERT INTO LivreAuteurs (LivreId, AuteurId, Position) + SELECT r.Id, a.Id, 0 + FROM _RepriseLivres r + JOIN Auteurs a ON a.CleRegroupement = lower(trim(r.Auteur)) + JOIN Livres l ON l.Id = r.Id + WHERE r.Auteur IS NOT NULL AND trim(r.Auteur) <> ''; + """); + + // Le statut commun devient celui de la personne qui a saisi le livre. Sans AjoutePar, + // il n'y a personne à qui l'attribuer : la ligne n'est pas créée. + migrationBuilder.Sql( + """ + INSERT INTO StatutsLecture (LivreId, Utilisateur, Statut, DateMaj) + SELECT l.Id, trim(l.AjoutePar), r.Statut, l.DateAjout + FROM Livres l + JOIN _RepriseLivres r ON r.Id = l.Id + WHERE l.AjoutePar IS NOT NULL AND trim(l.AjoutePar) <> ''; + """); + + migrationBuilder.Sql("DROP TABLE _RepriseLivres;"); + } + + /// + /// + /// Retour en arrière. Une partie de l'information est perdue par nature : l'ancien + /// modèle n'a qu'une case pour un auteur et une pour un statut. On y remet le premier + /// auteur et le statut de AjoutePar ; les co-auteurs et les statuts des autres + /// membres du foyer disparaissent. + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Auteur", + table: "Livres", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Statut", + table: "Livres", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.Sql( + """ + UPDATE Livres SET Auteur = ( + SELECT a.Nom FROM LivreAuteurs la + JOIN Auteurs a ON a.Id = la.AuteurId + WHERE la.LivreId = Livres.Id + ORDER BY la.Position LIMIT 1); + """); + + migrationBuilder.Sql( + """ + UPDATE Livres SET Statut = coalesce(( + SELECT s.Statut FROM StatutsLecture s + WHERE s.LivreId = Livres.Id AND s.Utilisateur = trim(Livres.AjoutePar) + LIMIT 1), 0); + """); + + migrationBuilder.DropTable( + name: "LivreAuteurs"); + + migrationBuilder.DropTable( + name: "RapprochementsRefuses"); + + migrationBuilder.DropTable( + name: "StatutsLecture"); + + migrationBuilder.DropTable( + name: "Auteurs"); + + migrationBuilder.DropIndex( + name: "IX_Livres_TitreNormalise", + table: "Livres"); + + migrationBuilder.DropColumn( + name: "TitreNormalise", + table: "Livres"); + } + } +} diff --git a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs index 222a8a3..418e5c9 100644 --- a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs +++ b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs @@ -17,6 +17,34 @@ namespace MaBibli.Api.Data.Migrations #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.Livre", b => { b.Property("Id") @@ -26,9 +54,6 @@ namespace MaBibli.Api.Data.Migrations b.Property("AjoutePar") .HasColumnType("TEXT"); - b.Property("Auteur") - .HasColumnType("TEXT"); - b.Property("CoverUrl") .HasColumnType("TEXT"); @@ -44,20 +69,41 @@ namespace MaBibli.Api.Data.Migrations b.Property("Isbn") .HasColumnType("TEXT"); - b.Property("Statut") - .HasColumnType("INTEGER"); - b.Property("Titre") .IsRequired() .HasColumnType("TEXT"); + b.Property("TitreNormalise") + .IsRequired() + .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.HasKey("LivreId", "AuteurId"); + + b.HasIndex("AuteurId"); + + b.ToTable("LivreAuteurs"); + }); + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => { b.Property("Id") @@ -84,6 +130,74 @@ namespace MaBibli.Api.Data.Migrations 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.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.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.Pret", b => { b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") @@ -95,9 +209,29 @@ namespace MaBibli.Api.Data.Migrations b.Navigation("Livre"); }); + 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"); }); #pragma warning restore 612, 618 } diff --git a/MaBibli.Api/Data/ServiceRenormalisation.cs b/MaBibli.Api/Data/ServiceRenormalisation.cs new file mode 100644 index 0000000..1cda3e7 --- /dev/null +++ b/MaBibli.Api/Data/ServiceRenormalisation.cs @@ -0,0 +1,205 @@ +using MaBibli.Shared.Entites; +using MaBibli.Shared.Textes; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Api.Data; + +/// +/// Remet en cohérence les colonnes normalisées, et réunit les auteurs que cette normalisation +/// révèle identiques. +/// +/// +/// Pourquoi ce passage existe. La migration a recopié les anciennes données avec les +/// moyens du bord : lower() de SQLite ne retire pas les accents, donc « Émile Zola » et +/// « emile zola » y sont restés deux auteurs distincts et les titres normalisés sont approchés. +/// Seul C# sait appliquer les vraies règles. +/// +/// Le passage est idempotent et ne touche que ce qui diffère : il ne coûte rien au +/// démarrage suivant. Il sert aussi de filet si les règles de normalisation changent un jour — +/// le prochain démarrage rattrape la base sans migration à écrire. +/// +/// +public sealed class ServiceRenormalisation(MaBibliDbContext db, ILogger logger) +{ + public async Task ExecuterAsync(CancellationToken ct = default) + { + var titres = await RenormaliserTitresAsync(ct); + var (auteursCorriges, auteursFusionnes) = await RenormaliserAuteursAsync(ct); + + if (titres + auteursCorriges + auteursFusionnes > 0) + { + logger.LogInformation( + "Renormalisation : {Titres} titre(s), {Auteurs} auteur(s) corrigés, {Fusions} fusionné(s).", + titres, auteursCorriges, auteursFusionnes); + } + } + + private async Task RenormaliserTitresAsync(CancellationToken ct) + { + var livres = await db.Livres.ToListAsync(ct); + var corriges = 0; + + foreach (var livre in livres) + { + var attendu = NormalisationTexte.Normaliser(livre.Titre); + if (livre.TitreNormalise == attendu) + { + continue; + } + + livre.TitreNormalise = attendu; + corriges++; + } + + if (corriges > 0) + { + await db.SaveChangesAsync(ct); + } + + return corriges; + } + + private async Task<(int Corriges, int Fusionnes)> RenormaliserAuteursAsync(CancellationToken ct) + { + var auteurs = await db.Auteurs.Include(a => a.Livres).OrderBy(a => a.Id).ToListAsync(ct); + var corriges = 0; + var fusionnes = 0; + + // Premier tour : la clé de regroupement, qui couvre casse, accents et ordre des mots. + // Le premier auteur rencontré pour une clé donnée est celui qui reste ; les suivants + // sont absorbés. L'ordre par identifiant rend le résultat reproductible. + var parCle = new Dictionary(StringComparer.Ordinal); + var conserves = new List(); + + foreach (var auteur in auteurs) + { + var cle = RapprochementAuteurs.Cle(auteur.Nom); + var nomNormalise = NormalisationTexte.Normaliser(auteur.Nom); + + if (parCle.TryGetValue(cle, out var conserve)) + { + // Deux fiches que la migration n'a pas pu rapprocher (accents) et que les vraies + // règles réunissent. C'est une variante sûre : la fusion est automatique. + Absorber(conserve, auteur); + fusionnes++; + continue; + } + + parCle[cle] = auteur; + conserves.Add(auteur); + + if (auteur.CleRegroupement == cle && auteur.NomNormalise == nomNormalise) + { + continue; + } + + auteur.CleRegroupement = cle; + auteur.NomNormalise = nomNormalise; + corriges++; + } + + // Second tour : les initiales abrégées, que la clé ne sait pas capturer + // (« P.F. Hamilton » et « Peter F. Hamilton »). Sans ce tour, une base héritée + // resterait éclatée là où une saisie neuve aurait été réunie d'emblée — deux + // comportements pour une même règle. + foreach (var auteur in conserves.ToList()) + { + if (db.Entry(auteur).State == EntityState.Deleted) + { + continue; + } + + var jumeau = conserves.FirstOrDefault(autre => + !ReferenceEquals(autre, auteur) + && db.Entry(autre).State != EntityState.Deleted + && RapprochementAuteurs.SontLeMemeAuteur(auteur.Nom, autre.Nom)); + + if (jumeau is null) + { + continue; + } + + // Le nom le plus complet l'emporte : « Peter F. Hamilton » plutôt que « P.F. ». + var (conserve, absorbe) = + NomsDeveloppes(auteur.Nom) >= NomsDeveloppes(jumeau.Nom) + ? (auteur, jumeau) + : (jumeau, auteur); + + Absorber(conserve, absorbe); + conserves.Remove(absorbe); + fusionnes++; + } + + if (corriges > 0 || fusionnes > 0) + { + await db.SaveChangesAsync(ct); + } + + return (corriges, fusionnes); + } + + /// Reporte les livres de sur . + private void Absorber(Auteur conserve, Auteur absorbe) + { + // La fiche conservée est celle au plus petit identifiant, or cet ordre est un hasard de + // la migration. On rattrape sur le nom d'affichage : sans cet arbitrage, la bibliothèque + // perdrait ses accents au profit des saisies bâclées. + if (QualiteAffichage(absorbe.Nom) > QualiteAffichage(conserve.Nom)) + { + conserve.Nom = absorbe.Nom; + conserve.NomNormalise = NormalisationTexte.Normaliser(absorbe.Nom); + conserve.CleRegroupement = RapprochementAuteurs.Cle(absorbe.Nom); + } + + var dejaLies = conserve.Livres.Select(l => l.LivreId).ToHashSet(); + + foreach (var lien in absorbe.Livres.ToList()) + { + db.LivreAuteurs.Remove(lien); + + // Un livre déjà signé par la fiche conservée ne doit pas recevoir un second lien : + // la clé primaire est (LivreId, AuteurId). + if (dejaLies.Add(lien.LivreId)) + { + db.LivreAuteurs.Add(new LivreAuteur + { + LivreId = lien.LivreId, + AuteurId = conserve.Id, + Position = lien.Position, + }); + } + } + + db.Auteurs.Remove(absorbe); + } + + /// + /// Note grossière d'un nom en tant que nom d'affichage, pour départager des variantes + /// équivalentes. + /// + /// + /// Trois signaux, dans cet ordre d'importance : la forme inversée « ZOLA, Émile » est une + /// convention de catalogue, pas quelque chose qu'on veut lire dans une liste ; les capitales + /// intégrales sont une saisie négligée ; les accents, eux, sont de l'information qu'on ne + /// veut pas perdre. Le résultat vise « Émile Zola ». + /// + private static int QualiteAffichage(string nom) + { + var note = nom.Count(c => char.IsLetter(c) && c > 127) * 2; + + if (nom.Contains(',')) + { + note -= 3; + } + + note -= nom + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Count(mot => mot.Length > 1 && mot.Where(char.IsLetter).All(char.IsUpper)); + + return note; + } + + /// Nombre de mots qui ne sont pas de simples initiales. + private static int NomsDeveloppes(string nom) => + NormalisationTexte.Mots(nom).Count(mot => mot.Length > 1); +} diff --git a/MaBibli.Api/Endpoints/LivresEndpoints.cs b/MaBibli.Api/Endpoints/LivresEndpoints.cs index 2a63bcb..b418683 100644 --- a/MaBibli.Api/Endpoints/LivresEndpoints.cs +++ b/MaBibli.Api/Endpoints/LivresEndpoints.cs @@ -15,6 +15,11 @@ public static class LivresEndpoints /// Aucune lecture ne filtre sur AjoutePar : la bibliothèque est commune. /// Ce champ n'est même pas acceptable en paramètre de requête, précisément pour qu'on ne /// puisse pas s'en servir comme d'un cloisonnement par inadvertance. + /// + /// Le statut de lecture, lui, est personnel : il est toujours celui de l'appelant, + /// déduit des en-têtes SSOwat. Aucun point d'entrée n'accepte un nom d'utilisateur — on ne + /// consulte ni ne modifie la lecture de quelqu'un d'autre. + /// /// public static IEndpointRouteBuilder MapLivresEndpoints(this IEndpointRouteBuilder routes) { @@ -24,7 +29,9 @@ public static class LivresEndpoints string? recherche, Format? format, Statut? statut, + int? auteurId, IServiceCatalogue service, + IFournisseurUtilisateur utilisateurs, CancellationToken ct) => { var criteres = new CritereLivres @@ -32,20 +39,28 @@ public static class LivresEndpoints Recherche = recherche, Format = format, Statut = statut, + AuteurId = auteurId, }; - return Results.Ok(await service.ListerAsync(criteres, ct)); + var utilisateur = utilisateurs.Obtenir().Identifiant; + return Results.Ok(await service.ListerAsync(criteres, utilisateur, ct)); }) .WithName("ListerLivres") - .WithSummary("Liste le catalogue, filtrable par format et statut, cherchable sur titre et auteur.") + .WithSummary("Liste le catalogue, filtrable par format, statut et auteur ; cherchable sur titre et auteurs.") .WithDescription( "Catalogue unique : physiques et numériques cohabitent, distingués par leur format. " - + "La bibliothèque est commune — la liste n'est jamais restreinte à l'utilisateur courant.") + + "La bibliothèque est commune — la liste n'est jamais restreinte à l'utilisateur courant. " + + "Le filtre « statut » porte en revanche sur le statut de lecture de l'appelant, " + + "qui lui est propre. La recherche ignore la casse et les accents.") .Produces>(); - groupe.MapGet("/{id:int}", async (int id, IServiceCatalogue service, CancellationToken ct) => + groupe.MapGet("/{id:int}", async ( + int id, + IServiceCatalogue service, + IFournisseurUtilisateur utilisateurs, + CancellationToken ct) => { - var livre = await service.ObtenirAsync(id, ct); + var livre = await service.ObtenirAsync(id, utilisateurs.Obtenir().Identifiant, ct); return livre is null ? Results.NotFound() : Results.Ok(livre); }) .WithName("ObtenirLivre") @@ -75,9 +90,11 @@ public static class LivresEndpoints int id, EnregistrementLivre saisie, IServiceCatalogue service, + IFournisseurUtilisateur utilisateurs, CancellationToken ct) => { - var resultat = await service.ModifierAsync(id, saisie, ct); + var resultat = await service.ModifierAsync( + id, saisie, utilisateurs.Obtenir().Identifiant, ct); if (resultat.EstIntrouvable) { @@ -89,7 +106,7 @@ public static class LivresEndpoints : Results.Ok(resultat.Livre); }) .WithName("ModifierLivre") - .WithSummary("Modifie un livre. « AjoutePar » et « DateAjout » restent inchangés.") + .WithSummary("Modifie un livre. « AjoutePar » et « DateAjout » restent inchangés ; le statut posé est celui de l'appelant.") .Produces() .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound); @@ -98,13 +115,17 @@ public static class LivresEndpoints int id, ChangementStatut changement, IServiceCatalogue service, + IFournisseurUtilisateur utilisateurs, CancellationToken ct) => { - var livre = await service.ChangerStatutAsync(id, changement.Statut, ct); + var livre = await service.ChangerStatutAsync( + id, changement.Statut, utilisateurs.Obtenir().Identifiant, ct); + return livre is null ? Results.NotFound() : Results.Ok(livre); }) .WithName("ChangerStatutLivre") - .WithSummary("Change le seul statut de lecture — l'action la plus fréquente.") + .WithSummary("Change le seul statut de lecture de l'appelant — l'action la plus fréquente.") + .WithDescription("Un statut nul remet le livre à « non commencé » : la ligne est supprimée.") .Produces() .Produces(StatusCodes.Status404NotFound); @@ -120,6 +141,65 @@ public static class LivresEndpoints return routes; } + /// + /// Consultation des auteurs et arbitrage des rapprochements ambigus. + /// + /// + /// Les variantes sûres sont réunies automatiquement à l'écriture d'un livre et n'atteignent + /// jamais ces points d'entrée. Ce qui arrive ici est ce que la machine refuse de + /// trancher : un patronyme seul face à un nom complet, où seul l'utilisateur sait s'il + /// s'agit d'une même personne. + /// + public static IEndpointRouteBuilder MapAuteursEndpoints(this IEndpointRouteBuilder routes) + { + var groupe = routes.MapGroup("/api/auteurs").WithTags("Auteurs"); + + groupe.MapGet("/", async (IServiceAuteurs service, CancellationToken ct) => + Results.Ok(await service.ListerAsync(ct))) + .WithName("ListerAuteurs") + .WithSummary("Tous les auteurs du catalogue, avec leur nombre de livres.") + .Produces>(); + + groupe.MapGet("/{id:int}", async (int id, IServiceAuteurs service, CancellationToken ct) => + { + var auteur = await service.ObtenirAsync(id, ct); + return auteur is null ? Results.NotFound() : Results.Ok(auteur); + }) + .WithName("ObtenirAuteur") + .Produces() + .Produces(StatusCodes.Status404NotFound); + + groupe.MapGet("/rapprochements", async (IServiceAuteurs service, CancellationToken ct) => + Results.Ok(await service.SuggestionsAsync(ct))) + .WithName("ListerRapprochements") + .WithSummary("Rapprochements ambigus proposés, refus déjà écartés.") + .Produces>(); + + groupe.MapPost("/rapprochements/accepter", async ( + DecisionRapprochement decision, IServiceAuteurs service, CancellationToken ct) => + { + var fusionne = await service.FusionnerAsync(decision.SourceId, decision.CibleId, ct); + return fusionne ? Results.NoContent() : Results.NotFound(); + }) + .WithName("AccepterRapprochement") + .WithSummary("Fusionne les deux auteurs : « source » disparaît au profit de « cible ».") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound); + + groupe.MapPost("/rapprochements/refuser", async ( + DecisionRapprochement decision, IServiceAuteurs service, CancellationToken ct) => + { + var refuse = await service.RefuserAsync(decision.SourceId, decision.CibleId, ct); + return refuse ? Results.NoContent() : Results.NotFound(); + }) + .WithName("RefuserRapprochement") + .WithSummary("Mémorise que ces deux auteurs sont bien deux personnes : la suggestion ne reviendra plus.") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound); + + return routes; + } + /// Expose l'identité de la personne connectée, pour affichage côté client. public static IEndpointRouteBuilder MapIdentiteEndpoints(this IEndpointRouteBuilder routes) { diff --git a/MaBibli.Api/Program.cs b/MaBibli.Api/Program.cs index 4280c74..0d9a47e 100644 --- a/MaBibli.Api/Program.cs +++ b/MaBibli.Api/Program.cs @@ -12,6 +12,7 @@ builder.Services.AddOpenApi(); builder.Services.AddDbContext(options => options.UseSqlite(builder.Configuration.GetConnectionString("MaBibli"))); +builder.Services.AddScoped(); builder.Services.AddScoped(); // Identité : lue dans les en-têtes injectés par SSOwat, avec repli sur un utilisateur simulé @@ -45,7 +46,15 @@ var app = builder.Build(); // Une seule base SQLite, migrée au démarrage : pas d'étape manuelle à l'installation YunoHost. using (var portee = app.Services.CreateScope()) { - portee.ServiceProvider.GetRequiredService().Database.Migrate(); + var db = portee.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + + // Les colonnes normalisées (sans accents) ne peuvent pas être calculées en SQL : la + // migration ne les remplit qu'approximativement, ce passage les remet d'aplomb et réunit + // les auteurs que la normalisation révèle identiques. Idempotent, donc sans coût ensuite. + await new ServiceRenormalisation( + db, portee.ServiceProvider.GetRequiredService>()) + .ExecuterAsync(); } if (app.Environment.IsDevelopment()) @@ -60,6 +69,7 @@ app.UseStaticFiles(); app.MapIsbnEndpoints(); app.MapLivresEndpoints(); +app.MapAuteursEndpoints(); app.MapIdentiteEndpoints(); app.MapFallbackToFile("index.html"); diff --git a/MaBibli.Api/Services/Catalogue/ServiceAuteurs.cs b/MaBibli.Api/Services/Catalogue/ServiceAuteurs.cs new file mode 100644 index 0000000..e57776d --- /dev/null +++ b/MaBibli.Api/Services/Catalogue/ServiceAuteurs.cs @@ -0,0 +1,291 @@ +using MaBibli.Api.Data; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; +using MaBibli.Shared.Textes; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Api.Services.Catalogue; + +public interface IServiceAuteurs +{ + /// + /// Convertit des noms saisis en fiches auteur, en réutilisant l'existant quand c'est sûr. + /// Les nouvelles fiches sont ajoutées au contexte mais pas enregistrées. + /// + Task> ResoudreAsync(IEnumerable noms, CancellationToken ct = default); + + Task> ListerAsync(CancellationToken ct = default); + + Task ObtenirAsync(int id, CancellationToken ct = default); + + /// Rapprochements ambigus à soumettre à l'utilisateur, refus déjà écartés. + Task> SuggestionsAsync(CancellationToken ct = default); + + /// Fusionne source dans cible. source disparaît. + Task FusionnerAsync(int sourceId, int cibleId, CancellationToken ct = default); + + /// Mémorise que ces deux auteurs sont bien deux personnes différentes. + Task RefuserAsync(int sourceId, int cibleId, CancellationToken ct = default); + + /// Supprime les auteurs qui ne sont plus rattachés à aucun livre. + Task SupprimerOrphelinsAsync(CancellationToken ct = default); +} + +/// +/// Gestion de la table des auteurs : résolution des noms, regroupement et fusions. +/// +/// +/// Tout se joue sur une table volontairement petite. Une bibliothèque de foyer compte +/// quelques centaines d'auteurs : les comparaisons mot à mot (initiales, ordre inversé) sont +/// faites en mémoire, parce qu'elles ne s'expriment pas en SQL. Le premier essai — la clé de +/// regroupement — reste, lui, un simple index en base et couvre l'écrasante majorité des cas. +/// +public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs +{ + public async Task> ResoudreAsync( + IEnumerable noms, CancellationToken ct = default) + { + var resolus = new List(); + + foreach (var brut in noms) + { + var nom = brut?.Trim(); + if (string.IsNullOrEmpty(nom)) + { + continue; + } + + var cle = RapprochementAuteurs.Cle(nom); + if (cle.Length == 0) + { + continue; // Une saisie sans la moindre lettre ne désigne pas un auteur. + } + + // Déjà résolu dans ce même enregistrement : « Zola » saisi deux fois ne doit pas + // produire deux liens vers le même auteur (la clé primaire du lien s'y opposerait). + if (resolus.Any(a => a.CleRegroupement == cle)) + { + continue; + } + + var auteur = await TrouverAsync(nom, cle, ct); + + if (auteur is null) + { + auteur = new Auteur { Nom = nom }; + auteur.RecalculerFormes(); + db.Auteurs.Add(auteur); + } + + resolus.Add(auteur); + } + + return resolus; + } + + /// + /// Cherche l'auteur existant qui correspond sûrement à ce nom, ou null. + /// + private async Task TrouverAsync(string nom, string cle, CancellationToken ct) + { + // 1. Clé identique : même mots normalisés, ordre indifférent. Résolu par l'index unique. + // Le Local est consulté aussi, sinon deux auteurs identiques créés dans la même + // requête violeraient l'unicité au SaveChanges. + var parCle = db.Auteurs.Local.FirstOrDefault(a => a.CleRegroupement == cle) + ?? await db.Auteurs.FirstOrDefaultAsync(a => a.CleRegroupement == cle, ct); + + if (parCle is not null) + { + return parCle; + } + + // 2. Variantes que la clé ne capture pas : les initiales abrégées. Il faut aligner les + // mots un à un, ce qui n'a pas d'équivalent SQL — d'où le balayage en mémoire. + var candidats = await db.Auteurs.ToListAsync(ct); + candidats.AddRange(db.Auteurs.Local.Where(a => a.Id == 0)); + + var surs = candidats + .Where(a => RapprochementAuteurs.SontLeMemeAuteur(nom, a.Nom)) + .OrderBy(a => a.Id) + .ToList(); + + // Plusieurs correspondances sûres signifient que la table contient déjà des doublons + // (ils se seraient rapprochés entre eux). On prend la plus ancienne plutôt que d'en + // créer une troisième : l'utilisateur les réunira depuis l'écran des auteurs. + return surs.FirstOrDefault(); + } + + public async Task> ListerAsync(CancellationToken ct = default) => + await db.Auteurs + .AsNoTracking() + .OrderBy(a => a.NomNormalise) + .Select(a => new AuteurDto + { + Id = a.Id, + Nom = a.Nom, + NombreDeLivres = a.Livres.Count, + }) + .ToListAsync(ct); + + public async Task ObtenirAsync(int id, CancellationToken ct = default) => + await db.Auteurs + .AsNoTracking() + .Where(a => a.Id == id) + .Select(a => new AuteurDto + { + Id = a.Id, + Nom = a.Nom, + NombreDeLivres = a.Livres.Count, + }) + .FirstOrDefaultAsync(ct); + + public async Task> SuggestionsAsync( + CancellationToken ct = default) + { + var auteurs = await db.Auteurs + .AsNoTracking() + .Select(a => new { a.Id, a.Nom, Livres = a.Livres.Count }) + .OrderBy(a => a.Id) + .ToListAsync(ct); + + var refuses = await db.RapprochementsRefuses + .AsNoTracking() + .Select(r => new { r.AuteurAId, r.AuteurBId }) + .ToListAsync(ct); + + var ecartes = refuses.Select(r => (r.AuteurAId, r.AuteurBId)).ToHashSet(); + + var suggestions = new List(); + + // Comparaison de toutes les paires : quadratique, mais sur quelques centaines d'auteurs + // c'est instantané, et le résultat n'est calculé qu'à l'ouverture de l'écran des auteurs. + for (var i = 0; i < auteurs.Count; i++) + { + for (var j = i + 1; j < auteurs.Count; j++) + { + var a = auteurs[i]; + var b = auteurs[j]; + + if (RapprochementAuteurs.Comparer(a.Nom, b.Nom) != NiveauRapprochement.Suggere) + { + continue; + } + + if (ecartes.Contains(Ordonner(a.Id, b.Id))) + { + continue; // L'utilisateur a déjà dit non : ne pas le lui redemander. + } + + // La fusion fait disparaître la source : c'est le nom le moins complet qui + // s'efface, pour ne pas perdre « Peter F. Hamilton » au profit de « Hamilton ». + var (source, cible) = + NormalisationTexte.Mots(a.Nom).Length <= NormalisationTexte.Mots(b.Nom).Length + ? (a, b) + : (b, a); + + suggestions.Add(new SuggestionRapprochement + { + Source = new AuteurDto { Id = source.Id, Nom = source.Nom, NombreDeLivres = source.Livres }, + Cible = new AuteurDto { Id = cible.Id, Nom = cible.Nom, NombreDeLivres = cible.Livres }, + }); + } + } + + return suggestions; + } + + public async Task FusionnerAsync(int sourceId, int cibleId, CancellationToken ct = default) + { + if (sourceId == cibleId) + { + return false; + } + + var source = await db.Auteurs.Include(a => a.Livres).FirstOrDefaultAsync(a => a.Id == sourceId, ct); + var cible = await db.Auteurs.Include(a => a.Livres).FirstOrDefaultAsync(a => a.Id == cibleId, ct); + + if (source is null || cible is null) + { + return false; + } + + var dejaLies = cible.Livres.Select(l => l.LivreId).ToHashSet(); + + foreach (var lien in source.Livres.ToList()) + { + // Un livre co-signé par les deux fiches ne doit pas donner deux liens identiques : + // la clé primaire du lien est (LivreId, AuteurId). + if (!dejaLies.Add(lien.LivreId)) + { + db.LivreAuteurs.Remove(lien); + continue; + } + + db.LivreAuteurs.Remove(lien); + db.LivreAuteurs.Add(new LivreAuteur + { + LivreId = lien.LivreId, + AuteurId = cible.Id, + Position = lien.Position, + }); + } + + // Les refus qui portaient sur la fiche absorbée n'ont plus d'objet. + var refusObsoletes = await db.RapprochementsRefuses + .Where(r => r.AuteurAId == sourceId || r.AuteurBId == sourceId) + .ToListAsync(ct); + db.RapprochementsRefuses.RemoveRange(refusObsoletes); + + db.Auteurs.Remove(source); + await db.SaveChangesAsync(ct); + return true; + } + + public async Task RefuserAsync(int sourceId, int cibleId, CancellationToken ct = default) + { + if (sourceId == cibleId) + { + return false; + } + + var (a, b) = Ordonner(sourceId, cibleId); + + var existants = await db.Auteurs.CountAsync(x => x.Id == a || x.Id == b, ct); + if (existants != 2) + { + return false; + } + + var deja = await db.RapprochementsRefuses.AnyAsync(r => r.AuteurAId == a && r.AuteurBId == b, ct); + if (!deja) + { + db.RapprochementsRefuses.Add(new RapprochementRefuse { AuteurAId = a, AuteurBId = b }); + await db.SaveChangesAsync(ct); + } + + return true; + } + + public async Task SupprimerOrphelinsAsync(CancellationToken ct = default) + { + // Après une édition ou une suppression de livre, une fiche auteur peut n'avoir plus aucun + // livre. La garder polluerait l'écran des auteurs et les suggestions de rapprochement. + var orphelins = await db.Auteurs.Where(a => a.Livres.Count == 0).ToListAsync(ct); + if (orphelins.Count == 0) + { + return; + } + + var ids = orphelins.Select(a => a.Id).ToList(); + var refus = await db.RapprochementsRefuses + .Where(r => ids.Contains(r.AuteurAId) || ids.Contains(r.AuteurBId)) + .ToListAsync(ct); + + db.RapprochementsRefuses.RemoveRange(refus); + db.Auteurs.RemoveRange(orphelins); + await db.SaveChangesAsync(ct); + } + + /// Couple rangé, pour qu'un refus vaille dans les deux sens. + private static (int, int) Ordonner(int x, int y) => x < y ? (x, y) : (y, x); +} diff --git a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs index 23ab8dc..80cb40b 100644 --- a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs +++ b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs @@ -22,15 +22,19 @@ public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur) public interface IServiceCatalogue { - Task> ListerAsync(CritereLivres criteres, CancellationToken ct = default); + Task> ListerAsync( + CritereLivres criteres, string? utilisateur, CancellationToken ct = default); - Task ObtenirAsync(int id, CancellationToken ct = default); + Task ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default); - Task CreerAsync(EnregistrementLivre saisie, string? ajoutePar, CancellationToken ct = default); + Task CreerAsync( + EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default); - Task ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default); + Task ModifierAsync( + int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default); - Task ChangerStatutAsync(int id, Statut statut, CancellationToken ct = default); + Task ChangerStatutAsync( + int id, Statut? statut, string? utilisateur, CancellationToken ct = default); Task SupprimerAsync(int id, CancellationToken ct = default); } @@ -39,27 +43,48 @@ public interface IServiceCatalogue /// CRUD du catalogue de livres — physiques et numériques confondus, distingués par /// . Il n'y a qu'un seul catalogue. /// -public sealed class ServiceCatalogue(MaBibliDbContext db) : IServiceCatalogue +/// +/// Le paramètre utilisateur ne restreint jamais la liste des livres. Il sert +/// uniquement à savoir de quel statut de lecture on parle : la bibliothèque reste commune, seule +/// la lecture est personnelle. +/// +public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteurs) : IServiceCatalogue { public async Task> ListerAsync( - CritereLivres criteres, CancellationToken ct = default) + CritereLivres criteres, string? utilisateur, CancellationToken ct = default) { // AsNoTracking : lecture pure. Et surtout : AUCUN filtre sur AjoutePar, // la bibliothèque est commune à tout le foyer (CLAUDE.md). - var requete = FiltreLivres.Appliquer(db.Livres.AsNoTracking(), criteres); + var requete = FiltreLivres.Appliquer( + db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur), + criteres, + utilisateur); var livres = await requete.ToListAsync(ct); - return livres.Select(Projeter).ToList(); + + var statuts = await StatutsAsync(livres.Select(l => l.Id).ToList(), utilisateur, ct); + + return livres.Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id))).ToList(); } - public async Task ObtenirAsync(int id, CancellationToken ct = default) + public async Task ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default) { - var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id, ct); - return livre is null ? null : Projeter(livre); + var livre = await db.Livres + .AsNoTracking() + .Include(l => l.Auteurs).ThenInclude(la => la.Auteur) + .FirstOrDefaultAsync(l => l.Id == id, ct); + + if (livre is null) + { + return null; + } + + var statuts = await StatutsAsync([id], utilisateur, ct); + return Projeter(livre, statuts.GetValueOrDefault(id)); } public async Task CreerAsync( - EnregistrementLivre saisie, string? ajoutePar, CancellationToken ct = default) + EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default) { if (Valider(saisie, out var isbn, out var erreur) is false) { @@ -69,28 +94,33 @@ public sealed class ServiceCatalogue(MaBibliDbContext db) : IServiceCatalogue var livre = new Livre { Isbn = isbn, - Titre = saisie.Titre.Trim(), - Auteur = Vide(saisie.Auteur), + Titre = saisie.Titre, Editeur = Vide(saisie.Editeur), Format = saisie.Format, - Statut = saisie.Statut, CoverUrl = Vide(saisie.CoverUrl), DateAjout = DateTime.UtcNow, // Renseigné par le serveur à partir de l'utilisateur authentifié, jamais par le client. - AjoutePar = ajoutePar, + AjoutePar = utilisateur, }; + livre.RecalculerFormes(); db.Livres.Add(livre); + + await RattacherAuteursAsync(livre, saisie.Auteurs, ct); await db.SaveChangesAsync(ct); - return ResultatEcriture.Ok(Projeter(livre)); + // Le statut n'est écrit que si l'utilisateur en a explicitement posé un : un livre sans + // ligne est « non commencé », et c'est le cas de départ le plus fréquent. + await AppliquerStatutAsync(livre.Id, saisie.Statut, utilisateur, ct); + + return ResultatEcriture.Ok((await ObtenirAsync(livre.Id, utilisateur, ct))!); } public async Task ModifierAsync( - int id, EnregistrementLivre saisie, CancellationToken ct = default) + int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default) { - var livre = await db.Livres.FirstOrDefaultAsync(l => l.Id == id, ct); + var livre = await db.Livres.Include(l => l.Auteurs).FirstOrDefaultAsync(l => l.Id == id, ct); if (livre is null) { return ResultatEcriture.Introuvable; @@ -102,46 +132,140 @@ public sealed class ServiceCatalogue(MaBibliDbContext db) : IServiceCatalogue } livre.Isbn = isbn; - livre.Titre = saisie.Titre.Trim(); - livre.Auteur = Vide(saisie.Auteur); + livre.Titre = saisie.Titre; livre.Editeur = Vide(saisie.Editeur); livre.Format = saisie.Format; - livre.Statut = saisie.Statut; livre.CoverUrl = Vide(saisie.CoverUrl); + livre.RecalculerFormes(); // DateAjout et AjoutePar ne sont jamais réécrits : ce sont des traces de la saisie // d'origine, pas des champs éditables. + await RattacherAuteursAsync(livre, saisie.Auteurs, ct); await db.SaveChangesAsync(ct); - return ResultatEcriture.Ok(Projeter(livre)); + + // Le statut modifié est celui de la personne qui édite, pas de celle qui a saisi le livre. + await AppliquerStatutAsync(livre.Id, saisie.Statut, utilisateur, ct); + await auteurs.SupprimerOrphelinsAsync(ct); + + return ResultatEcriture.Ok((await ObtenirAsync(livre.Id, utilisateur, ct))!); } - public async Task ChangerStatutAsync(int id, Statut statut, CancellationToken ct = default) + public async Task ChangerStatutAsync( + int id, Statut? statut, string? utilisateur, CancellationToken ct = default) { - var livre = await db.Livres.FirstOrDefaultAsync(l => l.Id == id, ct); - if (livre is null) + if (!await db.Livres.AnyAsync(l => l.Id == id, ct)) { return null; } - livre.Statut = statut; - await db.SaveChangesAsync(ct); - return Projeter(livre); + await AppliquerStatutAsync(id, statut, utilisateur, ct); + return await ObtenirAsync(id, utilisateur, ct); } public async Task SupprimerAsync(int id, CancellationToken ct = default) { - var livre = await db.Livres.FirstOrDefaultAsync(l => l.Id == id, ct); + var livre = await db.Livres.Include(l => l.Auteurs).FirstOrDefaultAsync(l => l.Id == id, ct); if (livre is null) { return false; } + // Les liens vers les auteurs partent en cascade, mais les fiches auteur restent : elles + // sont peut-être partagées avec d'autres livres. Le nettoyage d'après tranche. db.Livres.Remove(livre); await db.SaveChangesAsync(ct); + await auteurs.SupprimerOrphelinsAsync(ct); return true; } + /// Statut de pour chacun des livres demandés. + private async Task> StatutsAsync( + IReadOnlyList livreIds, string? utilisateur, CancellationToken ct) + { + // Sans identité, personne n'a de statut : on ne montre surtout pas celui d'un autre. + if (utilisateur is null || livreIds.Count == 0) + { + return []; + } + + // Le dictionnaire est volontairement typé « Statut? » : sans ça, l'absence de ligne + // ressortirait comme la valeur 0 de l'énumération — c'est-à-dire « À lire » — et + // « non commencé » deviendrait indiscernable d'un choix explicite. + return await db.StatutsLecture + .AsNoTracking() + .Where(s => s.Utilisateur == utilisateur && livreIds.Contains(s.LivreId)) + .ToDictionaryAsync(s => s.LivreId, s => (Statut?)s.Statut, ct); + } + + /// + /// Pose, met à jour ou retire le statut de lecture d'une personne pour un livre. + /// + private async Task AppliquerStatutAsync( + int livreId, Statut? statut, string? utilisateur, CancellationToken ct) + { + if (utilisateur is null) + { + // Aucune identité : on n'invente pas de propriétaire pour ce statut. Le livre reste + // enregistré, simplement sans lecture rattachée à personne. + return; + } + + var ligne = await db.StatutsLecture + .FirstOrDefaultAsync(s => s.LivreId == livreId && s.Utilisateur == utilisateur, ct); + + if (statut is null) + { + if (ligne is not null) + { + // Retour à « non commencé » : on supprime la ligne au lieu d'inventer une + // quatrième valeur d'énumération qui aurait fallu migrer. + db.StatutsLecture.Remove(ligne); + await db.SaveChangesAsync(ct); + } + + return; + } + + if (ligne is null) + { + db.StatutsLecture.Add(new StatutLecture + { + LivreId = livreId, + Utilisateur = utilisateur, + Statut = statut.Value, + DateMaj = DateTime.UtcNow, + }); + } + else + { + ligne.Statut = statut.Value; + ligne.DateMaj = DateTime.UtcNow; + } + + await db.SaveChangesAsync(ct); + } + + /// Remplace les auteurs du livre par ceux de la saisie, en conservant leur ordre. + private async Task RattacherAuteursAsync( + Livre livre, IEnumerable noms, CancellationToken ct) + { + var resolus = await auteurs.ResoudreAsync(noms, ct); + + // Les liens existants sont retirés puis reconstruits : c'est la façon la plus simple de + // gérer à la fois l'ajout, le retrait et le réordonnancement des auteurs. + if (livre.Auteurs.Count > 0) + { + db.LivreAuteurs.RemoveRange(livre.Auteurs); + livre.Auteurs.Clear(); + } + + for (var i = 0; i < resolus.Count; i++) + { + livre.Auteurs.Add(new LivreAuteur { Auteur = resolus[i], Position = i }); + } + } + /// /// Contrôles minimaux : un titre, et un ISBN qui tienne debout s'il est fourni. /// @@ -179,15 +303,19 @@ public sealed class ServiceCatalogue(MaBibliDbContext db) : IServiceCatalogue private static string? Vide(string? valeur) => string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim(); - private static LivreDto Projeter(Livre livre) => new() + private static LivreDto Projeter(Livre livre, Statut? statut) => new() { Id = livre.Id, Isbn = livre.Isbn, Titre = livre.Titre, - Auteur = livre.Auteur, + Auteurs = livre.Auteurs + .OrderBy(la => la.Position) + .Where(la => la.Auteur is not null) + .Select(la => new AuteurDto { Id = la.Auteur!.Id, Nom = la.Auteur.Nom }) + .ToList(), Editeur = livre.Editeur, Format = livre.Format, - Statut = livre.Statut, + Statut = statut, CoverUrl = livre.CoverUrl, DateAjout = livre.DateAjout, AjoutePar = livre.AjoutePar, diff --git a/MaBibli.Api/Services/Isbn/BnfSruParser.cs b/MaBibli.Api/Services/Isbn/BnfSruParser.cs index 5dc22d4..166aa1d 100644 --- a/MaBibli.Api/Services/Isbn/BnfSruParser.cs +++ b/MaBibli.Api/Services/Isbn/BnfSruParser.cs @@ -42,7 +42,20 @@ public static class BnfSruParser continue; } + // Plusieurs dc:creator sont possibles : un livre peut avoir plusieurs auteurs. + var auteurs = Tous(data, "creator") + .Select(NettoyageIsbd.Auteur) + .Where(a => a is not null) + .Select(a => a!) + .Distinct() + .ToList(); + var titre = NettoyageIsbd.Titre(Premier(data, "title")); + + // Certaines notices recollent le nom de l'auteur en fin de titre sans le séparateur + // ISBD — il faut donc les auteurs nettoyés avant de pouvoir finir le titre. + titre = NettoyageIsbd.RetirerAuteurEnFin(titre, auteurs); + if (titre is null) { // Une notice sans titre exploitable n'a aucun intérêt pour un pré-remplissage. @@ -52,7 +65,7 @@ public static class BnfSruParser candidats.Add(new CandidatLivre { Titre = titre, - Auteur = NettoyageIsbd.Auteur(Premier(data, "creator")), + Auteurs = auteurs, Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")), Annee = Premier(data, "date"), Langue = Premier(data, "language"), @@ -66,9 +79,12 @@ public static class BnfSruParser return candidats; } - private static string? Premier(XElement recordData, string nomLocal) - { - var valeur = recordData.Descendants(Dc + nomLocal).FirstOrDefault()?.Value?.Trim(); - return string.IsNullOrEmpty(valeur) ? null : valeur; - } + private static string? Premier(XElement recordData, string nomLocal) => + Tous(recordData, nomLocal).FirstOrDefault(); + + private static IEnumerable Tous(XElement recordData, string nomLocal) => + recordData.Descendants(Dc + nomLocal) + .Select(e => e.Value?.Trim()) + .Where(v => !string.IsNullOrEmpty(v)) + .Select(v => v!); } diff --git a/MaBibli.Api/Services/Isbn/OpenLibraryMapper.cs b/MaBibli.Api/Services/Isbn/OpenLibraryMapper.cs index 54bcd75..cb32471 100644 --- a/MaBibli.Api/Services/Isbn/OpenLibraryMapper.cs +++ b/MaBibli.Api/Services/Isbn/OpenLibraryMapper.cs @@ -62,14 +62,17 @@ public static class OpenLibraryMapper titre = $"{titre} : {edition.Subtitle.Trim()}"; } - var auteur = nomsAuteurs.Count > 0 - ? string.Join(", ", nomsAuteurs) - : NettoyerByStatement(edition.ByStatement); + // Les noms résolus par /authors/{id}.json arrivent déjà séparés. En dernier repli la + // mention de responsabilité n'est qu'une phrase : on la garde entière plutôt que de + // parier sur ses virgules, qui séparent aussi bien deux auteurs qu'un « Zola, Émile ». + var auteurs = nomsAuteurs.Count > 0 + ? nomsAuteurs + : Liste(NettoyerByStatement(edition.ByStatement)); return new CandidatLivre { Titre = titre, - Auteur = auteur, + Auteurs = auteurs, Editeur = edition.Publishers?.FirstOrDefault()?.Trim().Trim('[', ']'), Annee = string.IsNullOrWhiteSpace(edition.PublishDate) ? null : edition.PublishDate.Trim(), Langue = edition.Languages? @@ -83,6 +86,9 @@ public static class OpenLibraryMapper }; } + private static IReadOnlyList Liste(string? valeur) => + string.IsNullOrWhiteSpace(valeur) ? [] : [valeur]; + /// /// Dernier repli quand aucun auteur n'est résolvable : la mention de responsabilité brute, /// débarrassée de sa ponctuation finale (« Thomas H. Cormen ... [et al.]. »). diff --git a/MaBibli.Client/Composants/ChoixStatut.razor b/MaBibli.Client/Composants/ChoixStatut.razor new file mode 100644 index 0000000..b5a40c0 --- /dev/null +++ b/MaBibli.Client/Composants/ChoixStatut.razor @@ -0,0 +1,40 @@ +@* + Sélecteur du statut de lecture. + + Le statut est PERSONNEL (CLAUDE.md) : ce que montrent ces boutons est la lecture de la + personne connectée, pas celle du foyer. « Non commencé » n'est pas une valeur de plus dans + l'énumération, c'est l'absence de ligne en base — d'où le bouton qui remet la valeur à null. +*@ +
+ + + @foreach (var statut in Statuts) + { + + } +
+ +@code { + private static readonly Statut[] Statuts = Enum.GetValues(); + + [Parameter] public Statut? Valeur { get; set; } + + [Parameter] public EventCallback ValeurChanged { get; set; } + + [Parameter] public bool Desactive { get; set; } + + [Parameter] public bool Compact { get; set; } + + private Task Choisir(Statut? statut) => + statut == Valeur ? Task.CompletedTask : ValeurChanged.InvokeAsync(statut); +} diff --git a/MaBibli.Client/Composants/FormulaireLivre.razor b/MaBibli.Client/Composants/FormulaireLivre.razor index ff71d64..624ce79 100644 --- a/MaBibli.Client/Composants/FormulaireLivre.razor +++ b/MaBibli.Client/Composants/FormulaireLivre.razor @@ -18,10 +18,17 @@ placeholder="Titre du livre" autocomplete="off" /> + @* + Un seul champ, plusieurs auteurs : le point-virgule les sépare. La virgule est déjà + prise par la forme bibliographique « Zola, Émile », qu'on doit pouvoir taper telle quelle. + Le serveur rapproche ensuite les variantes sûres d'un auteur déjà connu. + *@
public class FiltreLivresTests { + private static Auteur Auteur(int id, string nom) + { + var auteur = new Auteur { Id = id, Nom = nom }; + auteur.RecalculerFormes(); + return auteur; + } + + private static readonly Auteur Zola = Auteur(1, "Émile Zola"); + private static readonly Auteur Maupassant = Auteur(2, "Guy de Maupassant"); + + private static Livre Livre( + int id, string titre, Auteur? auteur, Format format, Statut? statut, string? ajoutePar, + string? lecteur = "mathieu") + { + var livre = new Livre + { + Id = id, + Titre = titre, + Format = format, + AjoutePar = ajoutePar, + }; + + livre.RecalculerFormes(); + + if (auteur is not null) + { + livre.Auteurs.Add(new LivreAuteur { LivreId = id, AuteurId = auteur.Id, Auteur = auteur }); + } + + if (statut is { } valeur && lecteur is not null) + { + livre.Statuts.Add(new StatutLecture { LivreId = id, Utilisateur = lecteur, Statut = valeur }); + } + + return livre; + } + + // « mathieu » a un statut sur les quatre livres ; « camille » n'en a aucun. private static readonly Livre[] Catalogue = [ - new() - { - Id = 1, Titre = "Germinal", Auteur = "Émile Zola", - Format = Format.Physique, Statut = Statut.Lu, AjoutePar = "mathieu", - }, - new() - { - Id = 2, Titre = "La Bête humaine", Auteur = "Émile Zola", - Format = Format.Numerique, Statut = Statut.ALire, AjoutePar = "camille", - }, - new() - { - Id = 3, Titre = "Le Horla", Auteur = "Guy de Maupassant", - Format = Format.Physique, Statut = Statut.EnCours, AjoutePar = "camille", - }, - new() - { - Id = 4, Titre = "Bel-Ami", Auteur = null, - Format = Format.Numerique, Statut = Statut.Lu, AjoutePar = null, - }, + Livre(1, "Germinal", Zola, Format.Physique, Statut.Lu, "mathieu"), + Livre(2, "La Bête humaine", Zola, Format.Numerique, Statut.ALire, "camille"), + Livre(3, "Le Horla", Maupassant, Format.Physique, Statut.EnCours, "camille"), + Livre(4, "Bel-Ami", null, Format.Numerique, Statut.Lu, null), ]; - private static List Filtrer(CritereLivres criteres) => - FiltreLivres.Appliquer(Catalogue.AsQueryable(), criteres).ToList(); + private static List Filtrer(CritereLivres criteres, string? utilisateur = "mathieu") => + FiltreLivres.Appliquer(Catalogue.AsQueryable(), criteres, utilisateur).ToList(); [Fact] public void Sans_critere_tout_le_catalogue_remonte() @@ -61,13 +83,26 @@ public class FiltreLivresTests [InlineData(Statut.EnCours, 1)] [InlineData(Statut.Lu, 2)] public void Le_filtre_statut_ne_garde_que_ce_statut(Statut statut, int attendu) - { - var resultat = Filtrer(new CritereLivres { Statut = statut }); + => Assert.Equal(attendu, Filtrer(new CritereLivres { Statut = statut }).Count); - Assert.Equal(attendu, resultat.Count); - Assert.All(resultat, l => Assert.Equal(statut, l.Statut)); + /// + /// Le statut est personnel. Le même filtre, la même bibliothèque, une autre personne : + /// rien ne remonte, parce que « camille » n'a posé aucun statut. + /// + [Fact] + public void Le_filtre_statut_ne_voit_que_le_statut_de_lappelant() + { + Assert.Equal(2, Filtrer(new CritereLivres { Statut = Statut.Lu }, "mathieu").Count); + Assert.Empty(Filtrer(new CritereLivres { Statut = Statut.Lu }, "camille")); + + // Sans filtre, en revanche, camille voit toute la bibliothèque : elle est commune. + Assert.Equal(4, Filtrer(new CritereLivres(), "camille").Count); } + [Fact] + public void Sans_identite_aucun_statut_ne_correspond() + => Assert.Empty(Filtrer(new CritereLivres { Statut = Statut.Lu }, null)); + [Fact] public void La_recherche_porte_sur_le_titre() => Assert.Equal(["Germinal"], Filtrer(new CritereLivres { Recherche = "germ" }).Select(l => l.Titre)); @@ -78,6 +113,26 @@ public class FiltreLivresTests ["Germinal", "La Bête humaine"], Filtrer(new CritereLivres { Recherche = "zola" }).Select(l => l.Titre)); + /// + /// Le besoin exprimé dans IDEES.md : « Emile » doit trouver « Émile ». Il n'y a pas de + /// collation SQLite pour ça, seule la colonne normalisée le permet. + /// + [Theory] + [InlineData("emile")] + [InlineData("Émile")] + [InlineData("EMILE ZOLA")] + [InlineData("zola, émile")] + public void La_recherche_ignore_les_accents_sur_lauteur(string terme) + => Assert.Equal( + ["Germinal", "La Bête humaine"], + Filtrer(new CritereLivres { Recherche = terme }).Select(l => l.Titre)); + + [Theory] + [InlineData("bete")] + [InlineData("BÊTE")] + public void La_recherche_ignore_les_accents_sur_le_titre(string terme) + => Assert.Equal(["La Bête humaine"], Filtrer(new CritereLivres { Recherche = terme }).Select(l => l.Titre)); + [Theory] [InlineData("HORLA")] [InlineData("horla")] @@ -88,12 +143,18 @@ public class FiltreLivresTests [Fact] public void Un_livre_sans_auteur_ne_fait_pas_echouer_la_recherche() { - // Le livre 4 a Auteur = null : la clause sur l'auteur doit le traverser sans exploser. + // Le livre 4 n'a aucun auteur : la clause sur les auteurs doit le traverser sans exploser. var resultat = Filtrer(new CritereLivres { Recherche = "bel" }); Assert.Equal(["Bel-Ami"], resultat.Select(l => l.Titre)); } + [Fact] + public void Le_filtre_par_auteur_regroupe_ses_livres() + => Assert.Equal( + ["Germinal", "La Bête humaine"], + Filtrer(new CritereLivres { AuteurId = Zola.Id }).Select(l => l.Titre)); + [Fact] public void Les_criteres_se_combinent() { @@ -147,4 +208,16 @@ public class FiltreLivresTests Assert.DoesNotContain(proprietes, nom => nom.Contains("Ajoute", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(proprietes, nom => nom.Contains("Utilisateur", StringComparison.OrdinalIgnoreCase)); } + + /// + /// Troisième verrou, sur la décision inverse : ne doit plus jamais porter + /// de colonne Statut, qui redeviendrait commune à tout le foyer. + /// + [Fact] + public void Livre_ne_porte_aucun_statut_commun() + { + var statut = typeof(Livre).GetProperty("Statut"); + + Assert.Null(statut); + } } diff --git a/MaBibli.Tests/OpenLibraryMapperTests.cs b/MaBibli.Tests/OpenLibraryMapperTests.cs index bd12b08..28b871d 100644 --- a/MaBibli.Tests/OpenLibraryMapperTests.cs +++ b/MaBibli.Tests/OpenLibraryMapperTests.cs @@ -82,9 +82,11 @@ public class OpenLibraryMapperTests var candidat = OpenLibraryMapper.VersCandidat(edition, noms, "9780262033848", null)!; Assert.Equal("Introduction to Algorithms", candidat.Titre); + // Les quatre auteurs restent quatre entrées distinctes : les recoller en une chaîne + // rendrait impossible de les distinguer d'un « Zola, Émile » à la virgule près. Assert.Equal( - "Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein", - candidat.Auteur); + ["Thomas H. Cormen", "Charles E. Leiserson", "Ronald L. Rivest", "Clifford Stein"], + candidat.Auteurs); Assert.Equal("The MIT Press", candidat.Editeur); Assert.Equal("2009", candidat.Annee); } diff --git a/MaBibli.Tests/ServiceAuteursTests.cs b/MaBibli.Tests/ServiceAuteursTests.cs new file mode 100644 index 0000000..a274870 --- /dev/null +++ b/MaBibli.Tests/ServiceAuteursTests.cs @@ -0,0 +1,198 @@ +using MaBibli.Api.Data; +using MaBibli.Api.Services.Catalogue; +using MaBibli.Shared.Catalogue; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Tests; + +/// +/// Table des auteurs : regroupement automatique, suggestions et fusions. +/// +public class ServiceAuteursTests : IDisposable +{ + private readonly SqliteConnection _connexion; + private readonly MaBibliDbContext _db; + private readonly ServiceAuteurs _auteurs; + private readonly ServiceCatalogue _catalogue; + + public ServiceAuteursTests() + { + _connexion = new SqliteConnection("Data Source=:memory:"); + _connexion.Open(); + + _db = new MaBibliDbContext( + new DbContextOptionsBuilder().UseSqlite(_connexion).Options); + _db.Database.EnsureCreated(); + + _auteurs = new ServiceAuteurs(_db); + _catalogue = new ServiceCatalogue(_db, _auteurs); + } + + private async Task AjouterAsync(string titre, string auteurs) => + (await _catalogue.CreerAsync( + new EnregistrementLivre { Titre = titre, Auteur = auteurs }, "mathieu")).Livre!; + + [Fact] + public async Task Un_auteur_nest_cree_quune_fois() + { + await AjouterAsync("Germinal", "Émile Zola"); + await AjouterAsync("Nana", "Émile Zola"); + + var liste = await _auteurs.ListerAsync(); + var zola = Assert.Single(liste); + Assert.Equal("Émile Zola", zola.Nom); + Assert.Equal(2, zola.NombreDeLivres); + } + + [Fact] + public async Task Le_nom_daffichage_reste_celui_de_la_premiere_saisie() + { + await AjouterAsync("Germinal", "Émile Zola"); + await AjouterAsync("Nana", "ZOLA, emile"); + + // Les deux livres pointent la même fiche, sous le nom d'origine : l'accentuation correcte + // ne doit pas être écrasée par une saisie plus négligée. + Assert.Equal("Émile Zola", Assert.Single(await _auteurs.ListerAsync()).Nom); + } + + [Fact] + public async Task Aucune_suggestion_sur_des_auteurs_sans_rapport() + { + await AjouterAsync("Germinal", "Émile Zola"); + await AjouterAsync("Le Horla", "Guy de Maupassant"); + + Assert.Empty(await _auteurs.SuggestionsAsync()); + } + + [Fact] + public async Task Un_patronyme_seul_est_propose_vers_le_nom_complet() + { + await AjouterAsync("Pandora's Star", "Peter F. Hamilton"); + await AjouterAsync("Un autre", "Hamilton"); + + var suggestion = Assert.Single(await _auteurs.SuggestionsAsync()); + + // La fusion efface la source : c'est le nom le moins complet qui doit disparaître. + Assert.Equal("Hamilton", suggestion.Source.Nom); + Assert.Equal("Peter F. Hamilton", suggestion.Cible.Nom); + } + + [Fact] + public async Task Accepter_un_rapprochement_reunit_les_livres() + { + var complet = await AjouterAsync("Pandora's Star", "Peter F. Hamilton"); + var court = await AjouterAsync("Judas Unchained", "Hamilton"); + + var suggestion = Assert.Single(await _auteurs.SuggestionsAsync()); + Assert.True(await _auteurs.FusionnerAsync(suggestion.Source.Id, suggestion.Cible.Id)); + + var restant = Assert.Single(await _auteurs.ListerAsync()); + Assert.Equal("Peter F. Hamilton", restant.Nom); + Assert.Equal(2, restant.NombreDeLivres); + + // Les deux livres remontent maintenant sous le même auteur. + var groupe = await _catalogue.ListerAsync(new CritereLivres { AuteurId = restant.Id }, "mathieu"); + Assert.Equal([complet.Id, court.Id], groupe.Select(l => l.Id).Order()); + + // Et la suggestion ne peut plus revenir : l'une des deux fiches n'existe plus. + Assert.Empty(await _auteurs.SuggestionsAsync()); + } + + [Fact] + public async Task Refuser_un_rapprochement_le_fait_taire_definitivement() + { + await AjouterAsync("Pandora's Star", "Peter F. Hamilton"); + await AjouterAsync("Un autre", "Hamilton"); + + var suggestion = Assert.Single(await _auteurs.SuggestionsAsync()); + Assert.True(await _auteurs.RefuserAsync(suggestion.Source.Id, suggestion.Cible.Id)); + + Assert.Empty(await _auteurs.SuggestionsAsync()); + + // Les deux auteurs restent bien distincts : refuser n'est pas fusionner. + Assert.Equal(2, (await _auteurs.ListerAsync()).Count); + } + + [Fact] + public async Task Un_refus_vaut_dans_les_deux_sens() + { + await AjouterAsync("Pandora's Star", "Peter F. Hamilton"); + await AjouterAsync("Un autre", "Hamilton"); + + var suggestion = Assert.Single(await _auteurs.SuggestionsAsync()); + + // Refus enregistré dans l'ordre inverse de celui proposé : il doit compter quand même. + Assert.True(await _auteurs.RefuserAsync(suggestion.Cible.Id, suggestion.Source.Id)); + Assert.Empty(await _auteurs.SuggestionsAsync()); + + // Idempotent : redire non ne doit pas empiler les lignes. + Assert.True(await _auteurs.RefuserAsync(suggestion.Source.Id, suggestion.Cible.Id)); + Assert.Equal(1, await _db.RapprochementsRefuses.CountAsync()); + } + + [Fact] + public async Task Fusionner_un_livre_cosigne_ne_cree_pas_de_lien_en_double() + { + // Le même livre signé par les deux variantes : après fusion il ne doit rester qu'un lien. + var livre = await AjouterAsync("Œuvre commune", "Peter F. Hamilton; Hamilton"); + Assert.Equal(2, livre.Auteurs.Count); + + var suggestion = Assert.Single(await _auteurs.SuggestionsAsync()); + Assert.True(await _auteurs.FusionnerAsync(suggestion.Source.Id, suggestion.Cible.Id)); + + var relu = await _catalogue.ObtenirAsync(livre.Id, "mathieu"); + Assert.Single(relu!.Auteurs); + Assert.Equal(1, await _db.LivreAuteurs.CountAsync()); + } + + [Fact] + public async Task Fusionner_avec_un_auteur_inexistant_echoue_sans_rien_casser() + { + var livre = await AjouterAsync("Germinal", "Émile Zola"); + var zola = livre.Auteurs.Single().Id; + + Assert.False(await _auteurs.FusionnerAsync(zola, 4242)); + Assert.False(await _auteurs.FusionnerAsync(zola, zola)); + Assert.Equal(1, await _db.Auteurs.CountAsync()); + } + + [Fact] + public async Task Retirer_un_auteur_dun_livre_supprime_sa_fiche_devenue_vide() + { + var livre = await AjouterAsync("Germinal", "Émile Zola"); + + await _catalogue.ModifierAsync( + livre.Id, + new EnregistrementLivre { Titre = "Germinal", Auteur = "Guy de Maupassant" }, + "mathieu"); + + var restant = Assert.Single(await _auteurs.ListerAsync()); + Assert.Equal("Guy de Maupassant", restant.Nom); + } + + [Fact] + public async Task Une_saisie_sans_lettre_ne_cree_pas_dauteur() + { + await AjouterAsync("Sans auteur", " ; ; .,; "); + + Assert.Empty(await _auteurs.ListerAsync()); + } + + [Fact] + public async Task Le_meme_auteur_saisi_deux_fois_ne_donne_quun_lien() + { + var livre = await AjouterAsync("Germinal", "Émile Zola; ZOLA, Emile"); + + Assert.Single(livre.Auteurs); + } + + public void Dispose() + { + _db.Dispose(); + _connexion.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/MaBibli.Tests/ServiceCatalogueTests.cs b/MaBibli.Tests/ServiceCatalogueTests.cs index 0d1648a..3dc800a 100644 --- a/MaBibli.Tests/ServiceCatalogueTests.cs +++ b/MaBibli.Tests/ServiceCatalogueTests.cs @@ -31,7 +31,7 @@ public class ServiceCatalogueTests : IDisposable _db = new MaBibliDbContext(options); _db.Database.EnsureCreated(); - _service = new ServiceCatalogue(_db); + _service = new ServiceCatalogue(_db, new ServiceAuteurs(_db)); } private static EnregistrementLivre Saisie( @@ -39,7 +39,7 @@ public class ServiceCatalogueTests : IDisposable string? auteur = null, string? isbn = null, Format format = Format.Physique, - Statut statut = Statut.ALire) => new() + Statut? statut = null) => new() { Titre = titre, Auteur = auteur, @@ -58,6 +58,7 @@ public class ServiceCatalogueTests : IDisposable Assert.True(livre.Id > 0); Assert.Equal("Germinal", livre.Titre); Assert.Equal("mathieu", livre.AjoutePar); + Assert.Equal(["Émile Zola"], livre.Auteurs.Select(a => a.Nom)); Assert.NotEqual(default, livre.DateAjout); } @@ -97,10 +98,10 @@ public class ServiceCatalogueTests : IDisposable await _service.CreerAsync(Saisie("Papier", format: Format.Physique), "mathieu"); await _service.CreerAsync(Saisie("Epub", format: Format.Numerique), "mathieu"); - var tout = await _service.ListerAsync(new CritereLivres()); + var tout = await _service.ListerAsync(new CritereLivres(), "mathieu"); Assert.Equal(2, tout.Count); - var numeriques = await _service.ListerAsync(new CritereLivres { Format = Format.Numerique }); + var numeriques = await _service.ListerAsync(new CritereLivres { Format = Format.Numerique }, "mathieu"); Assert.Equal(["Epub"], numeriques.Select(l => l.Titre)); } @@ -110,13 +111,31 @@ public class ServiceCatalogueTests : IDisposable await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu"); await _service.CreerAsync(Saisie("Le Horla", "Guy de Maupassant"), "mathieu"); - var parAuteur = await _service.ListerAsync(new CritereLivres { Recherche = "maupassant" }); + var parAuteur = await _service.ListerAsync(new CritereLivres { Recherche = "maupassant" }, "mathieu"); Assert.Equal(["Le Horla"], parAuteur.Select(l => l.Titre)); - var parTitre = await _service.ListerAsync(new CritereLivres { Recherche = "GERMI" }); + var parTitre = await _service.ListerAsync(new CritereLivres { Recherche = "GERMI" }, "mathieu"); Assert.Equal(["Germinal"], parTitre.Select(l => l.Titre)); } + /// + /// La recherche sans accents doit traverser SQLite, pas seulement LINQ-to-Objects : c'est là + /// que la colonne normalisée gagne son existence. + /// + [Theory] + [InlineData("emile")] + [InlineData("EMILE")] + [InlineData("Émile Zola")] + public async Task La_recherche_sans_accents_traverse_bien_SQLite(string terme) + { + await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu"); + await _service.CreerAsync(Saisie("Le Horla", "Guy de Maupassant"), "mathieu"); + + var resultat = await _service.ListerAsync(new CritereLivres { Recherche = terme }, "mathieu"); + + Assert.Equal(["Germinal"], resultat.Select(l => l.Titre)); + } + /// /// Invariant central : la bibliothèque est commune. Des livres saisis par des comptes /// différents doivent tous ressortir, quelle que soit la personne qui consulte. @@ -128,7 +147,7 @@ public class ServiceCatalogueTests : IDisposable await _service.CreerAsync(Saisie("Livre de Camille"), "camille"); await _service.CreerAsync(Saisie("Livre anonyme"), null); - var tout = await _service.ListerAsync(new CritereLivres()); + var tout = await _service.ListerAsync(new CritereLivres(), "mathieu"); Assert.Equal(3, tout.Count); Assert.Contains(tout, l => l.AjoutePar == "mathieu"); @@ -139,19 +158,20 @@ public class ServiceCatalogueTests : IDisposable [Fact] public async Task Modifier_ne_reecrit_ni_AjoutePar_ni_DateAjout() { - var cree = (await _service.CreerAsync(Saisie("Titre initial", "Auteur"), "camille")).Livre!; + var cree = (await _service.CreerAsync(Saisie("Titre initial", "Auteur Initial"), "camille")).Livre!; var modifie = await _service.ModifierAsync(cree.Id, new EnregistrementLivre { Titre = "Titre corrigé", - Auteur = "Auteur corrigé", + Auteur = "Auteur Corrigé", Format = Format.Numerique, Statut = Statut.Lu, - }); + }, "camille"); Assert.Null(modifie.Erreur); Assert.Equal("Titre corrigé", modifie.Livre!.Titre); Assert.Equal(Format.Numerique, modifie.Livre.Format); + Assert.Equal(["Auteur Corrigé"], modifie.Livre.Auteurs.Select(a => a.Nom)); // La trace de la saisie d'origine survit à l'édition, y compris par quelqu'un d'autre. Assert.Equal("camille", modifie.Livre.AjoutePar); @@ -161,7 +181,7 @@ public class ServiceCatalogueTests : IDisposable [Fact] public async Task Modifier_un_livre_inexistant_est_signale_comme_introuvable() { - var resultat = await _service.ModifierAsync(4242, Saisie("Fantôme")); + var resultat = await _service.ModifierAsync(4242, Saisie("Fantôme"), "mathieu"); Assert.True(resultat.EstIntrouvable); } @@ -171,43 +191,161 @@ public class ServiceCatalogueTests : IDisposable { var cree = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!; - var apres = await _service.ChangerStatutAsync(cree.Id, Statut.EnCours); + var apres = await _service.ChangerStatutAsync(cree.Id, Statut.EnCours, "mathieu"); Assert.NotNull(apres); Assert.Equal(Statut.EnCours, apres!.Statut); Assert.Equal(cree.Titre, apres.Titre); - Assert.Equal(cree.Auteur, apres.Auteur); + Assert.Equal(cree.Auteurs.Select(a => a.Nom), apres.Auteurs.Select(a => a.Nom)); Assert.Equal(cree.AjoutePar, apres.AjoutePar); } + /// + /// La décision de cette phase, vérifiée de bout en bout. Un seul livre, deux personnes, + /// deux statuts qui ne se marchent pas dessus. + /// + [Fact] + public async Task Deux_utilisateurs_ont_des_statuts_differents_sur_le_meme_livre() + { + var livre = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!; + + await _service.ChangerStatutAsync(livre.Id, Statut.Lu, "mathieu"); + await _service.ChangerStatutAsync(livre.Id, Statut.EnCours, "camille"); + + Assert.Equal(Statut.Lu, (await _service.ObtenirAsync(livre.Id, "mathieu"))!.Statut); + Assert.Equal(Statut.EnCours, (await _service.ObtenirAsync(livre.Id, "camille"))!.Statut); + + // Une troisième personne n'a rien posé : pour elle le livre est « non commencé ». + Assert.Null((await _service.ObtenirAsync(livre.Id, "claire"))!.Statut); + + // Une seule ligne par personne : rien ne s'empile à force de changer d'avis. + Assert.Equal(2, await _db.StatutsLecture.CountAsync(s => s.LivreId == livre.Id)); + } + + [Fact] + public async Task Un_livre_cree_sans_statut_nen_ecrit_aucun() + { + var livre = (await _service.CreerAsync(Saisie("Germinal"), "mathieu")).Livre!; + + Assert.Null((await _service.ObtenirAsync(livre.Id, "mathieu"))!.Statut); + Assert.Empty(_db.StatutsLecture); + } + + [Fact] + public async Task Un_statut_nul_remet_le_livre_a_non_commence() + { + var livre = (await _service.CreerAsync(Saisie("Germinal", statut: Statut.Lu), "mathieu")).Livre!; + Assert.Equal(Statut.Lu, livre.Statut); + + var apres = await _service.ChangerStatutAsync(livre.Id, null, "mathieu"); + + Assert.Null(apres!.Statut); + Assert.Empty(_db.StatutsLecture); + } + + /// Éditer un livre pose MON statut, jamais celui de la personne qui l'a saisi. + [Fact] + public async Task Modifier_un_livre_ne_touche_pas_au_statut_des_autres() + { + var livre = (await _service.CreerAsync(Saisie("Germinal", statut: Statut.Lu), "mathieu")).Livre!; + + await _service.ModifierAsync( + livre.Id, Saisie("Germinal", statut: Statut.EnCours), "camille"); + + Assert.Equal(Statut.Lu, (await _service.ObtenirAsync(livre.Id, "mathieu"))!.Statut); + Assert.Equal(Statut.EnCours, (await _service.ObtenirAsync(livre.Id, "camille"))!.Statut); + } + [Fact] public async Task Changer_le_statut_dun_livre_inexistant_renvoie_null() - => Assert.Null(await _service.ChangerStatutAsync(4242, Statut.Lu)); + => Assert.Null(await _service.ChangerStatutAsync(4242, Statut.Lu, "mathieu")); + + [Fact] + public async Task Sans_identite_aucun_statut_nest_ecrit() + { + var livre = (await _service.CreerAsync(Saisie("Anonyme", statut: Statut.Lu), null)).Livre!; + + Assert.Null(livre.Statut); + Assert.Empty(_db.StatutsLecture); + } + + [Fact] + public async Task Un_livre_peut_avoir_plusieurs_auteurs_dans_lordre() + { + var livre = (await _service.CreerAsync( + Saisie("Introduction to Algorithms", + "Thomas H. Cormen; Charles E. Leiserson; Ronald L. Rivest; Clifford Stein"), + "mathieu")).Livre!; + + Assert.Equal( + ["Thomas H. Cormen", "Charles E. Leiserson", "Ronald L. Rivest", "Clifford Stein"], + livre.Auteurs.Select(a => a.Nom)); + } + + [Fact] + public async Task Les_variantes_sures_dun_auteur_partagent_la_meme_fiche() + { + var a = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!; + var b = (await _service.CreerAsync(Saisie("Nana", "Zola, Emile"), "mathieu")).Livre!; + var c = (await _service.CreerAsync(Saisie("La Curée", "E. Zola"), "mathieu")).Livre!; + + var id = a.Auteurs.Single().Id; + Assert.Equal(id, b.Auteurs.Single().Id); + Assert.Equal(id, c.Auteurs.Single().Id); + Assert.Equal(1, await _db.Auteurs.CountAsync()); + } + + [Fact] + public async Task Un_patronyme_seul_ne_se_fond_pas_dans_un_nom_complet() + { + var complet = (await _service.CreerAsync( + Saisie("Pandora's Star", "Peter F. Hamilton"), "mathieu")).Livre!; + var seul = (await _service.CreerAsync(Saisie("Autre livre", "Hamilton"), "mathieu")).Livre!; + + // Deux fiches distinctes : le rapprochement est ambigu, il sera proposé, pas appliqué. + Assert.NotEqual(complet.Auteurs.Single().Id, seul.Auteurs.Single().Id); + Assert.Equal(2, await _db.Auteurs.CountAsync()); + } [Fact] public async Task Supprimer_retire_le_livre_du_catalogue() { - var cree = (await _service.CreerAsync(Saisie("À jeter"), "mathieu")).Livre!; + var cree = (await _service.CreerAsync(Saisie("À jeter", "Auteur Jetable"), "mathieu")).Livre!; Assert.True(await _service.SupprimerAsync(cree.Id)); - Assert.Null(await _service.ObtenirAsync(cree.Id)); - Assert.Empty(await _service.ListerAsync(new CritereLivres())); + Assert.Null(await _service.ObtenirAsync(cree.Id, "mathieu")); + Assert.Empty(await _service.ListerAsync(new CritereLivres(), "mathieu")); + + // L'auteur n'a plus aucun livre : il ne doit pas rester à polluer l'écran des auteurs. + Assert.Empty(_db.Auteurs); // Seconde suppression : plus rien à supprimer. Assert.False(await _service.SupprimerAsync(cree.Id)); } + [Fact] + public async Task Un_auteur_encore_lu_par_un_autre_livre_survit_a_une_suppression() + { + var premier = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!; + await _service.CreerAsync(Saisie("Nana", "Émile Zola"), "mathieu"); + + await _service.SupprimerAsync(premier.Id); + + Assert.Equal(1, await _db.Auteurs.CountAsync()); + } + [Fact] public async Task Obtenir_renvoie_la_fiche_complete() { var cree = (await _service.CreerAsync( Saisie("Le Horla", "Guy de Maupassant", "9782080704092"), "mathieu")).Livre!; - var relu = await _service.ObtenirAsync(cree.Id); + var relu = await _service.ObtenirAsync(cree.Id, "mathieu"); Assert.NotNull(relu); Assert.Equal("Le Horla", relu!.Titre); Assert.Equal("9782080704092", relu.Isbn); + Assert.Equal(["Guy de Maupassant"], relu.Auteurs.Select(a => a.Nom)); } public void Dispose() diff --git a/MaBibli.Tests/ServiceRenormalisationTests.cs b/MaBibli.Tests/ServiceRenormalisationTests.cs new file mode 100644 index 0000000..028ae45 --- /dev/null +++ b/MaBibli.Tests/ServiceRenormalisationTests.cs @@ -0,0 +1,195 @@ +using MaBibli.Api.Data; +using MaBibli.Shared.Entites; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MaBibli.Tests; + +/// +/// Rattrapage des colonnes normalisées après une migration. +/// +/// +/// Les données sont écrites telles que la migration SQL les laisse : normalisation +/// approchée (lower() ne touche pas aux accents), donc variantes d'un même auteur encore +/// éclatées. C'est exactement l'état que ce service doit savoir rattraper. +/// +public class ServiceRenormalisationTests : IDisposable +{ + private readonly SqliteConnection _connexion; + private readonly MaBibliDbContext _db; + + public ServiceRenormalisationTests() + { + _connexion = new SqliteConnection("Data Source=:memory:"); + _connexion.Open(); + + _db = new MaBibliDbContext( + new DbContextOptionsBuilder().UseSqlite(_connexion).Options); + _db.Database.EnsureCreated(); + } + + private Task ExecuterAsync() => + new ServiceRenormalisation(_db, NullLogger.Instance).ExecuterAsync(); + + /// Écrit un auteur comme la migration le ferait : formes en lower() brut. + private Auteur AjouterHerite(string nom, params string[] titres) + { + var auteur = new Auteur + { + Nom = nom, + NomNormalise = nom.ToLowerInvariant(), + CleRegroupement = nom.ToLowerInvariant(), + }; + + _db.Auteurs.Add(auteur); + + foreach (var titre in titres) + { + var livre = new Livre { Titre = titre, TitreNormalise = titre.ToLowerInvariant() }; + _db.Livres.Add(livre); + livre.Auteurs.Add(new LivreAuteur { Auteur = auteur }); + } + + _db.SaveChanges(); + return auteur; + } + + [Fact] + public async Task Les_titres_perdent_leurs_accents_dans_la_colonne_normalisee() + { + AjouterHerite("Émile Zola", "La Bête humaine"); + + await ExecuterAsync(); + + Assert.Equal("la bete humaine", _db.Livres.Single().TitreNormalise); + } + + [Fact] + public async Task Les_variantes_daccent_et_dordre_sont_reunies() + { + AjouterHerite("Emile Zola", "Germinal"); + AjouterHerite("Émile Zola", "Nana"); + AjouterHerite("ZOLA, Émile", "La Curée"); + + await ExecuterAsync(); + + var zola = Assert.Single(_db.Auteurs); + Assert.Equal(3, await _db.LivreAuteurs.CountAsync(la => la.AuteurId == zola.Id)); + } + + /// + /// Le nom qui survit est celui qui se lit le mieux. Sans arbitrage, c'est l'ordre — arbitraire — + /// des identifiants issus de la migration qui déciderait, et la bibliothèque perdrait ses + /// accents au profit de la saisie la plus négligée. + /// + [Theory] + // L'ordre est celui des identifiants après migration : le résultat doit en être indépendant. + [InlineData("Emile Zola|Émile Zola")] + [InlineData("Émile Zola|Emile Zola")] + [InlineData("EMILE ZOLA|ZOLA, Émile|Émile Zola")] + [InlineData("ZOLA, Émile|Émile Zola")] + public async Task Le_nom_conserve_est_le_plus_presentable(string variantes) + { + foreach (var variante in variantes.Split('|')) + { + AjouterHerite(variante, $"Livre de {variante}"); + } + + await ExecuterAsync(); + + Assert.Equal("Émile Zola", Assert.Single(_db.Auteurs).Nom); + } + + /// + /// Les initiales échappent à la clé de regroupement. Sans second tour, une base héritée + /// resterait éclatée là où une saisie neuve aurait été réunie d'emblée. + /// + [Fact] + public async Task Les_initiales_abregees_sont_reunies_sous_le_nom_le_plus_complet() + { + AjouterHerite("P.F. Hamilton", "Pandora's Star"); + AjouterHerite("Peter F. Hamilton", "Judas Unchained"); + + await ExecuterAsync(); + + var hamilton = Assert.Single(_db.Auteurs); + Assert.Equal("Peter F. Hamilton", hamilton.Nom); + Assert.Equal(2, await _db.LivreAuteurs.CountAsync()); + } + + /// Le cas ambigu reste ambigu : il sera proposé, pas appliqué. + [Fact] + public async Task Un_patronyme_seul_nest_jamais_absorbe() + { + AjouterHerite("Hamilton", "Great North Road"); + AjouterHerite("Peter F. Hamilton", "Pandora's Star"); + + await ExecuterAsync(); + + Assert.Equal(2, await _db.Auteurs.CountAsync()); + } + + [Fact] + public async Task Un_livre_cosigne_par_deux_variantes_ne_garde_quun_lien() + { + var livre = new Livre { Titre = "Œuvre commune", TitreNormalise = "œuvre commune" }; + _db.Livres.Add(livre); + _db.SaveChanges(); + + foreach (var nom in new[] { "Emile Zola", "Émile Zola" }) + { + var auteur = new Auteur + { + Nom = nom, + NomNormalise = nom.ToLowerInvariant(), + CleRegroupement = nom.ToLowerInvariant(), + }; + + _db.Auteurs.Add(auteur); + _db.LivreAuteurs.Add(new LivreAuteur { LivreId = livre.Id, Auteur = auteur }); + } + + _db.SaveChanges(); + + await ExecuterAsync(); + + Assert.Single(_db.Auteurs); + Assert.Equal(1, await _db.LivreAuteurs.CountAsync()); + } + + [Fact] + public async Task Un_second_passage_ne_change_plus_rien() + { + AjouterHerite("Emile Zola", "Germinal"); + AjouterHerite("Émile Zola", "Nana"); + + await ExecuterAsync(); + var apresPremier = _db.Auteurs.Single(); + var nom = apresPremier.Nom; + var cle = apresPremier.CleRegroupement; + + await ExecuterAsync(); + + var apresSecond = Assert.Single(_db.Auteurs); + Assert.Equal(nom, apresSecond.Nom); + Assert.Equal(cle, apresSecond.CleRegroupement); + Assert.Equal(2, await _db.LivreAuteurs.CountAsync()); + } + + [Fact] + public async Task Une_base_vide_ne_pose_aucun_probleme() + { + await ExecuterAsync(); + + Assert.Empty(_db.Auteurs); + Assert.Empty(_db.Livres); + } + + public void Dispose() + { + _db.Dispose(); + _connexion.Dispose(); + GC.SuppressFinalize(this); + } +}