Transforme la bibliographie en écran de travail D1 à D5

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
LIMONIER Mathieu
2026-08-20 13:42:46 +02:00
co-authored by Copilot
parent ddc60898da
commit 359f4f59c6
13 changed files with 1044 additions and 20 deletions
+23
View File
@@ -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 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 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`. 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.
+2
View File
@@ -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 ## 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. 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 - **D1. Mettre en cache une bibliographie déjà demandée.** Elle coûte ~0,85 s par page, deux
+13
View File
@@ -24,6 +24,8 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
/// </summary> /// </summary>
public DbSet<LivreSouhaite> LivresSouhaites => Set<LivreSouhaite>(); public DbSet<LivreSouhaite> LivresSouhaites => Set<LivreSouhaite>();
public DbSet<BibliographieMasquee> BibliographiesMasquees => Set<BibliographieMasquee>();
/// <summary> /// <summary>
/// Sagas, cycles et séries. <b>Communs au foyer</b>, comme le catalogue : l'ordre de lecture /// Sagas, cycles et séries. <b>Communs au foyer</b>, comme le catalogue : l'ordre de lecture
/// est une propriété de l'œuvre, pas du lecteur. /// est une propriété de l'œuvre, pas du lecteur.
@@ -137,6 +139,17 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
.IsUnique(); .IsUnique();
}); });
modelBuilder.Entity<BibliographieMasquee>(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 => modelBuilder.Entity<Serie>(serie =>
{ {
serie.Property(s => s.Titre).IsRequired(); serie.Property(s => s.Titre).IsRequired();
@@ -0,0 +1,528 @@
// <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("20260820113543_MasquageBibliographie")]
partial class MasquageBibliographie
{
/// <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.HasKey("Id");
b.HasIndex("Isbn");
b.HasIndex("TitreNormalise");
b.ToTable("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
b.ToTable("LivreAuteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Annee")
.HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("AuteurNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre")
.IsUnique();
b.ToTable("LivresSouhaites");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateParution")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RevueId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RevueId", "NumeroNormalise")
.IsUnique();
b.ToTable("NumerosRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours")
.IsUnique()
.HasFilter("\"DateRetour\" IS NULL");
b.ToTable("Prets");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurAId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurBId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AuteurAId", "AuteurBId")
.IsUnique();
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TitreNormalise")
.IsUnique();
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
.IsUnique()
.HasFilter("\"Issn\" IS NOT NULL");
b.ToTable("Revues");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("SerieParenteId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SerieParenteId");
b.HasIndex("TitreNormalise")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateMaj")
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur");
b.HasIndex("LivreId", "Utilisateur")
.IsUnique();
b.ToTable("StatutsLecture");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany()
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MaBibli.Shared.Entites.Serie", "Serie")
.WithMany("Elements")
.HasForeignKey("SerieId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Serie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
.WithMany("Livres")
.HasForeignKey("AuteurId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Auteurs")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Auteur");
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
.WithMany("Numeros")
.HasForeignKey("RevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Revue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Prets")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente")
.WithMany("SousSeries")
.HasForeignKey("SerieParenteId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SerieParente");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Statuts")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Navigation("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Navigation("Auteurs");
b.Navigation("Prets");
b.Navigation("Statuts");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Navigation("Numeros");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Navigation("Elements");
b.Navigation("SousSeries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <inheritdoc />
public partial class MasquageBibliographie : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BibliographiesMasquees",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
AuteurId = table.Column<int>(type: "INTEGER", nullable: false),
Utilisateur = table.Column<string>(type: "TEXT", nullable: false),
TitreNormalise = table.Column<string>(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" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BibliographiesMasquees");
}
}
}
@@ -45,6 +45,33 @@ namespace MaBibli.Api.Data.Migrations
b.ToTable("Auteurs"); 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 => modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -180,6 +180,37 @@ public static class SouhaitsEndpoints
.Produces<BibliographieDto>() .Produces<BibliographieDto>()
.Produces(StatusCodes.Status404NotFound); .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; return routes;
} }
} }
@@ -1,6 +1,7 @@
using MaBibli.Api.Data; using MaBibli.Api.Data;
using MaBibli.Api.Services.Isbn; using MaBibli.Api.Services.Isbn;
using MaBibli.Shared.Dtos; using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Textes; using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -13,6 +14,10 @@ public interface IServiceBibliographie
/// l'appelant souhaite déjà. <c>null</c> si l'auteur n'existe pas. /// l'appelant souhaite déjà. <c>null</c> si l'auteur n'existe pas.
/// </summary> /// </summary>
Task<BibliographieDto?> ObtenirAsync(int auteurId, string? utilisateur, CancellationToken ct = default); Task<BibliographieDto?> ObtenirAsync(int auteurId, string? utilisateur, CancellationToken ct = default);
Task<bool> MasquerAsync(int auteurId, string titre, string? utilisateur, CancellationToken ct = default);
Task<bool> DemasquerAsync(int auteurId, string titre, string? utilisateur, CancellationToken ct = default);
} }
/// <summary> /// <summary>
@@ -73,9 +78,10 @@ public sealed class ServiceBibliographie(
} }
var souhaits = await SouhaitsAsync(utilisateur, ct); var souhaits = await SouhaitsAsync(utilisateur, ct);
var masquees = await MasqueesAsync(auteurId, utilisateur, ct);
var oeuvres = Regrouper(resultat) var oeuvres = Regrouper(resultat)
.Select(o => Confronter(o, parTitre, parIsbn, souhaits)) .Select(o => Confronter(o, parTitre, parIsbn, souhaits, masquees))
.OrderByDescending(o => AnneeTriable(o.Annee)) .OrderByDescending(o => AnneeTriable(o.Annee))
.ThenBy(o => o.Titre, StringComparer.CurrentCultureIgnoreCase) .ThenBy(o => o.Titre, StringComparer.CurrentCultureIgnoreCase)
.ToList(); .ToList();
@@ -95,6 +101,58 @@ public sealed class ServiceBibliographie(
}; };
} }
public async Task<bool> 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<bool> 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;
}
/// <summary>Clés d'œuvre déjà souhaitées par l'appelant, avec l'identifiant de l'envie.</summary> /// <summary>Clés d'œuvre déjà souhaitées par l'appelant, avec l'identifiant de l'envie.</summary>
private async Task<Dictionary<string, int>> SouhaitsAsync(string? utilisateur, CancellationToken ct) private async Task<Dictionary<string, int>> SouhaitsAsync(string? utilisateur, CancellationToken ct)
{ {
@@ -118,6 +176,22 @@ public sealed class ServiceBibliographie(
return index; return index;
} }
private async Task<HashSet<string>> 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);
}
/// <summary> /// <summary>
/// Réunit les rééditions sous une seule œuvre. /// Réunit les rééditions sous une seule œuvre.
/// </summary> /// </summary>
@@ -158,7 +232,8 @@ public sealed class ServiceBibliographie(
OeuvreBibliographie oeuvre, OeuvreBibliographie oeuvre,
IReadOnlyDictionary<string, int> parTitre, IReadOnlyDictionary<string, int> parTitre,
IReadOnlyDictionary<string, int> parIsbn, IReadOnlyDictionary<string, int> parIsbn,
IReadOnlyDictionary<string, int> souhaits) IReadOnlyDictionary<string, int> souhaits,
IReadOnlySet<string> masquees)
{ {
var cle = CleOeuvre.Cle(oeuvre.Titre); var cle = CleOeuvre.Cle(oeuvre.Titre);
@@ -179,6 +254,7 @@ public sealed class ServiceBibliographie(
LivreId = livreId, LivreId = livreId,
Souhaite = souhaitId is not null, Souhaite = souhaitId is not null,
SouhaitId = souhaitId, SouhaitId = souhaitId,
Masquee = masquees.Contains(cle),
}; };
} }
+151 -15
View File
@@ -86,6 +86,10 @@ else if (_bibliographie is { } biblio)
<p class="compteur"> <p class="compteur">
@biblio.Oeuvres.Count œuvre@(biblio.Oeuvres.Count > 1 ? "s" : "") @biblio.Oeuvres.Count œuvre@(biblio.Oeuvres.Count > 1 ? "s" : "")
— @_possedees déjà chez vous — @_possedees déjà chez vous
@if (_masquees > 0)
{
<text> — @_masquees masquée@(_masquees > 1 ? "s" : "")</text>
}
</p> </p>
@* Deux phrases distinctes plutôt qu'une seule à trous : enchaîner des fragments @* Deux phrases distinctes plutôt qu'une seule à trous : enchaîner des fragments
@@ -124,16 +128,37 @@ else if (_bibliographie is { } biblio)
<p class="message-erreur" role="alert">@_message</p> <p class="message-erreur" role="alert">@_message</p>
} }
@* Filtre sur une seule question : « qu'est-ce qui me manque ? ». Les livres possédés ne <div class="actions-formulaire">
sont jamais masqués par défaut — le grisage est l'information principale de l'écran. *@ <button type="button" class="bouton bouton-discret"
<div class="filtres"> @onclick="() => _filtresOuverts = !_filtresOuverts"
<div class="segments" role="group" aria-label="Filtrer la bibliographie"> aria-expanded="@_filtresOuverts">
<button type="button" class="segment @(_seulementManquants ? null : "segment-actif")" Filtrer (@NombreFiltres)
@onclick="() => _seulementManquants = false">Tout</button> </button>
<button type="button" class="segment @(_seulementManquants ? "segment-actif" : null)"
@onclick="() => _seulementManquants = true">Ce qui me manque</button>
</div>
</div> </div>
@if (_filtresOuverts)
{
<div class="filtres">
<div class="segments" role="group" aria-label="Possession">
<button type="button" class="segment @(_modePossession == 0 ? "segment-actif" : null)"
@onclick="() => _modePossession = 0">Tout</button>
<button type="button" class="segment @(_modePossession == 1 ? "segment-actif" : null)"
@onclick="() => _modePossession = 1">Ceux que j'ai déjà</button>
<button type="button" class="segment @(_modePossession == 2 ? "segment-actif" : null)"
@onclick="() => _modePossession = 2">Ce qui me manque</button>
</div>
@if (_masquees > 0)
{
<div class="segments">
<button type="button" class="segment @(_afficherMasquees ? "segment-actif" : null)"
@onclick="() => _afficherMasquees = true">
Afficher les masquées (@_masquees)
</button>
<button type="button" class="segment @(!_afficherMasquees ? "segment-actif" : null)"
@onclick="() => _afficherMasquees = false">Masquer les masquées</button>
</div>
}
</div>
}
<ul class="liste-bibliographie"> <ul class="liste-bibliographie">
@foreach (var oeuvre in Affichees(biblio)) @foreach (var oeuvre in Affichees(biblio))
@@ -204,16 +229,51 @@ else if (_bibliographie is { } biblio)
@if (oeuvre.ADecouvrir) @if (oeuvre.ADecouvrir)
{ {
<button type="button" class="bouton bouton-compact bouton-principal" <div class="actions-formulaire">
disabled="@_enCours" @onclick="() => SouhaiterAsync(oeuvre)" <label class="selection-oeuvre">
aria-label="@($"Ajouter « {oeuvre.Titre} » à ma liste d'envies")"> <input type="checkbox"
Je le veux checked="@_selection.Contains(oeuvre.Titre)"
@onchange="e => Selectionner(oeuvre.Titre, e.Value is true)" />
Sélectionner
</label>
<button type="button" class="bouton bouton-compact bouton-principal"
disabled="@_enCours" @onclick="() => SouhaiterAsync(oeuvre)"
aria-label="@($"Ajouter « {oeuvre.Titre} » à ma liste d'envies")">
Je le veux
</button>
</div>
}
@if (!oeuvre.Masquee)
{
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@_enCours" @onclick="() => MasquerAsync(oeuvre)">
Masquer
</button>
}
else
{
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@_enCours" @onclick="() => DemasquerAsync(oeuvre)">
Réafficher
</button> </button>
} }
</li> </li>
} }
</ul> </ul>
@if (_selection.Count > 0)
{
<div class="actions-flottantes">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne)"
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
@onclick="AjouterSelectionAsync">
Ajouter la sélection aux envies (@_selection.Count)
</button>
</div>
}
@* Le rapprochement est fait par titre, et il rate des choses (voir CleOeuvre). Le dire @* 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 franchement vaut mieux que laisser l'utilisateur découvrir seul qu'un livre qu'il
possède n'est pas grisé. *@ possède n'est pas grisé. *@
@@ -239,7 +299,9 @@ else if (_bibliographie is { } biblio)
public int AuteurId { get; set; } public int AuteurId { get; set; }
private BibliographieDto? _bibliographie; private BibliographieDto? _bibliographie;
private bool _seulementManquants; private int _modePossession;
private bool _filtresOuverts;
private bool _afficherMasquees;
private bool _enCours; private bool _enCours;
private bool _chargement; private bool _chargement;
@@ -250,13 +312,22 @@ else if (_bibliographie is { } biblio)
private string? _message; private string? _message;
private int _possedees; private int _possedees;
private int _masquees;
private IReadOnlyList<LivreDto> _livresCatalogue = []; private IReadOnlyList<LivreDto> _livresCatalogue = [];
private readonly HashSet<string> _oeuvresOuvertes = []; private readonly HashSet<string> _oeuvresOuvertes = [];
private readonly HashSet<string> _selection = [];
protected override async Task OnParametersSetAsync() => await ChargerAsync(); protected override async Task OnParametersSetAsync() => await ChargerAsync();
private IEnumerable<OeuvreBibliographie> Affichees(BibliographieDto biblio) => private IEnumerable<OeuvreBibliographie> 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() private async Task ChargerAsync()
{ {
@@ -278,6 +349,7 @@ else if (_bibliographie is { } biblio)
} }
_possedees = _bibliographie.Oeuvres.Count(o => o.Possede); _possedees = _bibliographie.Oeuvres.Count(o => o.Possede);
_masquees = _bibliographie.Oeuvres.Count(o => o.Masquee);
} }
catch (Exception) 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) private void BasculerOeuvre(string titre)
{ {
if (!_oeuvresOuvertes.Add(titre)) if (!_oeuvresOuvertes.Add(titre))
+63 -3
View File
@@ -25,6 +25,7 @@ namespace MaBibli.Client.Services;
public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, EtatReseau reseau) public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, EtatReseau reseau)
{ {
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private readonly Dictionary<int, BibliographieDto> _bibliographies = [];
/// <summary> /// <summary>
/// Recharge tous les instantanés depuis le réseau. /// 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<ResultatApi<SouhaitDto>> AjouterSouhaitAsync( public async Task<ResultatApi<SouhaitDto>> AjouterSouhaitAsync(
EnregistrementSouhait saisie, CancellationToken ct = default) => EnregistrementSouhait saisie, CancellationToken ct = default)
await EcrireAsync<SouhaitDto>(() => http.PostAsJsonAsync("api/souhaits", saisie, Json, ct), ct); {
var resultat = await EcrireAsync<SouhaitDto>(
() => http.PostAsJsonAsync("api/souhaits", saisie, Json, ct), ct);
if (resultat.EstOk)
{
_bibliographies.Clear();
}
return resultat;
}
public async Task<IReadOnlyList<ResultatApi<SouhaitDto>>> AjouterSouhaitsAsync(
IReadOnlyList<EnregistrementSouhait> saisies, CancellationToken ct = default)
{
var resultats = new List<ResultatApi<SouhaitDto>>(saisies.Count);
foreach (var saisie in saisies)
{
resultats.Add(await AjouterSouhaitAsync(saisie, ct));
}
return resultats;
}
/// <summary> /// <summary>
/// Retire une envie. <c>null</c> si c'est fait, sinon le motif à afficher. /// Retire une envie. <c>null</c> si c'est fait, sinon le motif à afficher.
@@ -735,10 +757,48 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
public async Task<BibliographieDto?> ObtenirBibliographieAsync( public async Task<BibliographieDto?> ObtenirBibliographieAsync(
int auteurId, CancellationToken ct = default) 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); var reponse = await http.GetAsync($"api/auteurs/{auteurId}/bibliographie", ct);
return reponse.StatusCode == HttpStatusCode.NotFound var bibliographie = reponse.StatusCode == HttpStatusCode.NotFound
? null ? null
: await LireAsync<BibliographieDto>(reponse, ct); : await LireAsync<BibliographieDto>(reponse, ct);
if (bibliographie?.Etat == EtatSourceBibliographie.Ok)
{
_bibliographies[auteurId] = bibliographie;
}
return bibliographie;
}
public async Task<string?> 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<string?> 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<string?> ModifierBibliographieAsync(
Func<Task<HttpResponseMessage>> 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);
} }
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
+9
View File
@@ -68,6 +68,12 @@ public record EnregistrementSouhait
public string? Note { get; set; } public string? Note { get; set; }
} }
/// <summary>Charge utile du masquage personnel d'une œuvre bibliographique.</summary>
public record MasquageBibliographie
{
public string Titre { get; set; } = string.Empty;
}
/// <summary>Formats d'export de la liste d'envies.</summary> /// <summary>Formats d'export de la liste d'envies.</summary>
/// <remarks> /// <remarks>
/// Les deux usages décrits dans IDEES.md ne demandent pas la même chose : « l'emporter en /// 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; } public int? SouhaitId { get; init; }
/// <summary>Vrai si l'appelant a explicitement masqué cette œuvre.</summary>
public bool Masquee { get; init; }
/// <summary>Ni possédé, ni déjà souhaité : ce qui reste à découvrir.</summary> /// <summary>Ni possédé, ni déjà souhaité : ce qui reste à découvrir.</summary>
[JsonIgnore] [JsonIgnore]
public bool ADecouvrir => !Possede && !Souhaite; public bool ADecouvrir => !Possede && !Souhaite;
@@ -0,0 +1,26 @@
using MaBibli.Shared.Textes;
namespace MaBibli.Shared.Entites;
/// <summary>Une œuvre explicitement masquée par un utilisateur dans une bibliographie.</summary>
/// <remarks>
/// 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.
/// </remarks>
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),
};
}
@@ -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<MaBibliDbContext>()
.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();
}
}