From 359f4f59c6f952590cfd76b51d459d67adad28b8 Mon Sep 17 00:00:00 2001 From: LIMONIER Mathieu Date: Thu, 20 Aug 2026 13:42:46 +0200 Subject: [PATCH] =?UTF-8?q?Transforme=20la=20bibliographie=20en=20=C3=A9cr?= =?UTF-8?q?an=20de=20travail=20D1=20=C3=A0=20D5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CLAUDE.md | 23 + IDEES.md | 2 + MaBibli.Api/Data/MaBibliDbContext.cs | 13 + ...20113543_MasquageBibliographie.Designer.cs | 528 ++++++++++++++++++ .../20260820113543_MasquageBibliographie.cs | 47 ++ .../MaBibliDbContextModelSnapshot.cs | 27 + MaBibli.Api/Endpoints/SouhaitsEndpoints.cs | 31 + .../Catalogue/ServiceBibliographie.cs | 80 ++- MaBibli.Client/Pages/Bibliographie.razor | 166 +++++- MaBibli.Client/Services/ServiceLivresApi.cs | 66 ++- MaBibli.Shared/Dtos/SouhaitDto.cs | 9 + .../Entites/BibliographieMasquee.cs | 26 + .../ServiceBibliographieMasquageTests.cs | 46 ++ 13 files changed, 1044 insertions(+), 20 deletions(-) create mode 100644 MaBibli.Api/Data/Migrations/20260820113543_MasquageBibliographie.Designer.cs create mode 100644 MaBibli.Api/Data/Migrations/20260820113543_MasquageBibliographie.cs create mode 100644 MaBibli.Shared/Entites/BibliographieMasquee.cs create mode 100644 MaBibli.Tests/ServiceBibliographieMasquageTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 1d7980c..7cf1c6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1934,3 +1934,26 @@ Depuis une place manquante, un ISBN passe par la cascade BnF puis OpenLibrary ex candidat choisi peut créer un livre du catalogue puis rattacher la place, ou créer une envie avec les métadonnées complètes, notamment l'auteur trouvé par le lookup. Les revues (préfixe `977`) ne font pas partie de ce flux : `ElementSerie.LivreId` ne pointe que vers `Livre`. + +## Lot D — bibliographie écran de travail (2026-08-20) + +La bibliographie conserve maintenant les résultats réussis pendant la session du client. Ce cache +est volontairement **mémoire uniquement**, par auteur et par instance de `ServiceLivresApi` : +une bibliographie dépend de la réponse BnF et des souhaits personnels, et n'est pas un instantané +de la bibliothèque. Une réponse `EtatSourceBibliographie.Ok` remplace toujours l'entrée ; une +réponse `SourceMuette` n'est jamais mise en cache et ne consulte jamais une entrée précédente. +Ainsi un incident BnF ne peut pas figer une bibliographie vide. + +L'écran possède un filtre replié derrière « Filtrer », avec compteur, distinguant tout le fonds, +les œuvres possédées et celles à découvrir. Les œuvres possédées réutilisent `CarteLivre` et +affichent donc le statut de lecture personnel déjà fourni par `LivreDto`. + +Le masquage est une décision **personnelle**, comme la liste d'envies : il exprime qu'une œuvre +n'est pas pertinente pour le lecteur courant, et ne doit pas imposer ce choix au foyer. La table +`BibliographiesMasquees` est indexée par utilisateur, auteur et clé d'œuvre ; le compteur visible +et le filtre « Afficher les masquées » empêchent tout masquage silencieux. Masquer est une +exception explicite à la règle générale « griser, ne jamais masquer ». + +Enfin, plusieurs œuvres non possédées peuvent être cochées puis ajoutées aux envies en une seule +action d'écran. Chaque ajout reprend le même rapprochement et les mêmes validations que l'ajout +unitaire ; les doublons sont rapportés sans effacer la sélection des autres œuvres. diff --git a/IDEES.md b/IDEES.md index 78649e7..07a6430 100644 --- a/IDEES.md +++ b/IDEES.md @@ -187,6 +187,8 @@ Le lot le plus structurant, et le seul qui touche au modèle. ## Lot D — La bibliographie devient un écran de travail +✅ Traité le 2026-08-20 : voir `CLAUDE.md`, section « Lot D — bibliographie écran de travail ». + Aujourd'hui elle se consulte ; les cinq items la font servir à **constituer** une liste. - **D1. Mettre en cache une bibliographie déjà demandée.** Elle coûte ~0,85 s par page, deux diff --git a/MaBibli.Api/Data/MaBibliDbContext.cs b/MaBibli.Api/Data/MaBibliDbContext.cs index 243f471..3d7aaf4 100644 --- a/MaBibli.Api/Data/MaBibliDbContext.cs +++ b/MaBibli.Api/Data/MaBibliDbContext.cs @@ -24,6 +24,8 @@ public class MaBibliDbContext(DbContextOptions options) : DbCo /// public DbSet LivresSouhaites => Set(); + public DbSet BibliographiesMasquees => Set(); + /// /// Sagas, cycles et séries. Communs au foyer, comme le catalogue : l'ordre de lecture /// est une propriété de l'œuvre, pas du lecteur. @@ -137,6 +139,17 @@ public class MaBibliDbContext(DbContextOptions options) : DbCo .IsUnique(); }); + modelBuilder.Entity(masquee => + { + masquee.Property(m => m.Utilisateur).IsRequired(); + masquee.Property(m => m.TitreNormalise).IsRequired(); + masquee.HasIndex( + m => new { m.AuteurId, m.Utilisateur, m.TitreNormalise }, + "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre") + .IsUnique(); + masquee.HasIndex(m => new { m.Utilisateur, m.AuteurId }); + }); + modelBuilder.Entity(serie => { serie.Property(s => s.Titre).IsRequired(); diff --git a/MaBibli.Api/Data/Migrations/20260820113543_MasquageBibliographie.Designer.cs b/MaBibli.Api/Data/Migrations/20260820113543_MasquageBibliographie.Designer.cs new file mode 100644 index 0000000..cc45834 --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260820113543_MasquageBibliographie.Designer.cs @@ -0,0 +1,528 @@ +// +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("20260820113543_MasquageBibliographie")] + partial class MasquageBibliographie + { + /// + 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.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/20260820113543_MasquageBibliographie.cs b/MaBibli.Api/Data/Migrations/20260820113543_MasquageBibliographie.cs new file mode 100644 index 0000000..c72448e --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260820113543_MasquageBibliographie.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + /// + public partial class MasquageBibliographie : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BibliographiesMasquees", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + AuteurId = table.Column(type: "INTEGER", nullable: false), + Utilisateur = table.Column(type: "TEXT", nullable: false), + TitreNormalise = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BibliographiesMasquees", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre", + table: "BibliographiesMasquees", + columns: new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_BibliographiesMasquees_Utilisateur_AuteurId", + table: "BibliographiesMasquees", + columns: new[] { "Utilisateur", "AuteurId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BibliographiesMasquees"); + } + } +} diff --git a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs index e2db085..325589b 100644 --- a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs +++ b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs @@ -45,6 +45,33 @@ namespace MaBibli.Api.Data.Migrations 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") diff --git a/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs b/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs index cbf0a10..dc35248 100644 --- a/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs +++ b/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs @@ -180,6 +180,37 @@ public static class SouhaitsEndpoints .Produces() .Produces(StatusCodes.Status404NotFound); + routes.MapPost("/api/auteurs/{id:int}/bibliographie/masque", async ( + int id, + MasquageBibliographie saisie, + IServiceBibliographie service, + IFournisseurUtilisateur utilisateurs, + CancellationToken ct) => + { + var fait = await service.MasquerAsync( + id, saisie.Titre, utilisateurs.Obtenir().Identifiant, ct); + return fait ? Results.NoContent() : Results.BadRequest(); + }) + .WithName("MasquerOeuvreBibliographie") + .WithSummary("Masque une œuvre de la bibliographie pour l'appelant.") + .Produces(StatusCodes.Status204NoContent); + + routes.MapDelete("/api/auteurs/{id:int}/bibliographie/masque", async ( + int id, + string titre, + IServiceBibliographie service, + IFournisseurUtilisateur utilisateurs, + CancellationToken ct) => + { + var fait = await service.DemasquerAsync( + id, titre, utilisateurs.Obtenir().Identifiant, ct); + return fait ? Results.NoContent() : Results.NotFound(); + }) + .WithName("DemasquerOeuvreBibliographie") + .WithSummary("Réaffiche une œuvre masquée de la bibliographie.") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound); + return routes; } } diff --git a/MaBibli.Api/Services/Catalogue/ServiceBibliographie.cs b/MaBibli.Api/Services/Catalogue/ServiceBibliographie.cs index 862e365..8875eec 100644 --- a/MaBibli.Api/Services/Catalogue/ServiceBibliographie.cs +++ b/MaBibli.Api/Services/Catalogue/ServiceBibliographie.cs @@ -1,6 +1,7 @@ using MaBibli.Api.Data; using MaBibli.Api.Services.Isbn; using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; using MaBibli.Shared.Textes; using Microsoft.EntityFrameworkCore; @@ -13,6 +14,10 @@ public interface IServiceBibliographie /// l'appelant souhaite déjà. null si l'auteur n'existe pas. /// Task ObtenirAsync(int auteurId, string? utilisateur, CancellationToken ct = default); + + Task MasquerAsync(int auteurId, string titre, string? utilisateur, CancellationToken ct = default); + + Task DemasquerAsync(int auteurId, string titre, string? utilisateur, CancellationToken ct = default); } /// @@ -73,9 +78,10 @@ public sealed class ServiceBibliographie( } var souhaits = await SouhaitsAsync(utilisateur, ct); + var masquees = await MasqueesAsync(auteurId, utilisateur, ct); var oeuvres = Regrouper(resultat) - .Select(o => Confronter(o, parTitre, parIsbn, souhaits)) + .Select(o => Confronter(o, parTitre, parIsbn, souhaits, masquees)) .OrderByDescending(o => AnneeTriable(o.Annee)) .ThenBy(o => o.Titre, StringComparer.CurrentCultureIgnoreCase) .ToList(); @@ -95,6 +101,58 @@ public sealed class ServiceBibliographie( }; } + public async Task MasquerAsync( + int auteurId, string titre, string? utilisateur, CancellationToken ct = default) + { + if (utilisateur is null) + { + return false; + } + + var cle = CleOeuvre.Cle(titre); + if (cle.Length == 0) + { + return false; + } + + var existe = await db.BibliographiesMasquees.AnyAsync( + m => m.AuteurId == auteurId && m.Utilisateur == utilisateur && m.TitreNormalise == cle, ct); + if (existe) + { + return true; + } + + db.BibliographiesMasquees.Add(new BibliographieMasquee + { + AuteurId = auteurId, + Utilisateur = utilisateur, + TitreNormalise = cle, + }); + await db.SaveChangesAsync(ct); + return true; + } + + public async Task DemasquerAsync( + int auteurId, string titre, string? utilisateur, CancellationToken ct = default) + { + if (utilisateur is null) + { + return false; + } + + var cle = CleOeuvre.Cle(titre); + var ligne = await db.BibliographiesMasquees.FirstOrDefaultAsync( + m => m.AuteurId == auteurId && m.Utilisateur == utilisateur && m.TitreNormalise == cle, ct); + if (ligne is null) + { + return false; + } + + db.BibliographiesMasquees.Remove(ligne); + await db.SaveChangesAsync(ct); + return true; + } + /// Clés d'œuvre déjà souhaitées par l'appelant, avec l'identifiant de l'envie. private async Task> SouhaitsAsync(string? utilisateur, CancellationToken ct) { @@ -118,6 +176,22 @@ public sealed class ServiceBibliographie( return index; } + private async Task> MasqueesAsync( + int auteurId, string? utilisateur, CancellationToken ct) + { + if (utilisateur is null) + { + return []; + } + + var cles = await db.BibliographiesMasquees + .AsNoTracking() + .Where(m => m.AuteurId == auteurId && m.Utilisateur == utilisateur) + .Select(m => m.TitreNormalise) + .ToListAsync(ct); + return cles.ToHashSet(StringComparer.Ordinal); + } + /// /// Réunit les rééditions sous une seule œuvre. /// @@ -158,7 +232,8 @@ public sealed class ServiceBibliographie( OeuvreBibliographie oeuvre, IReadOnlyDictionary parTitre, IReadOnlyDictionary parIsbn, - IReadOnlyDictionary souhaits) + IReadOnlyDictionary souhaits, + IReadOnlySet masquees) { var cle = CleOeuvre.Cle(oeuvre.Titre); @@ -179,6 +254,7 @@ public sealed class ServiceBibliographie( LivreId = livreId, Souhaite = souhaitId is not null, SouhaitId = souhaitId, + Masquee = masquees.Contains(cle), }; } diff --git a/MaBibli.Client/Pages/Bibliographie.razor b/MaBibli.Client/Pages/Bibliographie.razor index 0e641dd..05b47c8 100644 --- a/MaBibli.Client/Pages/Bibliographie.razor +++ b/MaBibli.Client/Pages/Bibliographie.razor @@ -86,6 +86,10 @@ else if (_bibliographie is { } biblio)

@biblio.Oeuvres.Count œuvre@(biblio.Oeuvres.Count > 1 ? "s" : "") — @_possedees déjà chez vous + @if (_masquees > 0) + { + — @_masquees masquée@(_masquees > 1 ? "s" : "") + }

@* Deux phrases distinctes plutôt qu'une seule à trous : enchaîner des fragments @@ -124,16 +128,37 @@ else if (_bibliographie is { } biblio) } - @* Filtre sur une seule question : « qu'est-ce qui me manque ? ». Les livres possédés ne - sont jamais masqués par défaut — le grisage est l'information principale de l'écran. *@ -
-
- - -
+
+
+ @if (_filtresOuverts) + { +
+
+ + + +
+ @if (_masquees > 0) + { +
+ + +
+ } +
+ }
    @foreach (var oeuvre in Affichees(biblio)) @@ -204,16 +229,51 @@ else if (_bibliographie is { } biblio) @if (oeuvre.ADecouvrir) { - +
+ } + + @if (!oeuvre.Masquee) + { + + } + else + { + } } + @if (_selection.Count > 0) + { +
+ +
+ } + @* Le rapprochement est fait par titre, et il rate des choses (voir CleOeuvre). Le dire franchement vaut mieux que laisser l'utilisateur découvrir seul qu'un livre qu'il possède n'est pas grisé. *@ @@ -239,7 +299,9 @@ else if (_bibliographie is { } biblio) public int AuteurId { get; set; } private BibliographieDto? _bibliographie; - private bool _seulementManquants; + private int _modePossession; + private bool _filtresOuverts; + private bool _afficherMasquees; private bool _enCours; private bool _chargement; @@ -250,13 +312,22 @@ else if (_bibliographie is { } biblio) private string? _message; private int _possedees; + private int _masquees; private IReadOnlyList _livresCatalogue = []; private readonly HashSet _oeuvresOuvertes = []; + private readonly HashSet _selection = []; protected override async Task OnParametersSetAsync() => await ChargerAsync(); private IEnumerable Affichees(BibliographieDto biblio) => - _seulementManquants ? biblio.Oeuvres.Where(o => o.ADecouvrir) : biblio.Oeuvres; + biblio.Oeuvres.Where(o => + (_afficherMasquees || !o.Masquee) + && (_modePossession == 0 + || (_modePossession == 1 && o.Possede) + || (_modePossession == 2 && o.ADecouvrir))); + + private int NombreFiltres => + (_modePossession == 0 ? 0 : 1) + (_masquees > 0 && !_afficherMasquees ? 1 : 0); private async Task ChargerAsync() { @@ -278,6 +349,7 @@ else if (_bibliographie is { } biblio) } _possedees = _bibliographie.Oeuvres.Count(o => o.Possede); + _masquees = _bibliographie.Oeuvres.Count(o => o.Masquee); } catch (Exception) { @@ -300,6 +372,70 @@ else if (_bibliographie is { } biblio) } } + private void Selectionner(string titre, bool selectionnee) + { + if (selectionnee) + { + _selection.Add(titre); + } + else + { + _selection.Remove(titre); + } + } + + private async Task AjouterSelectionAsync() + { + if (_bibliographie is null) + { + return; + } + + var saisies = _bibliographie.Oeuvres + .Where(o => _selection.Contains(o.Titre) && o.ADecouvrir) + .Select(o => new EnregistrementSouhait + { + Titre = o.Titre, + Auteur = _bibliographie.Auteur.Nom, + Editeur = o.Editeur, + Annee = o.Annee, + Isbn = o.Isbn, + }) + .ToList(); + + _enCours = true; + _message = null; + var resultats = await Api.AjouterSouhaitsAsync(saisies); + _enCours = false; + var erreurs = resultats.Where(r => !r.EstOk).Select(r => r.Erreur).Where(e => e is not null).ToList(); + _message = erreurs.Count == 0 ? null : string.Join(" ", erreurs); + _selection.Clear(); + await ChargerAsync(); + } + + private async Task MasquerAsync(OeuvreBibliographie oeuvre) + { + await ModifierMasquageAsync(oeuvre, false); + } + + private async Task DemasquerAsync(OeuvreBibliographie oeuvre) + { + await ModifierMasquageAsync(oeuvre, true); + } + + private async Task ModifierMasquageAsync(OeuvreBibliographie oeuvre, bool demasquer) + { + _enCours = true; + _message = demasquer + ? await Api.DemasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre) + : await Api.MasquerOeuvreBibliographieAsync(AuteurId, oeuvre.Titre); + _enCours = false; + if (_message is null) + { + await ChargerAsync(); + } + } + private void BasculerOeuvre(string titre) { if (!_oeuvresOuvertes.Add(titre)) diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs index 8368504..41f5ce0 100644 --- a/MaBibli.Client/Services/ServiceLivresApi.cs +++ b/MaBibli.Client/Services/ServiceLivresApi.cs @@ -25,6 +25,7 @@ namespace MaBibli.Client.Services; public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, EtatReseau reseau) { private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + private readonly Dictionary _bibliographies = []; /// /// Recharge tous les instantanés depuis le réseau. @@ -615,8 +616,29 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat } public async Task> AjouterSouhaitAsync( - EnregistrementSouhait saisie, CancellationToken ct = default) => - await EcrireAsync(() => http.PostAsJsonAsync("api/souhaits", saisie, Json, ct), ct); + EnregistrementSouhait saisie, CancellationToken ct = default) + { + var resultat = await EcrireAsync( + () => http.PostAsJsonAsync("api/souhaits", saisie, Json, ct), ct); + if (resultat.EstOk) + { + _bibliographies.Clear(); + } + + return resultat; + } + + public async Task>> AjouterSouhaitsAsync( + IReadOnlyList saisies, CancellationToken ct = default) + { + var resultats = new List>(saisies.Count); + foreach (var saisie in saisies) + { + resultats.Add(await AjouterSouhaitAsync(saisie, ct)); + } + + return resultats; + } /// /// Retire une envie. null si c'est fait, sinon le motif à afficher. @@ -735,10 +757,48 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat public async Task ObtenirBibliographieAsync( int auteurId, CancellationToken ct = default) { + if (_bibliographies.TryGetValue(auteurId, out var cachee) + && cachee.Etat == EtatSourceBibliographie.Ok) + { + return cachee; + } + var reponse = await http.GetAsync($"api/auteurs/{auteurId}/bibliographie", ct); - return reponse.StatusCode == HttpStatusCode.NotFound + var bibliographie = reponse.StatusCode == HttpStatusCode.NotFound ? null : await LireAsync(reponse, ct); + if (bibliographie?.Etat == EtatSourceBibliographie.Ok) + { + _bibliographies[auteurId] = bibliographie; + } + + return bibliographie; + } + + public async Task MasquerOeuvreBibliographieAsync( + int auteurId, string titre, CancellationToken ct = default) => + await ModifierBibliographieAsync( + () => http.PostAsJsonAsync( + $"api/auteurs/{auteurId}/bibliographie/masque", + new MasquageBibliographie { Titre = titre }, Json, ct), ct); + + public async Task DemasquerOeuvreBibliographieAsync( + int auteurId, string titre, CancellationToken ct = default) => + await ModifierBibliographieAsync( + () => http.DeleteAsync( + $"api/auteurs/{auteurId}/bibliographie/masque?titre={Uri.EscapeDataString(titre)}"), ct); + + private async Task ModifierBibliographieAsync( + Func> appel, CancellationToken ct) + { + var (reponse, motif) = await EnvoyerAsync(appel); + if (reponse is null) + { + return motif; + } + + _bibliographies.Clear(); + return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct); } // ───────────────────────────────────────────────────────────────────────── diff --git a/MaBibli.Shared/Dtos/SouhaitDto.cs b/MaBibli.Shared/Dtos/SouhaitDto.cs index 1364390..bc5a19d 100644 --- a/MaBibli.Shared/Dtos/SouhaitDto.cs +++ b/MaBibli.Shared/Dtos/SouhaitDto.cs @@ -68,6 +68,12 @@ public record EnregistrementSouhait public string? Note { get; set; } } +/// Charge utile du masquage personnel d'une œuvre bibliographique. +public record MasquageBibliographie +{ + public string Titre { get; set; } = string.Empty; +} + /// Formats d'export de la liste d'envies. /// /// Les deux usages décrits dans IDEES.md ne demandent pas la même chose : « l'emporter en @@ -116,6 +122,9 @@ public record OeuvreBibliographie public int? SouhaitId { get; init; } + /// Vrai si l'appelant a explicitement masqué cette œuvre. + public bool Masquee { get; init; } + /// Ni possédé, ni déjà souhaité : ce qui reste à découvrir. [JsonIgnore] public bool ADecouvrir => !Possede && !Souhaite; diff --git a/MaBibli.Shared/Entites/BibliographieMasquee.cs b/MaBibli.Shared/Entites/BibliographieMasquee.cs new file mode 100644 index 0000000..98955e0 --- /dev/null +++ b/MaBibli.Shared/Entites/BibliographieMasquee.cs @@ -0,0 +1,26 @@ +using MaBibli.Shared.Textes; + +namespace MaBibli.Shared.Entites; + +/// Une œuvre explicitement masquée par un utilisateur dans une bibliographie. +/// +/// Le masquage est personnel : il exprime que cette œuvre n'est pas pertinente pour ce lecteur, +/// pas qu'elle ne l'est pour le foyer. La clé d'œuvre reste celle de la bibliographie BnF. +/// +public class BibliographieMasquee +{ + public int Id { get; set; } + + public int AuteurId { get; set; } + + public string Utilisateur { get; set; } = string.Empty; + + public string TitreNormalise { get; set; } = string.Empty; + + public static BibliographieMasquee Creer(int auteurId, string utilisateur, string titre) => new() + { + AuteurId = auteurId, + Utilisateur = utilisateur, + TitreNormalise = CleOeuvre.Cle(titre), + }; +} diff --git a/MaBibli.Tests/ServiceBibliographieMasquageTests.cs b/MaBibli.Tests/ServiceBibliographieMasquageTests.cs new file mode 100644 index 0000000..3ab800c --- /dev/null +++ b/MaBibli.Tests/ServiceBibliographieMasquageTests.cs @@ -0,0 +1,46 @@ +using MaBibli.Api.Data; +using MaBibli.Api.Services.Catalogue; +using MaBibli.Shared.Textes; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Tests; + +public sealed class ServiceBibliographieMasquageTests : IDisposable +{ + private readonly SqliteConnection _connexion = new("Data Source=:memory:"); + private readonly MaBibliDbContext _db; + private readonly ServiceBibliographie _service; + + public ServiceBibliographieMasquageTests() + { + _connexion.Open(); + var options = new DbContextOptionsBuilder() + .UseSqlite(_connexion) + .Options; + _db = new MaBibliDbContext(options); + _db.Database.EnsureCreated(); + _service = new ServiceBibliographie(_db, null!, new ServiceAuteurs(_db)); + } + + [Fact] + public async Task Le_masquage_est_personnel_et_idempotent() + { + const int auteurId = 42; + + Assert.True(await _service.MasquerAsync(auteurId, "L'Œuvre", "mathieu")); + Assert.True(await _service.MasquerAsync(auteurId, "L'oeuvre", "mathieu")); + + var ligne = await _db.BibliographiesMasquees.SingleAsync(); + Assert.Equal(CleOeuvre.Cle("L'Œuvre"), ligne.TitreNormalise); + Assert.False(await _service.DemasquerAsync(auteurId, "L'Œuvre", "camille")); + Assert.True(await _service.DemasquerAsync(auteurId, "L'oeuvre", "mathieu")); + Assert.Empty(await _db.BibliographiesMasquees.ToListAsync()); + } + + public void Dispose() + { + _db.Dispose(); + _connexion.Dispose(); + } +}