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)
+ {
+
+ }
+
}
diff --git a/MaBibli.Client/Pages/FicheLivre.razor b/MaBibli.Client/Pages/FicheLivre.razor
index 1c3fca8..3baeaed 100644
--- a/MaBibli.Client/Pages/FicheLivre.razor
+++ b/MaBibli.Client/Pages/FicheLivre.razor
@@ -104,7 +104,10 @@ else
@foreach (var (auteur, index) in _livre.Auteurs.Select((a, i) => (a, i)))
{
@(index > 0 ? ", " : "")
- @auteur.Nom
+ @auteur.Nom@if (auteur.Role != RoleAuteur.NonPrecise)
+ {
+ (@Libelles.RoleAuteur(auteur.Role).ToLowerInvariant())
+ }
}
}
@@ -320,7 +323,7 @@ else
{
Isbn = livre.Isbn,
Titre = livre.Titre,
- Auteurs = livre.Auteurs.Select(a => a.Nom).ToList(),
+ Auteurs = livre.Auteurs.Select(a => new AuteurSaisi(a.Nom, a.Role)).ToList(),
Editeur = livre.Editeur,
Format = livre.Format,
TypeDocument = livre.TypeDocument,
diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css
index 032cd7f..885f64b 100644
--- a/MaBibli.Client/wwwroot/css/app.css
+++ b/MaBibli.Client/wwwroot/css/app.css
@@ -1044,3 +1044,25 @@ body {
.champs-numero .champ {
flex: 1 1 8rem;
}
+
+/* --- Rôles des auteurs (scénario, dessin, couleurs) --- */
+
+.role-auteur {
+ color: var(--mb-texte-doux);
+ font-size: 0.85em;
+}
+
+/* Un nom, sa rangée de rôles : la ligne se replie sur les écrans étroits plutôt que de
+ comprimer les boutons, qui doivent rester atteignables au pouce. */
+.ligne-role {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4rem 0.75rem;
+ margin-top: 0.4rem;
+}
+
+.nom-role {
+ font-weight: 600;
+ font-size: 0.9rem;
+}
diff --git a/MaBibli.Shared/Dtos/EnregistrementLivre.cs b/MaBibli.Shared/Dtos/EnregistrementLivre.cs
index d56e7d3..b6be8a2 100644
--- a/MaBibli.Shared/Dtos/EnregistrementLivre.cs
+++ b/MaBibli.Shared/Dtos/EnregistrementLivre.cs
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using MaBibli.Shared.Entites;
+using MaBibli.Shared.Textes;
namespace MaBibli.Shared.Dtos;
@@ -25,10 +26,10 @@ public record EnregistrementLivre
public string Titre { get; set; } = string.Empty;
///
- /// Noms des auteurs, dans l'ordre de la couverture. Le serveur les rattache aux fiches
- /// existantes quand c'est sûr, et en crée sinon.
+ /// Auteurs, dans l'ordre de la couverture, chacun avec son rôle sur ce livre. Le
+ /// serveur les rattache aux fiches existantes quand c'est sûr, et en crée sinon.
///
- public List Auteurs { get; set; } = [];
+ public List Auteurs { get; set; } = [];
public string? Editeur { get; set; }
@@ -57,18 +58,51 @@ public record EnregistrementLivre
[JsonIgnore]
public string? Auteur
{
- get => Auteurs.Count == 0 ? null : string.Join($"{SeparateurAuteurs} ", Auteurs);
- set => Auteurs = Decouper(value);
+ get => Auteurs.Count == 0 ? null : string.Join($"{SeparateurAuteurs} ", Auteurs.Select(a => a.Nom));
+ set => Auteurs = Decouper(value, Auteurs);
}
- /// Découpe une saisie texte en noms d'auteurs, en écartant les entrées vides.
- public static List Decouper(string? saisie) =>
- string.IsNullOrWhiteSpace(saisie)
- ? []
- : saisie.Split(SeparateurAuteurs, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
- .ToList();
+ ///
+ /// Découpe une saisie texte en auteurs, en écartant les entrées vides.
+ ///
+ ///
+ /// Auteurs déjà saisis, dont les rôles sont repris quand le nom réapparaît.
+ ///
+ ///
+ /// ⚠️ Sans cette reprise, corriger une faute de frappe dans le champ des noms effacerait le
+ /// rôle des auteurs voisins : le champ se relie à chaque frappe, et il reconstruirait la
+ /// liste entière à « non précisé ». Le rapprochement se fait sur la forme normalisée du nom,
+ /// pour survivre à un accent ajouté après coup.
+ ///
+ public static List Decouper(string? saisie, IEnumerable? connus = null)
+ {
+ if (string.IsNullOrWhiteSpace(saisie))
+ {
+ return [];
+ }
+
+ var roles = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var connu in connus ?? [])
+ {
+ roles.TryAdd(NormalisationTexte.Normaliser(connu.Nom), connu.Role);
+ }
+
+ return saisie
+ .Split(SeparateurAuteurs, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(nom => new AuteurSaisi(
+ nom, roles.GetValueOrDefault(NormalisationTexte.Normaliser(nom), RoleAuteur.NonPrecise)))
+ .ToList();
+ }
}
+/// Un auteur tel qu'il est saisi : son nom, et son rôle sur ce livre.
+///
+/// Le rôle n'appartient pas à la personne mais au lien : le même auteur scénarise un
+/// album et dessine le suivant. Voir .
+///
+public record AuteurSaisi(string Nom, RoleAuteur Role = RoleAuteur.NonPrecise);
+
/// Charge utile du changement de statut de lecture — l'action la plus fréquente.
///
/// Le statut posé est toujours celui de l'appelant : il n'y a pas de champ « utilisateur »,
diff --git a/MaBibli.Shared/Dtos/LivreDto.cs b/MaBibli.Shared/Dtos/LivreDto.cs
index ce84b57..29b319a 100644
--- a/MaBibli.Shared/Dtos/LivreDto.cs
+++ b/MaBibli.Shared/Dtos/LivreDto.cs
@@ -12,6 +12,13 @@ public record AuteurDto
/// Nombre de livres du catalogue rattachés à cet auteur. Renseigné par la liste des auteurs.
public int NombreDeLivres { get; init; }
+
+ ///
+ /// Rôle sur le livre où cet auteur figure. N'a de sens qu'à l'intérieur d'un
+ /// : la liste des auteurs, elle, ne parle d'aucun livre en
+ /// particulier et le laisse à NonPrecise.
+ ///
+ public RoleAuteur Role { get; init; }
}
///
diff --git a/MaBibli.Shared/Entites/Auteur.cs b/MaBibli.Shared/Entites/Auteur.cs
index 64bb260..4bdc77b 100644
--- a/MaBibli.Shared/Entites/Auteur.cs
+++ b/MaBibli.Shared/Entites/Auteur.cs
@@ -60,6 +60,15 @@ public class LivreAuteur
/// Rang de l'auteur sur la couverture, à partir de 0.
public int Position { get; set; }
+
+ ///
+ /// Rôle sur ce livre : scénario, dessin, couleurs. Voir .
+ ///
+ ///
+ /// Il est porté par le lien et non par l'auteur : le même dessinateur peut avoir scénarisé
+ /// un autre album, et il ne doit pas pour autant exister en deux fiches.
+ ///
+ public RoleAuteur Role { get; set; }
}
///
diff --git a/MaBibli.Shared/Entites/RoleAuteur.cs b/MaBibli.Shared/Entites/RoleAuteur.cs
new file mode 100644
index 0000000..d1505a5
--- /dev/null
+++ b/MaBibli.Shared/Entites/RoleAuteur.cs
@@ -0,0 +1,32 @@
+namespace MaBibli.Shared.Entites;
+
+///
+/// Rôle d'un auteur sur un livre donné, et non une propriété de la personne.
+///
+///
+/// Le même auteur scénarise un album et dessine le suivant : le rôle vit donc sur le lien
+/// , jamais sur . C'est ce qui permet aussi de
+/// garder une seule fiche par personne, à laquelle tous ses livres se rattachent.
+///
+/// ⚠️ est la valeur par défaut, et ne veut pas dire « auteur du
+/// texte » : la quasi-totalité des livres n'a qu'un signataire dont le rôle ne se pose pas.
+/// Même règle que — l'interface n'affiche que ce qui a été choisi.
+///
+///
+/// Liste volontairement courte : le besoin remonté est la bande dessinée, où l'on veut
+/// savoir qui écrit et qui dessine. Une valeur s'ajoute sans migration, la renommer une fois
+/// posée sur des centaines de liens, non.
+///
+///
+public enum RoleAuteur
+{
+ /// Aucun rôle particulier — le cas de l'immense majorité des livres.
+ NonPrecise = 0,
+
+ Scenario = 1,
+
+ Dessin = 2,
+
+ /// Mise en couleurs : un troisième nom figure souvent sur un album.
+ Couleurs = 3,
+}
diff --git a/MaBibli.Tests/RapprochementAuteursTests.cs b/MaBibli.Tests/RapprochementAuteursTests.cs
index 0a23381..3e473cd 100644
--- a/MaBibli.Tests/RapprochementAuteursTests.cs
+++ b/MaBibli.Tests/RapprochementAuteursTests.cs
@@ -1,3 +1,5 @@
+using MaBibli.Shared.Dtos;
+using MaBibli.Shared.Entites;
using MaBibli.Shared.Textes;
namespace MaBibli.Tests;
@@ -117,6 +119,41 @@ public class RapprochementAuteursTests
public void La_normalisation_met_a_plat_casse_accents_et_ponctuation(string? brut, string attendu)
=> Assert.Equal(attendu, NormalisationTexte.Normaliser(brut));
+ ///
+ /// Retaper le champ des noms ne doit pas effacer les rôles déjà posés.
+ ///
+ ///
+ /// Le champ se relie à chaque frappe : sans reprise des rôles connus, corriger une faute de
+ /// frappe dans le nom d'un auteur remettrait tous ses voisins à « non précisé », sous les
+ /// yeux de l'utilisateur et sans qu'il comprenne pourquoi.
+ ///
+ [Fact]
+ public void Redecouper_la_ligne_dauteurs_preserve_les_roles()
+ {
+ var saisie = new EnregistrementLivre
+ {
+ Auteurs =
+ [
+ new AuteurSaisi("Dobbs", RoleAuteur.Scenario),
+ new AuteurSaisi("Christophe", RoleAuteur.Dessin),
+ ],
+ };
+
+ // L'utilisateur ajoute un accent au second nom : la ligne est redécoupée entièrement.
+ saisie.Auteur = "Dobbs; Christophé";
+
+ // Les deux rôles survivent : le rapprochement se fait sur la forme NORMALISÉE, donc un
+ // accent ajouté après coup ne casse rien.
+ Assert.Equal(
+ [RoleAuteur.Scenario, RoleAuteur.Dessin],
+ saisie.Auteurs.Select(a => a.Role));
+
+ // Un nom réellement différent, lui, n'est pas reconnu : son rôle repart à zéro, ce qui
+ // est honnête — on ne devine pas qu'il s'agit de la même personne.
+ saisie.Auteur = "Dobbs; Marchand";
+ Assert.Equal(RoleAuteur.NonPrecise, saisie.Auteurs[1].Role);
+ }
+
///
/// 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.
diff --git a/MaBibli.Tests/ServiceCatalogueTests.cs b/MaBibli.Tests/ServiceCatalogueTests.cs
index 4d69944..4422b4a 100644
--- a/MaBibli.Tests/ServiceCatalogueTests.cs
+++ b/MaBibli.Tests/ServiceCatalogueTests.cs
@@ -390,6 +390,74 @@ public class ServiceCatalogueTests : IDisposable
Assert.Equal(["Germinal"], nonPrecises.Select(l => l.Titre));
}
+ // ── Rôles des auteurs : ils vivent sur le LIEN, pas sur la personne ──────
+
+ [Fact]
+ public async Task Les_roles_sont_enregistres_par_livre_et_pas_par_personne()
+ {
+ var bd = Saisie("Le Horla", format: Format.Physique);
+ bd.TypeDocument = TypeDocument.BandeDessinee;
+ bd.Auteurs =
+ [
+ new AuteurSaisi("Guy de Maupassant", RoleAuteur.Scenario),
+ new AuteurSaisi("Guillaume Sorel", RoleAuteur.Dessin),
+ ];
+
+ var premier = (await _service.CreerAsync(bd, "mathieu")).Livre!;
+
+ // Le MÊME dessinateur, scénariste ailleurs : une seule fiche d'auteur, deux rôles.
+ var autre = Saisie("Autre album");
+ autre.Auteurs = [new AuteurSaisi("Guillaume Sorel", RoleAuteur.Scenario)];
+ var second = (await _service.CreerAsync(autre, "mathieu", confirmerDoublon: true)).Livre!;
+
+ Assert.Equal(
+ [RoleAuteur.Scenario, RoleAuteur.Dessin],
+ premier.Auteurs.Select(a => a.Role));
+
+ Assert.Equal(RoleAuteur.Scenario, second.Auteurs.Single().Role);
+ Assert.Equal(2, await _db.Auteurs.CountAsync());
+ }
+
+ [Fact]
+ public async Task Un_livre_sans_role_precise_reste_non_precise()
+ {
+ var livre = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!;
+
+ Assert.Equal(RoleAuteur.NonPrecise, livre.Auteurs.Single().Role);
+ }
+
+ [Fact]
+ public async Task Le_role_suit_lauteur_meme_si_son_nom_est_ecrit_autrement()
+ {
+ var saisie = Saisie("Le Horla");
+ saisie.Auteurs =
+ [
+ // Variante sûre d'un auteur déjà connu : le serveur la rapproche, et le rôle doit
+ // suivre la fiche RÉSOLUE, pas la position dans la saisie.
+ new AuteurSaisi("Maupassant, Guy de", RoleAuteur.Scenario),
+ ];
+
+ await _service.CreerAsync(Saisie("Bel-Ami", "Guy de Maupassant"), "mathieu");
+ var livre = (await _service.CreerAsync(saisie, "mathieu")).Livre!;
+
+ Assert.Single(await _db.Auteurs.ToListAsync());
+ Assert.Equal(RoleAuteur.Scenario, livre.Auteurs.Single().Role);
+ }
+
+ [Fact]
+ public async Task Modifier_un_livre_change_le_role_sans_toucher_a_lauteur()
+ {
+ var saisie = Saisie("Le Horla");
+ saisie.Auteurs = [new AuteurSaisi("Guillaume Sorel", RoleAuteur.Dessin)];
+ var cree = (await _service.CreerAsync(saisie, "mathieu")).Livre!;
+
+ saisie.Auteurs = [new AuteurSaisi("Guillaume Sorel", RoleAuteur.Couleurs)];
+ var modifie = (await _service.ModifierAsync(cree.Id, saisie, "mathieu")).Livre!;
+
+ Assert.Equal(RoleAuteur.Couleurs, modifie.Auteurs.Single().Role);
+ Assert.Single(await _db.Auteurs.ToListAsync());
+ }
+
// ─────────────────────────────────────────────────────────────────────────
// Doublons : un AVERTISSEMENT, jamais un refus. Posséder deux exemplaires est légitime —
// on garde le sien et on prête l'autre — et l'ISBN est facultatif, donc il ne peut pas