diff --git a/CLAUDE.md b/CLAUDE.md index bc52d87..377e9a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/IDEES.md b/IDEES.md index 44d746b..ccbf854 100644 --- a/IDEES.md +++ b/IDEES.md @@ -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à diff --git a/MaBibli.Api/Data/MaBibliDbContext.cs b/MaBibli.Api/Data/MaBibliDbContext.cs index a0754bf..442c735 100644 --- a/MaBibli.Api/Data/MaBibliDbContext.cs +++ b/MaBibli.Api/Data/MaBibliDbContext.cs @@ -11,6 +11,10 @@ public class MaBibliDbContext(DbContextOptions options) : DbCo public DbSet LivreAuteurs => Set(); + public DbSet Themes => Set(); + + public DbSet LivreThemes => Set(); + public DbSet StatutsLecture => Set(); public DbSet RapprochementsRefuses => Set(); @@ -94,6 +98,30 @@ public class MaBibliDbContext(DbContextOptions options) : DbCo lien.HasIndex(la => la.AuteurId); }); + modelBuilder.Entity(theme => + { + theme.Property(t => t.Nom).IsRequired(); + theme.Property(t => t.NomNormalise).IsRequired(); + theme.HasIndex(t => t.NomNormalise).IsUnique(); + }); + + modelBuilder.Entity(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(statut => { statut.Property(s => s.Utilisateur).IsRequired(); diff --git a/MaBibli.Api/Data/Migrations/20260820123516_ThemesEtEtiquettes.Designer.cs b/MaBibli.Api/Data/Migrations/20260820123516_ThemesEtEtiquettes.Designer.cs new file mode 100644 index 0000000..1c324e1 --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260820123516_ThemesEtEtiquettes.Designer.cs @@ -0,0 +1,594 @@ +// +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 + { + /// + 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.BibliographieMasquee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("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.Property("UrlNotice") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Isbn"); + + b.HasIndex("TitreNormalise"); + + b.ToTable("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.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.LivreTheme", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "ThemeId"); + + b.HasIndex("ThemeId"); + + b.ToTable("LivreThemes"); + }); + + 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.Theme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Nom") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/MaBibli.Api/Data/Migrations/20260820123516_ThemesEtEtiquettes.cs b/MaBibli.Api/Data/Migrations/20260820123516_ThemesEtEtiquettes.cs new file mode 100644 index 0000000..c5a4e37 --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260820123516_ThemesEtEtiquettes.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + /// + public partial class ThemesEtEtiquettes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Themes", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Nom = table.Column(type: "TEXT", nullable: false), + NomNormalise = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Themes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "LivreThemes", + columns: table => new + { + LivreId = table.Column(type: "INTEGER", nullable: false), + ThemeId = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "LivreThemes"); + + migrationBuilder.DropTable( + name: "Themes"); + } + } +} diff --git a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs index 9c434a7..4d46347 100644 --- a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs +++ b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs @@ -230,6 +230,21 @@ namespace MaBibli.Api.Data.Migrations b.ToTable("LivresSouhaites"); }); + modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "ThemeId"); + + b.HasIndex("ThemeId"); + + b.ToTable("LivreThemes"); + }); + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => { b.Property("Id") @@ -417,6 +432,28 @@ namespace MaBibli.Api.Data.Migrations b.ToTable("StatutsLecture"); }); + modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Nom") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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 } } diff --git a/MaBibli.Api/Data/ServiceRenormalisation.cs b/MaBibli.Api/Data/ServiceRenormalisation.cs index 95cb8c6..7ebee62 100644 --- a/MaBibli.Api/Data/ServiceRenormalisation.cs +++ b/MaBibli.Api/Data/ServiceRenormalisation.cs @@ -89,6 +89,14 @@ public sealed class ServiceRenormalisation(MaBibliDbContext db, ILogger 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); diff --git a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs index d62dc83..f6c0e5f 100644 --- a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs +++ b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs @@ -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 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 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); /// Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ? /// Le rôle n'entre pas dans la comparaison : c'est la personne qui compte ici. @@ -444,6 +454,37 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur } } + private async Task RattacherThemesAsync( + Livre livre, IReadOnlyList 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 }); + } + } + /// /// Contrôles minimaux : un titre, et un ISBN qui tienne debout s'il est fourni. /// @@ -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, diff --git a/MaBibli.Client/Composants/CarteLivre.razor b/MaBibli.Client/Composants/CarteLivre.razor index 32c9b4b..b50bc30 100644 --- a/MaBibli.Client/Composants/CarteLivre.razor +++ b/MaBibli.Client/Composants/CarteLivre.razor @@ -31,6 +31,10 @@ @Libelles.TypeDocument(Livre.TypeDocument) } + @foreach (var theme in Livre.Themes) + { + @theme + } @if (Livre.Statut is { } statut) { diff --git a/MaBibli.Client/Composants/FormulaireLivre.razor b/MaBibli.Client/Composants/FormulaireLivre.razor index 480ac09..adab0ce 100644 --- a/MaBibli.Client/Composants/FormulaireLivre.razor +++ b/MaBibli.Client/Composants/FormulaireLivre.razor @@ -69,6 +69,12 @@ autocomplete="off" /> + +