Distinguer scénario, dessin et couleurs sur chaque livre

Le rôle vit sur le LIEN livre-auteur, jamais sur l'auteur : le même auteur
scénarise un album et dessine le suivant, et il ne doit pas exister en deux
fiches pour autant — ce que l'index unique sur la clé de regroupement
interdit de toute façon.

Les rôles se retrouvent par clé d'auteur résolue et non par position :
ResoudreAsync écarte les noms vides et fusionne les variantes, donc sa
liste peut être plus courte que la saisie.

Piège traité : le champ des noms reconstruit la liste à chaque frappe.
Decouper reprend les rôles connus en rapprochant sur la forme normalisée,
sans quoi corriger une faute de frappe les effacerait tous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-19 22:37:20 +02:00
co-authored by Claude Opus 5
parent 9ab6f23b8c
commit 07cb48b2be
16 changed files with 895 additions and 22 deletions
+44 -1
View File
@@ -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
@@ -0,0 +1,501 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CleRegroupement")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("SerieId")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("TypeDocument")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Isbn");
b.HasIndex("TitreNormalise");
b.ToTable("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
b.ToTable("LivreAuteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Annee")
.HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("AuteurNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateParution")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RevueId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RevueId", "NumeroNormalise")
.IsUnique();
b.ToTable("NumerosRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurAId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurBId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AuteurAId", "AuteurBId")
.IsUnique();
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("SerieParenteId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateMaj")
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("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
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <inheritdoc />
public partial class RolesDesAuteurs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Role",
table: "LivreAuteurs",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Role",
table: "LivreAuteurs");
}
}
}
@@ -132,6 +132,9 @@ namespace MaBibli.Api.Data.Migrations
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
@@ -288,8 +288,10 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur);
/// <summary>Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ?</summary>
private static bool MemeAuteur(Livre livre, IReadOnlyList<string> saisis) =>
RapprochementAuteurs.PartagentUnAuteur(livre.Auteurs.Select(la => la.Auteur?.Nom), saisis);
/// <remarks>Le rôle n'entre pas dans la comparaison : c'est la personne qui compte ici.</remarks>
private static bool MemeAuteur(Livre livre, IReadOnlyList<AuteurSaisi> saisis) =>
RapprochementAuteurs.PartagentUnAuteur(
livre.Auteurs.Select(la => la.Auteur?.Nom), saisis.Select(a => a.Nom));
/// <summary>Phrase d'avertissement, écrite pour être lue telle quelle par l'utilisateur.</summary>
/// <remarks>
@@ -399,11 +401,18 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
await db.SaveChangesAsync(ct);
}
/// <summary>Remplace les auteurs du livre par ceux de la saisie, en conservant leur ordre.</summary>
/// <summary>
/// Remplace les auteurs du livre par ceux de la saisie, en conservant leur ordre et leur rôle.
/// </summary>
/// <remarks>
/// ⚠️ Le <b>rôle vit sur le lien</b>, 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 <c>ResoudreAsync</c> ne reçoit que des noms.
/// </remarks>
private async Task RattacherAuteursAsync(
Livre livre, IEnumerable<string> noms, CancellationToken ct)
Livre livre, IReadOnlyList<AuteurSaisi> 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<string, RoleAuteur>(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,
@@ -31,6 +31,38 @@
placeholder="Prénom Nom ; Prénom Nom" autocomplete="off" />
</label>
@*
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)
{
<fieldset class="champ">
<legend class="champ-libelle">
Rôles<span class="champ-aide"> (facultatif — utile pour une BD)</span>
</legend>
@foreach (var (auteur, index) in Saisie.Auteurs.Select((a, i) => (a, i)))
{
<div class="ligne-role" @key="index">
<span class="nom-role">@auteur.Nom</span>
<div class="segments">
@foreach (var role in Roles)
{
<button type="button"
class="segment @(auteur.Role == role ? "segment-actif" : null)"
@onclick="() => PoserRole(index, role)">
@Libelles.RoleAuteur(role)
</button>
}
</div>
</div>
}
</fieldset>
}
<label class="champ">
<span class="champ-libelle">Éditeur</span>
<input class="champ-saisie" type="text" @bind="Saisie.Editeur" @bind:event="oninput"
@@ -135,6 +167,19 @@
private static readonly TypeDocument[] Types = Enum.GetValues<TypeDocument>();
private static readonly RoleAuteur[] Roles = Enum.GetValues<RoleAuteur>();
/// <summary>
/// Pose le rôle d'un auteur.
/// </summary>
/// <remarks>
/// <see cref="AuteurSaisi"/> est un record : on remplace l'entrée plutôt que de la muter,
/// et la liste reste celle que la propriété <c>Auteur</c> saura relire pour préserver les
/// rôles à la frappe suivante.
/// </remarks>
private void PoserRole(int index, RoleAuteur role) =>
Saisie.Auteurs[index] = Saisie.Auteurs[index] with { Role = role };
[Parameter, EditorRequired] public EnregistrementLivre Saisie { get; set; } = new();
[Parameter] public string LibelleValidation { get; set; } = "Enregistrer";
+13
View File
@@ -25,6 +25,19 @@ public static class Libelles
_ => type.ToString(),
};
/// <summary>
/// Libellé d'un rôle d'auteur. « Non précisé » ne s'affiche jamais sur une fiche : c'est
/// l'absence de choix, pas une information.
/// </summary>
public static string RoleAuteur(RoleAuteur role) => role switch
{
MaBibli.Shared.Entites.RoleAuteur.NonPrecise => "Non précisé",
MaBibli.Shared.Entites.RoleAuteur.Scenario => "Scénario",
MaBibli.Shared.Entites.RoleAuteur.Dessin => "Dessin",
MaBibli.Shared.Entites.RoleAuteur.Couleurs => "Couleurs",
_ => role.ToString(),
};
public static string Statut(Statut statut) => statut switch
{
MaBibli.Shared.Entites.Statut.ALire => "À lire",
+5 -1
View File
@@ -127,7 +127,11 @@ else if (_livres is not null)
@foreach (var (auteur, index) in livre.Auteurs.Select((a, i) => (a, i)))
{
@(index > 0 ? ", " : "")
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>@if (auteur.Role != RoleAuteur.NonPrecise)
{
@* Comme le format et le type : seul ce qui a été choisi s'affiche. *@
<span class="role-auteur"> (@Libelles.RoleAuteur(auteur.Role).ToLowerInvariant())</span>
}
}
</p>
}
+5 -2
View File
@@ -104,7 +104,10 @@ else
@foreach (var (auteur, index) in _livre.Auteurs.Select((a, i) => (a, i)))
{
@(index > 0 ? ", " : "")
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>@if (auteur.Role != RoleAuteur.NonPrecise)
{
<span class="role-auteur"> (@Libelles.RoleAuteur(auteur.Role).ToLowerInvariant())</span>
}
}
</p>
}
@@ -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,
+22
View File
@@ -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;
}
+45 -11
View File
@@ -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;
/// <summary>
/// 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 <b>ce</b> livre. Le
/// serveur les rattache aux fiches existantes quand c'est sûr, et en crée sinon.
/// </summary>
public List<string> Auteurs { get; set; } = [];
public List<AuteurSaisi> 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);
}
/// <summary>Découpe une saisie texte en noms d'auteurs, en écartant les entrées vides.</summary>
public static List<string> Decouper(string? saisie) =>
string.IsNullOrWhiteSpace(saisie)
? []
: saisie.Split(SeparateurAuteurs, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
/// <summary>
/// Découpe une saisie texte en auteurs, en écartant les entrées vides.
/// </summary>
/// <param name="connus">
/// Auteurs déjà saisis, dont les <b>rôles</b> sont repris quand le nom réapparaît.
/// </param>
/// <remarks>
/// ⚠️ 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.
/// </remarks>
public static List<AuteurSaisi> Decouper(string? saisie, IEnumerable<AuteurSaisi>? connus = null)
{
if (string.IsNullOrWhiteSpace(saisie))
{
return [];
}
var roles = new Dictionary<string, RoleAuteur>(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();
}
}
/// <summary>Un auteur tel qu'il est saisi : son nom, et son rôle sur ce livre.</summary>
/// <remarks>
/// Le rôle n'appartient pas à la personne mais au <b>lien</b> : le même auteur scénarise un
/// album et dessine le suivant. Voir <see cref="RoleAuteur"/>.
/// </remarks>
public record AuteurSaisi(string Nom, RoleAuteur Role = RoleAuteur.NonPrecise);
/// <summary>Charge utile du changement de statut de lecture — l'action la plus fréquente.</summary>
/// <remarks>
/// Le statut posé est toujours celui de l'appelant : il n'y a pas de champ « utilisateur »,
+7
View File
@@ -12,6 +12,13 @@ public record AuteurDto
/// <summary>Nombre de livres du catalogue rattachés à cet auteur. Renseigné par la liste des auteurs.</summary>
public int NombreDeLivres { get; init; }
/// <summary>
/// Rôle sur le livre où cet auteur figure. <b>N'a de sens qu'à l'intérieur d'un
/// <see cref="LivreDto"/></b> : la liste des auteurs, elle, ne parle d'aucun livre en
/// particulier et le laisse à <c>NonPrecise</c>.
/// </summary>
public RoleAuteur Role { get; init; }
}
/// <summary>
+9
View File
@@ -60,6 +60,15 @@ public class LivreAuteur
/// <summary>Rang de l'auteur sur la couverture, à partir de 0.</summary>
public int Position { get; set; }
/// <summary>
/// Rôle sur <b>ce livre</b> : scénario, dessin, couleurs. Voir <see cref="RoleAuteur"/>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public RoleAuteur Role { get; set; }
}
/// <summary>
+32
View File
@@ -0,0 +1,32 @@
namespace MaBibli.Shared.Entites;
/// <summary>
/// Rôle d'un auteur <b>sur un livre donné</b>, et non une propriété de la personne.
/// </summary>
/// <remarks>
/// Le même auteur scénarise un album et dessine le suivant : le rôle vit donc sur le lien
/// <see cref="LivreAuteur"/>, jamais sur <see cref="Auteur"/>. C'est ce qui permet aussi de
/// garder <b>une seule fiche</b> par personne, à laquelle tous ses livres se rattachent.
/// <para>
/// ⚠️ <see cref="NonPrecise"/> 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 <see cref="TypeDocument"/> — l'interface n'affiche que ce qui a été choisi.
/// </para>
/// <para>
/// Liste volontairement courte : le besoin remonté est la <b>bande dessinée</b>, 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.
/// </para>
/// </remarks>
public enum RoleAuteur
{
/// <summary>Aucun rôle particulier — le cas de l'immense majorité des livres.</summary>
NonPrecise = 0,
Scenario = 1,
Dessin = 2,
/// <summary>Mise en couleurs : un troisième nom figure souvent sur un album.</summary>
Couleurs = 3,
}
@@ -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));
/// <summary>
/// Retaper le champ des noms ne doit pas effacer les rôles déjà posés.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <summary>
/// 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.
+68
View File
@@ -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