diff --git a/CLAUDE.md b/CLAUDE.md index e16856c..dd73ab9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -310,20 +310,47 @@ Compilation **locale**, puis dépôt manuel de l'archive en release sur le Gitea **Ne jamais compiler sur le serveur** à l'installation : cela imposerait le SDK .NET complet sur la machine YunoHost, pour une compilation lente — l'inverse exact de ce que permet le self-contained. -## Modèle de données (base de départ, à affiner) +## Modèle de données + +Schéma en vigueur depuis la migration `StatutPersonnelEtTableAuteurs` (2026-08-18). ``` Livre ├── Id -├── Isbn +├── Isbn (index) ├── Titre -├── Auteur +├── TitreNormalise (index — minuscules sans accents, recalculé à chaque écriture) ├── Editeur ├── Format : Physique | Numerique -├── Statut : ALire | EnCours | Lu ├── CoverUrl ├── DateAjout └── AjoutePar (YNH_USER — traçabilité, PAS un cloisonnement) + ⚠ pas de colonne Statut, pas de colonne Auteur + +Auteur +├── Id +├── Nom (affichage : « Émile Zola ») +├── NomNormalise (index — recherche : « emile zola ») +└── CleRegroupement (index UNIQUE — mots normalisés triés : « emile zola ») + +LivreAuteur (n-n : un livre peut avoir plusieurs auteurs) +├── LivreId ─┐ clé primaire composite +├── AuteurId ─┘ FK Livre = cascade, FK Auteur = restrict +└── Position (rang sur la couverture, à partir de 0) + +StatutLecture (le statut est PERSONNEL) +├── Id +├── LivreId (FK vers Livre, cascade) +├── Utilisateur (YNH_USER) +├── Statut : ALire | EnCours | Lu +├── DateMaj +└── UNIQUE (LivreId, Utilisateur) + absence de ligne = « non commencé » + +RapprochementRefuse (mémoire des « non » de l'utilisateur) +├── Id +├── AuteurAId ─┐ UNIQUE, couple toujours rangé (petit Id d'abord) +└── AuteurBId ─┘ pour qu'un refus vaille dans les deux sens Pret ├── Id @@ -341,6 +368,26 @@ Garder `Pret` comme table séparée (pas un champ sur `Livre`) pour conserver l' **L'auteur n'est plus un champ texte sur `Livre`.** Une table dédiée porte le nom d'affichage et une forme normalisée servant au regroupement et à la recherche. Le regroupement automatique ne s'applique qu'aux variantes **sûres** (casse, accents, initiales, ordre nom/prénom) ; les rapprochements ambigus — « Hamilton » seul vers « Peter F. Hamilton » — doivent être **proposés**, jamais appliqués silencieusement : une fusion erronée est difficile à défaire. +### Recherche sans accents : pourquoi des colonnes normalisées + +**SQLite ne sait pas ignorer les accents.** Sa collation `NOCASE` ne couvre que l'ASCII et il n'existe pas de `unaccent()` intégré. La seule façon d'obtenir « Emile » → « Émile » sans charger tout le catalogue en mémoire est de stocker la forme mise à plat dans une colonne dédiée, et d'y chercher un terme passé par **exactement la même** fonction (`NormalisationTexte`). + +Conséquence à ne pas oublier : **toute écriture doit recalculer ces colonnes** (`Livre.RecalculerFormes()`, `Auteur.RecalculerFormes()`). + +Deux formes cohabitent sur `Auteur`, et ce n'est pas une redondance : + +- `NomNormalise` garde l'ordre de saisie — c'est ce que la recherche parcourt en sous-chaîne ; +- `CleRegroupement` trie les mots, ce qui rend `Zola, Émile` et `Émile Zola` identiques. C'est elle qui porte l'index **unique**, donc l'invariant « un auteur, une fiche ». + +La clé ne sait pas rapprocher les **initiales** (`P.F. Hamilton` ↔ `Peter F. Hamilton`) : cela demande d'aligner les mots un à un, ce qui n'a pas d'équivalent SQL. Ce cas est traité en mémoire par `RapprochementAuteurs.Comparer`, sur une table qui compte au plus quelques centaines de lignes. + +### Reprise des données existantes + +La migration recopie l'ancien contenu avec les moyens du bord, puis **`ServiceRenormalisation` finit le travail en C# au démarrage** — idempotent, il sert aussi de filet si les règles de normalisation changent un jour. + +- L'ancien `Livres.Statut`, commun à tout le foyer, est **rattaché à `AjoutePar`**, seule personne que la base associe au livre. Les statuts des livres sans `AjoutePar` sont **perdus** : les attribuer serait une invention. +- L'ancien `Livres.Auteur` devient une fiche par valeur distincte. SQLite ne regroupant qu'à la casse près, les variantes accentuées ressortent en double — c'est le passage C# qui les réunit, en gardant le nom d'affichage le plus présentable (accents conservés, ni `NOM, Prénom` ni capitales intégrales). + **Les prêts ne concernent en pratique que les livres physiques** — les ebooks étant de simples fiches, il n'y a pas d'objet à prêter. `Emprunteur` reste un **texte libre**, sans lien avec les comptes YunoHost : on suit les prêts à des personnes extérieures au foyer, pas les échanges entre utilisateurs de l'app. ## Historique du projet (pourquoi ces choix) 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/NettoyageIsbd.cs b/MaBibli.Api/Services/Isbn/NettoyageIsbd.cs index 2382b14..fa6deab 100644 --- a/MaBibli.Api/Services/Isbn/NettoyageIsbd.cs +++ b/MaBibli.Api/Services/Isbn/NettoyageIsbd.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.RegularExpressions; namespace MaBibli.Api.Services.Isbn; @@ -43,6 +44,71 @@ public static partial class NettoyageIsbd return s.Length == 0 ? null : s; } + /// + /// Retire du titre le nom d'auteur qui y est collé en fin de chaîne. + /// + /// + /// Cas réel mesuré, ISBN 9782749942636 : + /// dc:title vaut « Le plus grand défi … (Nouvelle éd. augmentée) Aurélien Barrau » + /// alors que dc:creator vaut « Barrau, Aurélien (1973-....) ». Le nom est recollé + /// sans le séparateur ISBD « / », donc ne peut rien y faire : la + /// notice est sale à la source. + /// + /// L'heuristique est volontairement stricte — comparaison en fin de chaîne uniquement, + /// et seulement contre un auteur déjà nettoyé. Un titre qui contient légitimement un nom de + /// personne ailleurs (« Lettre à Émile Zola ») n'est pas touché, et un titre qui est + /// le nom de l'auteur (biographie, recueil) est laissé intact plutôt que réduit à rien. + /// + /// + public static string? RetirerAuteurEnFin(string? titre, IEnumerable auteurs) + { + var s = titre?.Trim(); + if (string.IsNullOrEmpty(s)) + { + return null; + } + + foreach (var auteur in auteurs) + { + var nom = auteur?.Trim(); + if (string.IsNullOrEmpty(nom) || nom.Length >= s.Length) + { + // Un nom aussi long que le titre ne laisserait rien : c'est une biographie + // ou un recueil, pas une notice sale. + continue; + } + + var debut = s.Length - nom.Length; + + // IgnoreNonSpace ignore les accents sans changer les longueurs, contrairement à une + // normalisation NFD : les indices calculés restent valables sur la chaîne d'origine. + var identique = string.Compare( + s, debut, nom, 0, nom.Length, + CultureInfo.InvariantCulture, + CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace) == 0; + + if (!identique) + { + continue; + } + + // Le nom doit commencer sur une frontière de mot, sinon « …Barrau » couperait aussi + // dans un mot qui se termine par les mêmes lettres. + if (char.IsLetterOrDigit(s[debut - 1])) + { + continue; + } + + var reste = s[..debut].TrimEnd(' ', ',', ';', '.', '-', '/', ':'); + if (reste.Length > 0) + { + return reste; + } + } + + return s; + } + /// /// Auteur : retirer les dates entre parenthèses, le rôle qui suit le point, /// puis inverser « Nom, Prénom » en « Prénom Nom ». 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/NettoyageIsbdTests.cs b/MaBibli.Tests/NettoyageIsbdTests.cs index 6ce560f..493af12 100644 --- a/MaBibli.Tests/NettoyageIsbdTests.cs +++ b/MaBibli.Tests/NettoyageIsbdTests.cs @@ -22,6 +22,52 @@ public class NettoyageIsbdTests public void Titre_laisse_passer_ce_qui_est_deja_propre(string brut, string attendu) => Assert.Equal(attendu, NettoyageIsbd.Titre(brut)); + /// + /// Cas réel mesuré sur l'ISBN 9782749942636 : la BnF recolle le nom de l'auteur en + /// fin de titre sans le séparateur ISBD, ce que ne + /// peut donc pas voir. + /// + [Fact] + public void Titre_retire_le_nom_de_lauteur_colle_en_fin() + { + const string brut = "Le plus grand défi de l'histoire de l'humanité : face à la " + + "catastrophe écologique et sociale (Nouvelle éd. augmentée) Aurélien Barrau"; + + var titre = NettoyageIsbd.RetirerAuteurEnFin(brut, ["Aurélien Barrau"]); + + Assert.Equal( + "Le plus grand défi de l'histoire de l'humanité : face à la catastrophe écologique " + + "et sociale (Nouvelle éd. augmentée)", + titre); + } + + [Theory] + // Contre-exemple central : le nom est bien là, mais pas en fin de chaîne. On n'y touche pas. + [InlineData("Lettre à Émile Zola", "Guy de Maupassant")] + [InlineData("Émile Zola et son temps", "Émile Zola")] + // Le titre EST le nom de l'auteur (biographie, recueil) : le retirer ne laisserait rien. + [InlineData("Aurélien Barrau", "Aurélien Barrau")] + // Fin de mot fortuite : « Zola » ne doit pas être découpé dans « Gorgonzola ». + [InlineData("Éloge du gorgonzola", "Zola")] + // Auteur inconnu du titre. + [InlineData("Germinal", "Émile Zola")] + public void Titre_ne_mutile_pas_un_nom_legitime(string titre, string auteur) + => Assert.Equal(titre, NettoyageIsbd.RetirerAuteurEnFin(titre, [auteur])); + + [Fact] + public void Titre_tolere_une_difference_daccent_ou_de_casse_sur_le_nom() + => Assert.Equal( + "Le grand défi", + NettoyageIsbd.RetirerAuteurEnFin("Le grand défi AURELIEN BARRAU", ["Aurélien Barrau"])); + + [Fact] + public void Titre_sans_auteur_connu_reste_intact() + { + Assert.Equal("Germinal", NettoyageIsbd.RetirerAuteurEnFin("Germinal", [])); + Assert.Equal("Germinal", NettoyageIsbd.RetirerAuteurEnFin("Germinal", [null, " "])); + Assert.Null(NettoyageIsbd.RetirerAuteurEnFin(null, ["Émile Zola"])); + } + [Theory] [InlineData("Zola, Émile (1840-1902). Auteur du texte", "Émile Zola")] [InlineData("Saint-Exupéry, Antoine de (1900-1944). Auteur du texte", "Antoine de Saint-Exupéry")] 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/RapprochementAuteursTests.cs b/MaBibli.Tests/RapprochementAuteursTests.cs new file mode 100644 index 0000000..da3309e --- /dev/null +++ b/MaBibli.Tests/RapprochementAuteursTests.cs @@ -0,0 +1,121 @@ +using MaBibli.Shared.Textes; + +namespace MaBibli.Tests; + +/// +/// Règles de regroupement des noms d'auteurs. +/// +/// +/// La frontière testée ici est celle que CLAUDE.md pose : ce qui est sûr est fusionné, ce qui +/// est ambigu est proposé. Les tests sont écrits par catégorie plutôt que par cas, pour que +/// l'ajout d'une règle oblige à dire de quel côté de la frontière elle tombe. +/// +public class RapprochementAuteursTests +{ + [Theory] + // Casse et accents. + [InlineData("Émile Zola", "emile zola")] + [InlineData("Émile Zola", "EMILE ZOLA")] + [InlineData("Émile Zola", "Emile Zola")] + // Ordre inversé, avec ou sans virgule. + [InlineData("Zola, Émile", "Émile Zola")] + [InlineData("Zola, Emile", "emile zola")] + // Initiales abrégées, le reste concordant. + [InlineData("Peter F. Hamilton", "P.F. Hamilton")] + [InlineData("Peter F. Hamilton", "P. F. Hamilton")] + [InlineData("P.F. Hamilton", "P. F. Hamilton")] + [InlineData("Thomas H. Cormen", "T. H. Cormen")] + // Combinaison des trois. + [InlineData("Hamilton, Peter F.", "p f hamilton")] + // Auteur mononyme. + [InlineData("Homère", "homere")] + public void Les_variantes_sures_sont_reconnues(string a, string b) + { + Assert.Equal(NiveauRapprochement.Certain, RapprochementAuteurs.Comparer(a, b)); + Assert.Equal(NiveauRapprochement.Certain, RapprochementAuteurs.Comparer(b, a)); + } + + [Theory] + // Le cas d'école de CLAUDE.md : un patronyme seul face à un nom complet. + [InlineData("Hamilton", "Peter F. Hamilton")] + [InlineData("Zola", "Émile Zola")] + [InlineData("Cormen", "Thomas H. Cormen")] + // Un prénom en plus, ou en moins. + [InlineData("Émile Zola", "Émile Édouard Zola")] + public void Les_rapprochements_ambigus_sont_seulement_proposes(string a, string b) + { + Assert.Equal(NiveauRapprochement.Suggere, RapprochementAuteurs.Comparer(a, b)); + Assert.Equal(NiveauRapprochement.Suggere, RapprochementAuteurs.Comparer(b, a)); + + // Et surtout : jamais fusionnés d'office. + Assert.False(RapprochementAuteurs.SontLeMemeAuteur(a, b)); + } + + [Theory] + // Deux personnes qui partagent un patronyme : rien ne doit les rapprocher automatiquement. + [InlineData("Peter Hamilton", "Paul Hamilton")] + [InlineData("Peter F. Hamilton", "Peter G. Hamilton")] + // Homonymes partiels sans mot entier commun. + [InlineData("Émile Zola", "Émile Ajar")] + [InlineData("Voltaire", "Molière")] + // Une initiale seule ne concorde sur rien de vérifiable. + [InlineData("P. H.", "Peter Hamilton")] + [InlineData("P. Hamilton", "Peter Paul")] + public void Les_auteurs_distincts_ne_sont_pas_rapproches(string a, string b) + { + Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(a, b)); + Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(b, a)); + } + + [Fact] + public void Un_nom_vide_ne_se_rapproche_de_rien() + { + Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(null, "Émile Zola")); + Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(" ", "Émile Zola")); + Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(".,;", "Émile Zola")); + } + + [Fact] + public void La_cle_est_identique_pour_les_variantes_dordre_et_daccents() + { + var attendue = RapprochementAuteurs.Cle("Émile Zola"); + + Assert.Equal(attendue, RapprochementAuteurs.Cle("Zola, Émile")); + Assert.Equal(attendue, RapprochementAuteurs.Cle("emile zola")); + Assert.Equal(attendue, RapprochementAuteurs.Cle(" ZOLA, Emile ")); + Assert.Equal("emile zola", attendue); + } + + /// + /// La clé ne sait pas rapprocher les initiales : c'est justement pour ça que + /// existe en plus de l'index unique. + /// + [Fact] + public void La_cle_ne_couvre_pas_les_initiales() + { + Assert.NotEqual( + RapprochementAuteurs.Cle("Peter F. Hamilton"), + RapprochementAuteurs.Cle("P.F. Hamilton")); + + Assert.True(RapprochementAuteurs.SontLeMemeAuteur("Peter F. Hamilton", "P.F. Hamilton")); + } + + [Theory] + [InlineData("Émile ZOLA", "emile zola")] + [InlineData("P.F. Hamilton", "p f hamilton")] + [InlineData(" Zola, Émile ", "zola emile")] + [InlineData("Saint-Exupéry", "saint exupery")] + [InlineData("L'Écume", "l ecume")] + [InlineData("", "")] + [InlineData(null, "")] + public void La_normalisation_met_a_plat_casse_accents_et_ponctuation(string? brut, string attendu) + => Assert.Equal(attendu, NormalisationTexte.Normaliser(brut)); + + /// + /// La ponctuation devient une espace et ne disparaît pas : sans ça « P.F. » donnerait un seul + /// mot et les initiales ne pourraient plus s'aligner. + /// + [Fact] + public void La_ponctuation_separe_les_mots() + => Assert.Equal(["p", "f", "hamilton"], NormalisationTexte.Mots("P.F. Hamilton")); +} 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); + } +}