diff --git a/CLAUDE.md b/CLAUDE.md index ec8ed15..0523cff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -731,7 +731,9 @@ Auteur 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) +├── Position (rang sur la couverture, à partir de 0) +└── Role : NonPrecise | Scenario | Dessin | Couleurs + ⚠ sur le LIEN, pas sur l'auteur : on scénarise un album et on dessine le suivant StatutLecture (le statut est PERSONNEL) ├── Id @@ -941,6 +943,47 @@ livre sorti depuis six mois qu'on avait oublié, pas celui prêté hier. (`ServiceLivresApi.EnUtc`). Sans cela le prêt se décalerait d'un jour pour la moitié du globe. Vérifié en exécution : saisie du 12/08 → `2026-08-11 22:00` en base (CEST). +## Rôles des auteurs — migration `RolesDesAuteurs` (2026-08-19) + +Besoin venu de la bande dessinée : savoir **qui écrit et qui dessine**. `LivreAuteur.Role` porte +`NonPrecise` (0), `Scenario`, `Dessin`, `Couleurs` — un troisième nom figurant souvent sur un +album. + +### ⚠️ Le rôle vit sur le LIEN, pas sur l'auteur + +C'est la décision structurante, et elle se justifie par un cas banal : le même auteur scénarise +un album et **dessine** le suivant. Un rôle posé sur `Auteur` obligerait à créer deux fiches pour +une seule personne — exactement ce que l'index unique `CleRegroupement` interdit, et ce que tout +le travail de rapprochement cherche à éviter. + +Conséquence dans `ServiceCatalogue` : `ResoudreAsync` ne reçoit **que des noms**, et les rôles +sont réappliqués ensuite. ⚠️ Ils se retrouvent **par clé d'auteur résolue**, jamais par position : +`ResoudreAsync` écarte les noms vides et fusionne les variantes, donc sa liste peut être plus +courte que la saisie — un rapprochement par index décalerait tous les rôles suivants. Un test +couvre le cas « Maupassant, Guy de » rapproché de « Guy de Maupassant ». + +### Deux règles d'interface, héritées telles quelles + +- **Les rôles n'apparaissent qu'à partir de deux auteurs.** Un roman n'a qu'un signataire dont le + rôle ne se pose pas ; la question « qui écrit, qui dessine ? » ne se pose qu'à plusieurs. +- **Seul ce qui a été choisi s'affiche** : « Dobbs (scénario), Christophe Regnault (dessin) », + et rien du tout quand le rôle est `NonPrecise`. Même règle que le format et le type de document. + +### ⚠️ Le piège : le champ des noms se relie à chaque frappe + +`EnregistrementLivre.Auteur` (la vue « une seule ligne ») **reconstruit la liste entière** à +chaque caractère tapé. Sans précaution, corriger une faute de frappe remettrait tous les rôles à +« non précisé » sous les yeux de l'utilisateur. + +`Decouper` reprend donc les rôles des auteurs déjà saisis, en rapprochant sur la forme +**normalisée** du nom — ce qui les fait survivre à un accent ajouté après coup (vérifié en +exécution : « Christophe » → « Christophé » garde son rôle). Un nom réellement différent, lui, +repart à zéro : on ne devine pas qu'il s'agit de la même personne. + +**Ce qui n'est délibérément pas fait** : ni traducteur, ni préfacier, ni photographe. La liste +reste courte tant que le besoin n'est pas constaté — une valeur s'ajoute sans migration, la +renommer une fois posée sur des centaines de liens, non. + ## Type de document — migration `TypeDeDocument` (2026-08-19) Besoin remonté : distinguer une BD d'un roman. Une BD était déjà catalogable telle quelle (elle diff --git a/MaBibli.Api/Data/Migrations/20260819203118_RolesDesAuteurs.Designer.cs b/MaBibli.Api/Data/Migrations/20260819203118_RolesDesAuteurs.Designer.cs new file mode 100644 index 0000000..d50df71 --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260819203118_RolesDesAuteurs.Designer.cs @@ -0,0 +1,501 @@ +// +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("20260819203118_RolesDesAuteurs")] + partial class RolesDesAuteurs + { + /// + 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.ElementSerie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SerieId") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LivreId"); + + b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId"); + + b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId") + .IsUnique() + .HasFilter("\"LivreId\" IS NOT NULL"); + + b.ToTable("ElementsSerie"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeDocument") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Isbn"); + + b.HasIndex("TitreNormalise"); + + b.ToTable("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "AuteurId"); + + b.HasIndex("AuteurId"); + + b.ToTable("LivreAuteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Annee") + .HasColumnType("TEXT"); + + b.Property("Auteur") + .HasColumnType("TEXT"); + + b.Property("AuteurNormalise") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Rang") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur"); + + b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre") + .IsUnique(); + + b.ToTable("LivresSouhaites"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("DateParution") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Numero") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NumeroNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevueId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RevueId", "NumeroNormalise") + .IsUnique(); + + b.ToTable("NumerosRevue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DatePret") + .HasColumnType("TEXT"); + + b.Property("DateRetour") + .HasColumnType("TEXT"); + + b.Property("Emprunteur") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours") + .IsUnique() + .HasFilter("\"DateRetour\" IS NULL"); + + b.ToTable("Prets"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurAId") + .HasColumnType("INTEGER"); + + b.Property("AuteurBId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuteurAId", "AuteurBId") + .IsUnique(); + + b.ToTable("RapprochementsRefuses"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Issn") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TitreNormalise") + .IsUnique(); + + b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn") + .IsUnique() + .HasFilter("\"Issn\" IS NOT NULL"); + + b.ToTable("Revues"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SerieParenteId") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SerieParenteId"); + + b.HasIndex("TitreNormalise") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateMaj") + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Statut") + .HasColumnType("INTEGER"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Utilisateur"); + + b.HasIndex("LivreId", "Utilisateur") + .IsUnique(); + + b.ToTable("StatutsLecture"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany() + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("MaBibli.Shared.Entites.Serie", "Serie") + .WithMany("Elements") + .HasForeignKey("SerieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + + b.Navigation("Serie"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur") + .WithMany("Livres") + .HasForeignKey("AuteurId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Auteurs") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Auteur"); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.HasOne("MaBibli.Shared.Entites.Revue", "Revue") + .WithMany("Numeros") + .HasForeignKey("RevueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Revue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Prets") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente") + .WithMany("SousSeries") + .HasForeignKey("SerieParenteId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("SerieParente"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Statuts") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Navigation("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Navigation("Auteurs"); + + b.Navigation("Prets"); + + b.Navigation("Statuts"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => + { + b.Navigation("Numeros"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.Navigation("Elements"); + + b.Navigation("SousSeries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MaBibli.Api/Data/Migrations/20260819203118_RolesDesAuteurs.cs b/MaBibli.Api/Data/Migrations/20260819203118_RolesDesAuteurs.cs new file mode 100644 index 0000000..bb6711f --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260819203118_RolesDesAuteurs.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + /// + public partial class RolesDesAuteurs : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Role", + table: "LivreAuteurs", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Role", + table: "LivreAuteurs"); + } + } +} diff --git a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs index 19711a2..e2db085 100644 --- a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs +++ b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs @@ -132,6 +132,9 @@ namespace MaBibli.Api.Data.Migrations b.Property("Position") .HasColumnType("INTEGER"); + b.Property("Role") + .HasColumnType("INTEGER"); + b.HasKey("LivreId", "AuteurId"); b.HasIndex("AuteurId"); diff --git a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs index f409b71..5c07c68 100644 --- a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs +++ b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs @@ -288,8 +288,10 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur); /// Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ? - private static bool MemeAuteur(Livre livre, IReadOnlyList saisis) => - RapprochementAuteurs.PartagentUnAuteur(livre.Auteurs.Select(la => la.Auteur?.Nom), saisis); + /// Le rôle n'entre pas dans la comparaison : c'est la personne qui compte ici. + private static bool MemeAuteur(Livre livre, IReadOnlyList saisis) => + RapprochementAuteurs.PartagentUnAuteur( + livre.Auteurs.Select(la => la.Auteur?.Nom), saisis.Select(a => a.Nom)); /// Phrase d'avertissement, écrite pour être lue telle quelle par l'utilisateur. /// @@ -399,11 +401,18 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur await db.SaveChangesAsync(ct); } - /// Remplace les auteurs du livre par ceux de la saisie, en conservant leur ordre. + /// + /// Remplace les auteurs du livre par ceux de la saisie, en conservant leur ordre et leur rôle. + /// + /// + /// ⚠️ Le rôle vit sur le lien, pas sur la fiche de l'auteur : le même dessinateur + /// peut avoir scénarisé un autre album, et il ne doit pas exister en deux fiches pour + /// autant. C'est pour cela que ResoudreAsync ne reçoit que des noms. + /// private async Task RattacherAuteursAsync( - Livre livre, IEnumerable noms, CancellationToken ct) + Livre livre, IReadOnlyList saisis, CancellationToken ct) { - var resolus = await auteurs.ResoudreAsync(noms, ct); + var resolus = await auteurs.ResoudreAsync(saisis.Select(a => a.Nom), 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. @@ -413,9 +422,23 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur livre.Auteurs.Clear(); } + // ResoudreAsync écarte les noms vides et fusionne les variantes : sa liste peut être + // plus courte que la saisie. Les rôles se retrouvent donc par NOM RÉSOLU, jamais par + // position — sans quoi un nom vide au milieu décalerait tous les rôles suivants. + var roles = new Dictionary(StringComparer.Ordinal); + foreach (var saisi in saisis) + { + roles.TryAdd(RapprochementAuteurs.Cle(saisi.Nom), saisi.Role); + } + for (var i = 0; i < resolus.Count; i++) { - livre.Auteurs.Add(new LivreAuteur { Auteur = resolus[i], Position = i }); + livre.Auteurs.Add(new LivreAuteur + { + Auteur = resolus[i], + Position = i, + Role = roles.GetValueOrDefault(resolus[i].CleRegroupement, RoleAuteur.NonPrecise), + }); } } @@ -464,7 +487,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs 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 }) + .Select(la => new AuteurDto { Id = la.Auteur!.Id, Nom = la.Auteur.Nom, Role = la.Role }) .ToList(), Editeur = livre.Editeur, Format = livre.Format, diff --git a/MaBibli.Client/Composants/FormulaireLivre.razor b/MaBibli.Client/Composants/FormulaireLivre.razor index f72c5fe..fefc0e9 100644 --- a/MaBibli.Client/Composants/FormulaireLivre.razor +++ b/MaBibli.Client/Composants/FormulaireLivre.razor @@ -31,6 +31,38 @@ placeholder="Prénom Nom ; Prénom Nom" autocomplete="off" /> + @* + Les rôles n'apparaissent qu'à partir de DEUX auteurs, et c'est délibéré : un roman n'a + qu'un signataire dont le rôle ne se pose pas, et une ligne de boutons sous chaque saisie + n'apprendrait rien. La question « qui écrit, qui dessine ? » ne se pose qu'à plusieurs. + Le défaut reste « non précisé » : on ne remplit pas une case à la place de l'utilisateur. + *@ + @if (Saisie.Auteurs.Count > 1) + { +
+ + Rôles (facultatif — utile pour une BD) + + + @foreach (var (auteur, index) in Saisie.Auteurs.Select((a, i) => (a, i))) + { +
+ @auteur.Nom +
+ @foreach (var role in Roles) + { + + } +
+
+ } +
+ } +