Ajoute les thèmes communs sur les fiches de livres

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
LIMONIER Mathieu
2026-08-20 14:38:19 +02:00
co-authored by Copilot
parent e28d5bce32
commit c2d408afc6
17 changed files with 992 additions and 8 deletions
+25
View File
@@ -2008,3 +2008,28 @@ la position la plus précoce et un rôle renseigné plutôt que `NonPrecise`, pu
ses refus deviennent obsolètes. Sans collision, le renommage est direct. Cette opération est
volontairement distincte de la renormalisation automatique : c'est un choix explicite de
l'utilisateur.
### F2 : thèmes en étiquettes (2026-08-20)
Les thèmes décrivent l'œuvre (« dark fantasy », « space opera »), pas la relation personnelle
d'un lecteur à un livre. Ils sont donc **communs au foyer**, comme le catalogue, les auteurs et
les séries : `AjoutePar` reste une trace de saisie et ne crée aucune frontière. Les statuts de
lecture et les envies restent les seules données personnelles parce qu'elles expriment une
relation de l'utilisateur, et non une propriété de l'œuvre.
La saisie est **manuelle uniquement**. La BnF expose bien `dc:subject`, mais son vocabulaire
Rameau est une indexation documentaire professionnelle et ne correspond pas aux étiquettes
attendues par l'utilisateur. Aucun thème n'est donc déduit automatiquement d'un lookup ISBN ;
comme pour les rôles d'auteurs, l'utilisateur choisit ce qu'il veut réellement afficher.
Le modèle est une table dédiée `Theme` (`Nom`, `NomNormalise`) et une table n-n `LivreTheme`,
avec clé composite et suppression en cascade depuis le livre. La forme normalisée réutilise
`NormalisationTexte` et porte un index unique : « Fantasy » et « fantasy » partagent la même
fiche. Le formulaire accepte une liste séparée par des virgules, dédoublonnée avant résolution ;
le premier libellé saisi devient la forme affichée. Les thèmes apparaissent en étiquettes sur la
carte partagée `CarteLivre` et sur la fiche en consultation.
Le filtre par thème n'est **pas inclus dans F2**. Il aurait fallu ajouter une dimension aux deux
implémentations de filtrage (`FiltreLivres` et `FiltreLivresLocal`), au contrat de cache et aux
tests de confrontation ; cette extension est reportée à un lot ultérieur plutôt que d'introduire
un filtre partiel ou divergent.
+6 -4
View File
@@ -239,10 +239,12 @@ L'écran `/souhaits/ajout` rend aujourd'hui une liste plate.
recours quand une donnée paraît fausse. ⚠️ Suppose de **conserver l'identifiant de la notice**
au lookup, ce que le modèle ne fait pas aujourd'hui : ce n'est donc pas un simple lien à
afficher, mais une colonne de plus.
- **F2. Des thèmes en étiquettes** (dark fantasy, science-fiction, space opera…). Grosse maille :
table de tags, saisie, filtre, et la question de la **portée** (commune ? personnelle ?). Une
source automatique est douteuse — le Dublin Core BnF porte `dc:subject`, mais avec un vocabulaire
Rameau qui ne ressemble pas à ces mots-là. À instruire séparément du reste du lot.
- ~~**F2. Des thèmes en étiquettes** (dark fantasy, science-fiction, space opera…).~~ Traité le
2026-08-20 : thèmes communs au foyer dans `Theme`/`LivreTheme`, saisis manuellement et
normalisés pour éviter les doublons. La BnF n'alimente pas ces étiquettes : son `dc:subject`
est en vocabulaire Rameau, inadapté aux mots-clés grand public. Le filtre catalogue est
volontairement reporté ; le formulaire accepte une liste séparée par des virgules et les
thèmes s'affichent sur les cartes et fiches.
- ~~**F3. Renommer un auteur**, la correction se répercutant sur tous ses livres.~~ Traité le
2026-08-20 : le renommage recalcule les formes et propose une fusion confirmable en cas de
collision. ⚠️ Le n-n est déjà
+28
View File
@@ -11,6 +11,10 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
public DbSet<LivreAuteur> LivreAuteurs => Set<LivreAuteur>();
public DbSet<Theme> Themes => Set<Theme>();
public DbSet<LivreTheme> LivreThemes => Set<LivreTheme>();
public DbSet<StatutLecture> StatutsLecture => Set<StatutLecture>();
public DbSet<RapprochementRefuse> RapprochementsRefuses => Set<RapprochementRefuse>();
@@ -94,6 +98,30 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
lien.HasIndex(la => la.AuteurId);
});
modelBuilder.Entity<Theme>(theme =>
{
theme.Property(t => t.Nom).IsRequired();
theme.Property(t => t.NomNormalise).IsRequired();
theme.HasIndex(t => t.NomNormalise).IsUnique();
});
modelBuilder.Entity<LivreTheme>(lien =>
{
lien.HasKey(lt => new { lt.LivreId, lt.ThemeId });
lien.HasOne(lt => lt.Livre)
.WithMany(l => l.Themes)
.HasForeignKey(lt => lt.LivreId)
.OnDelete(DeleteBehavior.Cascade);
lien.HasOne(lt => lt.Theme)
.WithMany(t => t.Livres)
.HasForeignKey(lt => lt.ThemeId)
.OnDelete(DeleteBehavior.Restrict);
lien.HasIndex(lt => lt.ThemeId);
});
modelBuilder.Entity<StatutLecture>(statut =>
{
statut.Property(s => s.Utilisateur).IsRequired();
@@ -0,0 +1,594 @@
// <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("20260820123516_ThemesEtEtiquettes")]
partial class ThemesEtEtiquettes
{
/// <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.BibliographieMasquee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur", "AuteurId");
b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
.IsUnique();
b.ToTable("BibliographiesMasquees");
});
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.Property<string>("UrlNotice")
.HasColumnType("TEXT");
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.LivreTheme", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("ThemeId")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "ThemeId");
b.HasIndex("ThemeId");
b.ToTable("LivreThemes");
});
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.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NomNormalise")
.IsUnique();
b.ToTable("Themes");
});
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.LivreTheme", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Themes")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Theme", "Theme")
.WithMany("Livres")
.HasForeignKey("ThemeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Theme");
});
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");
b.Navigation("Themes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Navigation("Numeros");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Navigation("Elements");
b.Navigation("SousSeries");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
{
b.Navigation("Livres");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <inheritdoc />
public partial class ThemesEtEtiquettes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Themes",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Nom = table.Column<string>(type: "TEXT", nullable: false),
NomNormalise = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Themes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "LivreThemes",
columns: table => new
{
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
ThemeId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LivreThemes", x => new { x.LivreId, x.ThemeId });
table.ForeignKey(
name: "FK_LivreThemes_Livres_LivreId",
column: x => x.LivreId,
principalTable: "Livres",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_LivreThemes_Themes_ThemeId",
column: x => x.ThemeId,
principalTable: "Themes",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_LivreThemes_ThemeId",
table: "LivreThemes",
column: "ThemeId");
migrationBuilder.CreateIndex(
name: "IX_Themes_NomNormalise",
table: "Themes",
column: "NomNormalise",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LivreThemes");
migrationBuilder.DropTable(
name: "Themes");
}
}
}
@@ -230,6 +230,21 @@ namespace MaBibli.Api.Data.Migrations
b.ToTable("LivresSouhaites");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("ThemeId")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "ThemeId");
b.HasIndex("ThemeId");
b.ToTable("LivreThemes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Property<int>("Id")
@@ -417,6 +432,28 @@ namespace MaBibli.Api.Data.Migrations
b.ToTable("StatutsLecture");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NomNormalise")
.IsUnique();
b.ToTable("Themes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
@@ -454,6 +491,25 @@ namespace MaBibli.Api.Data.Migrations
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Themes")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Theme", "Theme")
.WithMany("Livres")
.HasForeignKey("ThemeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Theme");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
@@ -509,6 +565,8 @@ namespace MaBibli.Api.Data.Migrations
b.Navigation("Prets");
b.Navigation("Statuts");
b.Navigation("Themes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
@@ -522,6 +580,11 @@ namespace MaBibli.Api.Data.Migrations
b.Navigation("SousSeries");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
{
b.Navigation("Livres");
});
#pragma warning restore 612, 618
}
}
@@ -89,6 +89,14 @@ public sealed class ServiceRenormalisation(MaBibliDbContext db, ILogger<ServiceR
n => n.RecalculerFormes(),
n => $"le numéro {n.Numero}");
var themes = await db.Themes.OrderBy(t => t.Id).ToListAsync(ct);
corriges += Renormaliser(
themes,
t => t.NomNormalise,
t => NormalisationTexte.Normaliser(t.Nom),
t => t.RecalculerFormes(),
t => $"le thème « {t.Nom} »");
if (corriges > 0)
{
await db.SaveChangesAsync(ct);
@@ -72,7 +72,9 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
// 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().Include(l => l.Auteurs).ThenInclude(la => la.Auteur),
db.Livres.AsNoTracking()
.Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
.Include(l => l.Themes).ThenInclude(lt => lt.Theme),
criteres,
utilisateur);
@@ -92,6 +94,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
var livre = await db.Livres
.AsNoTracking()
.Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
.Include(l => l.Themes).ThenInclude(lt => lt.Theme)
.FirstOrDefaultAsync(l => l.Id == id, ct);
if (livre is null)
@@ -143,6 +146,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
db.Livres.Add(livre);
await RattacherAuteursAsync(livre, saisie.Auteurs, ct);
await RattacherThemesAsync(livre, saisie.Themes, ct);
await db.SaveChangesAsync(ct);
// Le statut n'est écrit que si l'utilisateur en a explicitement posé un : un livre sans
@@ -155,7 +159,10 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
public async Task<ResultatEcriture> ModifierAsync(
int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default)
{
var livre = await db.Livres.Include(l => l.Auteurs).FirstOrDefaultAsync(l => l.Id == id, ct);
var livre = await db.Livres
.Include(l => l.Auteurs)
.Include(l => l.Themes).ThenInclude(lt => lt.Theme)
.FirstOrDefaultAsync(l => l.Id == id, ct);
if (livre is null)
{
return ResultatEcriture.Introuvable;
@@ -179,6 +186,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
// d'origine, pas des champs éditables.
await RattacherAuteursAsync(livre, saisie.Auteurs, ct);
await RattacherThemesAsync(livre, saisie.Themes, ct);
await db.SaveChangesAsync(ct);
// Le statut modifié est celui de la personne qui édite, pas de celle qui a saisi le livre.
@@ -287,7 +295,9 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
}
private IQueryable<Livre> AvecAuteurs() =>
db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur);
db.Livres.AsNoTracking()
.Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
.Include(l => l.Themes).ThenInclude(lt => lt.Theme);
/// <summary>Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ?</summary>
/// <remarks>Le rôle n'entre pas dans la comparaison : c'est la personne qui compte ici.</remarks>
@@ -444,6 +454,37 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
}
}
private async Task RattacherThemesAsync(
Livre livre, IReadOnlyList<string> saisis, CancellationToken ct)
{
var themes = saisis
.Where(t => !string.IsNullOrWhiteSpace(t))
.Select(t => t.Trim())
.GroupBy(NormalisationTexte.Normaliser, StringComparer.Ordinal)
.Select(g => g.First())
.ToList();
if (livre.Themes.Count > 0)
{
db.LivreThemes.RemoveRange(livre.Themes);
livre.Themes.Clear();
}
foreach (var nom in themes)
{
var normalise = NormalisationTexte.Normaliser(nom);
var theme = await db.Themes.FirstOrDefaultAsync(t => t.NomNormalise == normalise, ct);
if (theme is null)
{
theme = new Theme { Nom = nom };
theme.RecalculerFormes();
db.Themes.Add(theme);
}
livre.Themes.Add(new LivreTheme { Livre = livre, Theme = theme });
}
}
/// <summary>
/// Contrôles minimaux : un titre, et un ISBN qui tienne debout s'il est fourni.
/// </summary>
@@ -491,6 +532,11 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
.Where(la => la.Auteur is not null)
.Select(la => new AuteurDto { Id = la.Auteur!.Id, Nom = la.Auteur.Nom, Role = la.Role })
.ToList(),
Themes = livre.Themes
.Where(lt => lt.Theme is not null)
.Select(lt => lt.Theme!.Nom)
.OrderBy(nom => NormalisationTexte.Normaliser(nom), StringComparer.Ordinal)
.ToList(),
Editeur = livre.Editeur,
Format = livre.Format,
TypeDocument = livre.TypeDocument,
@@ -31,6 +31,10 @@
@Libelles.TypeDocument(Livre.TypeDocument)
</span>
}
@foreach (var theme in Livre.Themes)
{
<span class="etiquette">@theme</span>
}
@if (Livre.Statut is { } statut)
{
<span class="etiquette etiquette-statut @Libelles.ClasseStatut(statut)">
@@ -69,6 +69,12 @@
autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Thèmes <span class="champ-aide">(séparés par des virgules)</span></span>
<input class="champ-saisie" type="text" @bind="Saisie.ThemesTexte" @bind:event="oninput"
placeholder="Fantasy, aventure, space opera" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">ISBN <span class="champ-aide">(facultatif)</span></span>
<input class="champ-saisie" type="text" inputmode="numeric" @bind="Saisie.Isbn" @bind:event="oninput"
+14
View File
@@ -150,6 +150,19 @@ else
<div><dt>Type</dt><dd>@Libelles.TypeDocument(_livre.TypeDocument)</dd></div>
}
@if (_livre.Themes.Count > 0)
{
<div>
<dt>Thèmes</dt>
<dd class="etiquettes">
@foreach (var theme in _livre.Themes)
{
<span class="etiquette">@theme</span>
}
</dd>
</div>
}
@if (!string.IsNullOrWhiteSpace(_livre.UrlNotice))
{
<div><dt>Source</dt><dd><a href="@_livre.UrlNotice" target="_blank" rel="noopener noreferrer">Voir la notice source</a></dd></div>
@@ -332,6 +345,7 @@ else
Editeur = livre.Editeur,
Format = livre.Format,
TypeDocument = livre.TypeDocument,
Themes = livre.Themes.ToList(),
Statut = livre.Statut,
CoverUrl = livre.CoverUrl,
UrlNotice = livre.UrlNotice,
@@ -31,6 +31,9 @@ public record EnregistrementLivre
/// </summary>
public List<AuteurSaisi> Auteurs { get; set; } = [];
/// <summary>Thèmes saisis manuellement, séparés par des virgules dans l'interface.</summary>
public List<string> Themes { get; set; } = [];
public string? Editeur { get; set; }
public Format Format { get; set; }
@@ -67,6 +70,13 @@ public record EnregistrementLivre
set => Auteurs = Decouper(value, Auteurs);
}
[JsonIgnore]
public string? ThemesTexte
{
get => Themes.Count == 0 ? null : string.Join(", ", Themes);
set => Themes = DecouperThemes(value);
}
/// <summary>
/// Découpe une saisie texte en auteurs, en écartant les entrées vides.
/// </summary>
@@ -99,6 +109,20 @@ public record EnregistrementLivre
nom, roles.GetValueOrDefault(NormalisationTexte.Normaliser(nom), RoleAuteur.NonPrecise)))
.ToList();
}
public static List<string> DecouperThemes(string? saisie)
{
if (string.IsNullOrWhiteSpace(saisie))
{
return [];
}
var vus = new HashSet<string>(StringComparer.Ordinal);
return saisie
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(theme => vus.Add(NormalisationTexte.Normaliser(theme)))
.ToList();
}
}
/// <summary>Un auteur tel qu'il est saisi : son nom, et son rôle sur ce livre.</summary>
+2
View File
@@ -37,6 +37,8 @@ public record LivreDto
/// <summary>Auteurs, dans l'ordre de la couverture. Peut être vide.</summary>
public IReadOnlyList<AuteurDto> Auteurs { get; init; } = [];
public IReadOnlyList<string> Themes { get; init; } = [];
public string? Editeur { get; init; }
public required Format Format { get; init; }
+6
View File
@@ -56,6 +56,12 @@ public class Livre
/// <summary>Auteurs du livre, ordonnés par <see cref="LivreAuteur.Position"/>.</summary>
public List<LivreAuteur> Auteurs { get; set; } = [];
/// <summary>
/// Thèmes du livre. Ils décrivent l'œuvre et sont donc communs au foyer, contrairement aux
/// statuts de lecture et aux envies.
/// </summary>
public List<LivreTheme> Themes { get; set; } = [];
/// <summary>
/// Statuts de lecture, <b>un par personne</b> et seulement pour celles qui en ont posé un.
/// </summary>
+33
View File
@@ -0,0 +1,33 @@
using MaBibli.Shared.Textes;
namespace MaBibli.Shared.Entites;
/// <summary>Étiquette thématique commune au catalogue.</summary>
public class Theme
{
public int Id { get; set; }
public string Nom { get; set; } = string.Empty;
public string NomNormalise { get; set; } = string.Empty;
public List<LivreTheme> Livres { get; set; } = [];
public void RecalculerFormes()
{
Nom = Nom.Trim();
NomNormalise = NormalisationTexte.Normaliser(Nom);
}
}
/// <summary>Lien entre un livre et une étiquette thématique.</summary>
public class LivreTheme
{
public int LivreId { get; set; }
public Livre? Livre { get; set; }
public int ThemeId { get; set; }
public Theme? Theme { get; set; }
}
+30 -1
View File
@@ -39,15 +39,44 @@ public class ServiceCatalogueTests : IDisposable
string? auteur = null,
string? isbn = null,
Format format = Format.Physique,
Statut? statut = null) => new()
Statut? statut = null,
params string[] themes) => new()
{
Titre = titre,
Auteur = auteur,
Isbn = isbn,
Format = format,
Statut = statut,
Themes = themes.ToList(),
};
[Fact]
public async Task Les_themes_sont_communs_et_normalises()
{
var premier = (await _service.CreerAsync(
Saisie("Dune", themes: ["Science-fiction", "Dark Fantasy"]), "mathieu")).Livre!;
var second = (await _service.CreerAsync(
Saisie("Fondation", themes: ["science-fiction", "dark fantasy"]), "camille")).Livre!;
Assert.Equal(["Dark Fantasy", "Science-fiction"], premier.Themes);
Assert.Equal(["Dark Fantasy", "Science-fiction"], second.Themes);
Assert.Equal(2, await _db.Themes.CountAsync());
Assert.Equal(4, await _db.LivreThemes.CountAsync());
}
[Fact]
public async Task Modifier_remplace_les_themes_du_livre()
{
var livre = (await _service.CreerAsync(
Saisie("Dune", themes: ["Science-fiction", "Space opera"]), "mathieu")).Livre!;
var modifie = await _service.ModifierAsync(
livre.Id, Saisie("Dune", themes: ["space OPERA", "Aventure"]), "mathieu");
Assert.Equal(["Aventure", "Space opera"], modifie.Livre!.Themes);
Assert.Equal(2, await _db.LivreThemes.CountAsync());
}
[Fact]
public async Task Creer_enregistre_le_livre_et_trace_lauteur_de_la_saisie()
{
+27
View File
@@ -0,0 +1,27 @@
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
namespace MaBibli.Tests;
public class ThemesTests
{
[Fact]
public void La_saisie_des_themes_separe_par_virgules_et_dedoublonne_sans_accents()
{
var themes = EnregistrementLivre.DecouperThemes(
"Dark Fantasy, science-fiction, dark fantasy, , SCIENCE FICTION");
Assert.Equal(["Dark Fantasy", "science-fiction"], themes);
}
[Fact]
public void Un_theme_recalcule_sa_forme_normalisee()
{
var theme = new Theme { Nom = " Cœur d'aventure " };
theme.RecalculerFormes();
Assert.Equal("Cœur d'aventure", theme.Nom);
Assert.Equal("coeur d aventure", theme.NomNormalise);
}
}