From 49eb8e1c982a057d6f301782eba8bcfb8bfad981 Mon Sep 17 00:00:00 2001 From: mathieu Date: Wed, 19 Aug 2026 21:45:37 +0200 Subject: [PATCH] Distinguer romans et BD par un type de document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format disait physique ou numérique, jamais roman ou BD. Trois valeurs seulement : une valeur s'ajoute sans migration, la renommer une fois posée sur des centaines de fiches, non. Le défaut est « non précisé », pas « roman » : les fiches existantes n'ont pas été saisies avec cette question en tête, et un AddColumn suffit justement parce que la valeur par défaut ne prétend rien. L'interface n'affiche donc que ce qui a été choisi, comme pour le format. Filtre ajouté aux DEUX implémentations, serveur et hors-ligne, avec quatre jeux de critères de plus dans le test qui les confronte. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 48 +++ IDEES.md | 23 +- .../20260819194040_TypeDeDocument.Designer.cs | 307 ++++++++++++++++++ .../20260819194040_TypeDeDocument.cs | 38 +++ .../MaBibliDbContextModelSnapshot.cs | 3 + MaBibli.Api/Endpoints/LivresEndpoints.cs | 4 +- .../Services/Catalogue/ServiceCatalogue.cs | 3 + .../Composants/FormulaireLivre.razor | 23 ++ MaBibli.Client/Composants/Libelles.cs | 13 + MaBibli.Client/Pages/Catalogue.razor | 46 ++- MaBibli.Client/Pages/FicheLivre.razor | 7 + MaBibli.Client/Services/FiltreLivresLocal.cs | 5 + MaBibli.Client/Services/ServiceLivresApi.cs | 5 + MaBibli.Shared/Catalogue/CritereLivres.cs | 10 + MaBibli.Shared/Catalogue/FiltreLivres.cs | 5 + MaBibli.Shared/Dtos/EnregistrementLivre.cs | 3 + MaBibli.Shared/Dtos/LivreDto.cs | 5 + MaBibli.Shared/Entites/Livre.cs | 10 + MaBibli.Shared/Entites/TypeDocument.cs | 34 ++ MaBibli.Tests/FiltreLivresLocalTests.cs | 24 +- MaBibli.Tests/ServiceCatalogueTests.cs | 42 +++ 21 files changed, 631 insertions(+), 27 deletions(-) create mode 100644 MaBibli.Api/Data/Migrations/20260819194040_TypeDeDocument.Designer.cs create mode 100644 MaBibli.Api/Data/Migrations/20260819194040_TypeDeDocument.cs create mode 100644 MaBibli.Shared/Entites/TypeDocument.cs diff --git a/CLAUDE.md b/CLAUDE.md index 16cfe4a..1206e37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -686,6 +686,7 @@ Livre ├── TitreNormalise (index — minuscules sans accents, recalculé à chaque écriture) ├── Editeur ├── Format : Physique | Numerique +├── TypeDocument : NonPrecise | Roman | BandeDessinee (défaut = NonPrecise, PAS Roman) ├── CoverUrl ├── DateAjout └── AjoutePar (YNH_USER — traçabilité, PAS un cloisonnement) @@ -835,6 +836,53 @@ livre sorti depuis six mois qu'on avait oublié, pas celui prêté hier. (`ServiceLivresApi.EnUtc`). Sans cela le prêt se décalerait d'un jour pour la moitié du globe. Vérifié en exécution : saisie du 12/08 → `2026-08-11 22:00` en base (CEST). +## Type de document — migration `TypeDeDocument` (2026-08-19) + +Besoin remonté : distinguer une BD d'un roman. Une BD était déjà catalogable telle quelle (elle +a un ISBN, la BnF la connaît), mais rien ne permettait de la **reconnaître** — `Format` dit +physique ou numérique, pas roman ou bande dessinée. + +`Livre.TypeDocument` : **`NonPrecise` (0), `Roman`, `BandeDessinee`**. Trois valeurs, et c'est +délibéré : une valeur s'ajoute **sans migration** (une énumération se stocke en entier), alors +que renommer ou fusionner des valeurs déjà posées sur des centaines de fiches, non. Dans le +doute, on n'ajoute pas. + +### ⚠️ Le défaut est « non précisé », et surtout pas « roman » + +C'est ce qui distingue cette migration de `RangDesEnvies`, qui avait dû reconduire un ordre en +SQL : ici un simple `AddColumn` suffit, **parce que la valeur par défaut ne prétend rien**. Un +défaut « Roman » aurait écrit quelque chose de faux sur chaque fiche existante — elles n'ont +pas été saisies avec cette question en tête. + +Deux conséquences tenues partout : + +- **l'interface n'affiche que ce qui a été choisi** — étiquette au catalogue et ligne sur la + fiche seulement si `TypeDocument != NonPrecise`, dans le prolongement exact de « le format ne + s'affiche que pour les ebooks ». Sans cela, presque chaque ligne porterait « Non précisé » ; +- **le formulaire ne présélectionne rien d'autre** que « Non précisé ». ⚠️ Symétrique inverse du + format, qui **doit** rester sur *Physique* : là-bas l'absence d'étiquette signifie « physique », + ici elle signifie « on ne sait pas ». + +### `NonPrecise` est une valeur de filtre à part entière + +Contrairement au statut de lecture, dont l'absence n'est **pas** un statut. « Qu'est-ce que je +n'ai pas encore rangé ? » est précisément la question qui se pose après avoir introduit ce champ, +et c'est la seule façon d'y répondre. Le filtre suit pour le reste les règles déjà actées : la +ligne se replie derrière « Filtrer », compte dans le compteur du bouton, et **disparaît tant que +le fonds n'a qu'un seul type** — comme la ligne des formats, et avec la même précaution (les +types présents ne se déduisent que d'un chargement `EstSansCritere`). + +⚠️ **Le filtre existe en deux exemplaires** (`FiltreLivres` côté base, `FiltreLivresLocal` côté +navigateur) : toute nouvelle dimension doit être ajoutée **aux deux**, et au test qui les +confronte — quatre jeux de critères ont été ajoutés à `FiltreLivresLocalTests`, dont un croisant +type, format et recherche. Une divergence silencieuse ferait chercher hors-ligne autre chose +qu'en ligne. + +**Ce qui n'est délibérément pas fait** : ni « magazine » dans l'énumération — le catalogage des +périodiques demande un ISSN et une table de numéros, pas une étiquette de plus (voir IDEES.md) — +ni les champs propres à la BD (série, tome, scénariste/dessinateur), le modèle mettant tous les +auteurs dans `LivreAuteur` sans distinguer les rôles. + ## Doublons du catalogue — avertir, jamais refuser (2026-08-19) Défaut remonté en usage : rien n'empêchait de rescanner un livre déjà catalogué, et diff --git a/IDEES.md b/IDEES.md index 9d34e35..54a45ef 100644 --- a/IDEES.md +++ b/IDEES.md @@ -80,25 +80,12 @@ rendu en canvas, pas sur une image de caméra. # Retours d'usage du 2026-08-19 (3ᵉ série) -## Bandes dessinées et magazines +## Les magazines : une fiche par revue, les numéros à l'intérieur -Deux demandes distinctes, de coût très différent. - -### Les BD : surtout une question de type de document - -Une BD est déjà catalogable telle quelle — elle a un ISBN, la BnF la connaît (vérifié au lot 6 : -la BD de Dobbs tirée de *La bête humaine* remonte bien). Ce qui manque est de pouvoir **la -distinguer** : `Format` dit physique ou numérique, pas roman ou BD. - -Piste : un `TypeDocument` (roman / BD / magazine…) à côté de `Format`, avec migration, filtre au -catalogue et étiquette. ⚠️ Suivre la règle déjà actée pour le format : **n'afficher que ce qui -n'est pas le cas par défaut**, sinon chaque ligne du catalogue porte une étiquette qui n'apprend -rien. - -Champs propres à la BD (série, tome, scénariste/dessinateur) : à ne faire que si le besoin se -confirme. Le modèle actuel met tous les auteurs dans `LivreAuteur` sans distinguer les rôles. - -### Les magazines : une fiche par revue, les numéros à l'intérieur +⚠️ La demande jumelle, **les BD**, est traitée : `TypeDocument` existe (voir `CLAUDE.md`). +Restent les champs propres à la BD — série, tome, scénariste/dessinateur — à ne faire que si le +besoin se confirme, le modèle mettant aujourd'hui tous les auteurs dans `LivreAuteur` sans +distinguer les rôles. La question des **séries** rejoint celle des sagas, plus bas. ⚠️ Rouvre la décision du lot 1, qui écartait le catalogage des périodiques faute de modèle. Le modèle est maintenant choisi. diff --git a/MaBibli.Api/Data/Migrations/20260819194040_TypeDeDocument.Designer.cs b/MaBibli.Api/Data/Migrations/20260819194040_TypeDeDocument.Designer.cs new file mode 100644 index 0000000..56e47ab --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260819194040_TypeDeDocument.Designer.cs @@ -0,0 +1,307 @@ +// +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("20260819194040_TypeDeDocument")] + partial class TypeDeDocument + { + /// + 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.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.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.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.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.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.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.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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MaBibli.Api/Data/Migrations/20260819194040_TypeDeDocument.cs b/MaBibli.Api/Data/Migrations/20260819194040_TypeDeDocument.cs new file mode 100644 index 0000000..6b42a17 --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260819194040_TypeDeDocument.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + /// + /// Ajoute le type de document, à « non précisé » pour tout l'existant. + /// + /// + /// ⚠️ Contrairement à RangDesEnvies, cette migration se réduit bien à un + /// AddColumn, et c'est le choix de la valeur par défaut qui l'explique : 0 vaut + /// NonPrecise, c'est-à-dire « on ne sait pas ». Si le défaut avait été « Roman », il + /// aurait fallu écrire quelque chose de faux sur chaque fiche existante — ou renoncer au + /// défaut. Rien à reprendre, donc rien à deviner. + /// + public partial class TypeDeDocument : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TypeDocument", + table: "Livres", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TypeDocument", + table: "Livres"); + } + } +} diff --git a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs index 77ef9df..cbbd0b5 100644 --- a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs +++ b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs @@ -77,6 +77,9 @@ namespace MaBibli.Api.Data.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("TypeDocument") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.HasIndex("Isbn"); diff --git a/MaBibli.Api/Endpoints/LivresEndpoints.cs b/MaBibli.Api/Endpoints/LivresEndpoints.cs index 4dc5d95..bf2fbb9 100644 --- a/MaBibli.Api/Endpoints/LivresEndpoints.cs +++ b/MaBibli.Api/Endpoints/LivresEndpoints.cs @@ -28,6 +28,7 @@ public static class LivresEndpoints groupe.MapGet("/", async ( string? recherche, Format? format, + TypeDocument? typeDocument, Statut? statut, int? auteurId, IServiceCatalogue service, @@ -38,6 +39,7 @@ public static class LivresEndpoints { Recherche = recherche, Format = format, + TypeDocument = typeDocument, Statut = statut, AuteurId = auteurId, }; @@ -46,7 +48,7 @@ public static class LivresEndpoints return Results.Ok(await service.ListerAsync(criteres, utilisateur, ct)); }) .WithName("ListerLivres") - .WithSummary("Liste le catalogue, filtrable par format, statut et auteur ; cherchable sur titre et auteurs.") + .WithSummary("Liste le catalogue, filtrable par format, type de document, statut et auteur ; cherchable sur titre et auteurs.") .WithDescription( "Catalogue unique : physiques et numériques cohabitent, distingués par leur format. " + "La bibliothèque est commune — la liste n'est jamais restreinte à l'utilisateur courant. " diff --git a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs index 3aa522e..f409b71 100644 --- a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs +++ b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs @@ -130,6 +130,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur Titre = saisie.Titre, Editeur = Vide(saisie.Editeur), Format = saisie.Format, + TypeDocument = saisie.TypeDocument, CoverUrl = Vide(saisie.CoverUrl), DateAjout = DateTime.UtcNow, @@ -168,6 +169,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur livre.Titre = saisie.Titre; livre.Editeur = Vide(saisie.Editeur); livre.Format = saisie.Format; + livre.TypeDocument = saisie.TypeDocument; livre.CoverUrl = Vide(saisie.CoverUrl); livre.RecalculerFormes(); @@ -466,6 +468,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur .ToList(), Editeur = livre.Editeur, Format = livre.Format, + TypeDocument = livre.TypeDocument, Statut = statut, CoverUrl = livre.CoverUrl, DateAjout = livre.DateAjout, diff --git a/MaBibli.Client/Composants/FormulaireLivre.razor b/MaBibli.Client/Composants/FormulaireLivre.razor index f266f12..f72c5fe 100644 --- a/MaBibli.Client/Composants/FormulaireLivre.razor +++ b/MaBibli.Client/Composants/FormulaireLivre.razor @@ -66,6 +66,27 @@ + @* + ⚠️ Le défaut est « non précisé », PAS « roman » : on ne remplit pas une case à la place + de l'utilisateur. C'est aussi ce qui rend l'étiquette honnête dans les listes — elle ne + s'affiche que lorsqu'un type a été choisi. + *@ +
+ + Type (facultatif) + +
+ @foreach (var type in Types) + { + + } +
+
+
Statut de lecture (le vôtre) @@ -112,6 +133,8 @@ @code { private static readonly Format[] Formats = Enum.GetValues(); + private static readonly TypeDocument[] Types = Enum.GetValues(); + [Parameter, EditorRequired] public EnregistrementLivre Saisie { get; set; } = new(); [Parameter] public string LibelleValidation { get; set; } = "Enregistrer"; diff --git a/MaBibli.Client/Composants/Libelles.cs b/MaBibli.Client/Composants/Libelles.cs index bc4a453..ac6fd20 100644 --- a/MaBibli.Client/Composants/Libelles.cs +++ b/MaBibli.Client/Composants/Libelles.cs @@ -12,6 +12,19 @@ public static class Libelles _ => format.ToString(), }; + /// + /// Libellé du type de document. « Non précisé » ne s'affiche pas sur une fiche : + /// c'est l'absence de choix, et l'interface ne montre que ce qui a été choisi. Le libellé + /// existe pour les boutons de saisie et de filtre, où il faut bien nommer l'option. + /// + public static string TypeDocument(TypeDocument type) => type switch + { + MaBibli.Shared.Entites.TypeDocument.NonPrecise => "Non précisé", + MaBibli.Shared.Entites.TypeDocument.Roman => "Roman", + MaBibli.Shared.Entites.TypeDocument.BandeDessinee => "BD", + _ => type.ToString(), + }; + public static string Statut(Statut statut) => statut switch { MaBibli.Shared.Entites.Statut.ALire => "À lire", diff --git a/MaBibli.Client/Pages/Catalogue.razor b/MaBibli.Client/Pages/Catalogue.razor index 50270f6..320b6ac 100644 --- a/MaBibli.Client/Pages/Catalogue.razor +++ b/MaBibli.Client/Pages/Catalogue.razor @@ -54,6 +54,23 @@ } + @* Même logique que pour le format : tant qu'aucun type n'a été renseigné, la ligne + n'aurait qu'un seul choix utile. Elle apparaît dès la première fiche typée. + ⚠️ « Non précisé » y figure comme valeur à part entière : « qu'est-ce que je n'ai + pas encore rangé ? » est justement la question qui se pose après coup. *@ + @if (PlusieursTypes) + { +
+ + @foreach (var type in Enum.GetValues()) + { + + } +
+ } + @* ⚠️ Ce filtre porte sur MON statut, pas sur celui du foyer : un livre sans statut pour moi ne remonte sous aucune valeur, il reste visible sous « Tous ». L'écran l'assume au lieu de laisser croire à une vue commune. *@ @@ -125,6 +142,13 @@ else if (_livres is not null) @Libelles.Format(livre.Format) } + @* Même règle que le format : on n'affiche que ce qui a été CHOISI. + « Non précisé » n'apprendrait rien et serait sur presque chaque ligne. *@ + @if (livre.TypeDocument != TypeDocument.NonPrecise) + { + @Libelles.TypeDocument(livre.TypeDocument) + } + @* Personnel : ce que montre cette pastille est MA lecture, pas celle du foyer. Pas de pastille = non commencé, l'absence de ligne en base. *@ @if (livre.Statut is { } statut) @@ -184,6 +208,7 @@ else if (_livres is not null) private int? _auteurCharge; private string _recherche = string.Empty; private Format? _format; + private TypeDocument? _type; private Statut? _statut; private bool _chargement; private bool _filtresOuverts; @@ -203,6 +228,11 @@ else if (_livres is not null) private bool PlusieursFormats => _formatsDuFonds is null || _formatsDuFonds.Count > 1; + /// Types réellement présents dans le fonds. Mêmes précautions que les formats. + private IReadOnlySet? _typesDuFonds; + + private bool PlusieursTypes => _typesDuFonds is null || _typesDuFonds.Count > 1; + /// /// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ». /// @@ -211,14 +241,15 @@ else if (_livres is not null) /// déjà à l'écran, l'une dans son champ, l'autre dans sa phrase. Ce compteur ne dit que ce /// qui serait invisible une fois le panneau refermé. /// - private int FiltresActifs => (_format is null ? 0 : 1) + (_statut is null ? 0 : 1); + private int FiltresActifs => + (_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1); // Une frappe au clavier annule la recherche précédente encore en vol. private CancellationTokenSource _cts = new(); private bool EstFiltre => - !string.IsNullOrWhiteSpace(_recherche) || _format is not null || _statut is not null - || AuteurId is not null; + !string.IsNullOrWhiteSpace(_recherche) || _format is not null || _type is not null + || _statut is not null || AuteurId is not null; /// /// La liste se relit à chaque bascule du réseau : au retour, pour reprendre les données du @@ -255,6 +286,12 @@ else if (_livres is not null) return ChargerAsync(); } + private Task FiltrerType(TypeDocument? type) + { + _type = type; + return ChargerAsync(); + } + private Task FiltrerStatut(Statut? statut) { _statut = statut; @@ -265,6 +302,7 @@ else if (_livres is not null) private static bool EstSansCritere(CritereLivres criteres) => string.IsNullOrWhiteSpace(criteres.Recherche) && criteres.Format is null + && criteres.TypeDocument is null && criteres.Statut is null && criteres.AuteurId is null; @@ -284,6 +322,7 @@ else if (_livres is not null) { Recherche = _recherche, Format = _format, + TypeDocument = _type, Statut = _statut, AuteurId = AuteurId, }; @@ -293,6 +332,7 @@ else if (_livres is not null) if (EstSansCritere(criteres)) { _formatsDuFonds = _livres.Select(l => l.Format).ToHashSet(); + _typesDuFonds = _livres.Select(l => l.TypeDocument).ToHashSet(); } } catch (OperationCanceledException) diff --git a/MaBibli.Client/Pages/FicheLivre.razor b/MaBibli.Client/Pages/FicheLivre.razor index f80680d..433892a 100644 --- a/MaBibli.Client/Pages/FicheLivre.razor +++ b/MaBibli.Client/Pages/FicheLivre.razor @@ -127,6 +127,12 @@ else {
Format
@Libelles.Format(_livre.Format)
} + + @* Idem : « non précisé » est l'absence de réponse, pas une réponse. *@ + @if (_livre.TypeDocument != TypeDocument.NonPrecise) + { +
Type
@Libelles.TypeDocument(_livre.TypeDocument)
+ } @@ -274,6 +280,7 @@ else Auteurs = livre.Auteurs.Select(a => a.Nom).ToList(), Editeur = livre.Editeur, Format = livre.Format, + TypeDocument = livre.TypeDocument, Statut = livre.Statut, CoverUrl = livre.CoverUrl, }; diff --git a/MaBibli.Client/Services/FiltreLivresLocal.cs b/MaBibli.Client/Services/FiltreLivresLocal.cs index 7598575..46a78ce 100644 --- a/MaBibli.Client/Services/FiltreLivresLocal.cs +++ b/MaBibli.Client/Services/FiltreLivresLocal.cs @@ -36,6 +36,11 @@ public static class FiltreLivresLocal livres = livres.Where(l => l.Format == format); } + if (criteres.TypeDocument is { } type) + { + livres = livres.Where(l => l.TypeDocument == type); + } + if (criteres.Statut is { } statut) { // LivreDto.Statut est déjà celui de l'utilisateur courant : le serveur l'a résolu diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs index ac12094..8491bce 100644 --- a/MaBibli.Client/Services/ServiceLivresApi.cs +++ b/MaBibli.Client/Services/ServiceLivresApi.cs @@ -76,6 +76,11 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat parametres.Add($"format={format}"); } + if (criteres.TypeDocument is { } type) + { + parametres.Add($"typeDocument={type}"); + } + if (criteres.Statut is { } statut) { parametres.Add($"statut={statut}"); diff --git a/MaBibli.Shared/Catalogue/CritereLivres.cs b/MaBibli.Shared/Catalogue/CritereLivres.cs index 526be08..8c62199 100644 --- a/MaBibli.Shared/Catalogue/CritereLivres.cs +++ b/MaBibli.Shared/Catalogue/CritereLivres.cs @@ -25,6 +25,16 @@ public record CritereLivres /// Physique ou numérique. null = les deux : le catalogue est unique. public Format? Format { get; init; } + /// + /// Roman, bande dessinée… null = tous. + /// + /// + /// TypeDocument.NonPrecise est une valeur filtrable : « ce que je n'ai pas + /// encore rangé » est justement la question qu'on se pose après avoir introduit ce champ. + /// C'est ce qui la distingue du statut de lecture, dont l'absence n'est pas une valeur. + /// + public TypeDocument? TypeDocument { get; init; } + /// /// Statut de lecture de l'utilisateur courant. null = tous. /// diff --git a/MaBibli.Shared/Catalogue/FiltreLivres.cs b/MaBibli.Shared/Catalogue/FiltreLivres.cs index 8d13a59..8befa00 100644 --- a/MaBibli.Shared/Catalogue/FiltreLivres.cs +++ b/MaBibli.Shared/Catalogue/FiltreLivres.cs @@ -34,6 +34,11 @@ public static class FiltreLivres source = source.Where(l => l.Format == format); } + if (criteres.TypeDocument is { } type) + { + source = source.Where(l => l.TypeDocument == type); + } + if (criteres.Statut is { } statut) { // Le statut vit dans une table par personne : on interroge la ligne de l'appelant, diff --git a/MaBibli.Shared/Dtos/EnregistrementLivre.cs b/MaBibli.Shared/Dtos/EnregistrementLivre.cs index 15114d3..d56e7d3 100644 --- a/MaBibli.Shared/Dtos/EnregistrementLivre.cs +++ b/MaBibli.Shared/Dtos/EnregistrementLivre.cs @@ -34,6 +34,9 @@ public record EnregistrementLivre public Format Format { get; set; } + /// Roman, bande dessinée… Facultatif : « non précisé » est une réponse valable. + public TypeDocument TypeDocument { get; set; } + /// /// Statut de lecture de l'appelant. null = non commencé, et aucune ligne n'est /// alors écrite en base. diff --git a/MaBibli.Shared/Dtos/LivreDto.cs b/MaBibli.Shared/Dtos/LivreDto.cs index 2b2c415..ce84b57 100644 --- a/MaBibli.Shared/Dtos/LivreDto.cs +++ b/MaBibli.Shared/Dtos/LivreDto.cs @@ -34,6 +34,11 @@ public record LivreDto public required Format Format { get; init; } + /// + /// Roman, bande dessinée… NonPrecise par défaut, qui ne veut pas dire « roman ». + /// + public TypeDocument TypeDocument { get; init; } + /// /// Statut de lecture de l'utilisateur courant, ou null s'il n'en a jamais posé /// (« non commencé »). diff --git a/MaBibli.Shared/Entites/Livre.cs b/MaBibli.Shared/Entites/Livre.cs index e0fce68..9bae6ef 100644 --- a/MaBibli.Shared/Entites/Livre.cs +++ b/MaBibli.Shared/Entites/Livre.cs @@ -27,6 +27,16 @@ public class Livre public Format Format { get; set; } + /// + /// Roman, bande dessinée… Indépendant du format : une BD numérique existe. + /// + /// + /// Par défaut , qui ne veut pas dire « roman » : voir + /// l'énumération. L'interface n'affiche donc une étiquette que lorsque le type a été + /// choisi, comme elle n'affiche le format que pour les ebooks. + /// + public TypeDocument TypeDocument { get; set; } + /// URL de la couverture (OpenLibrary), pas de fichier stocké localement. public string? CoverUrl { get; set; } diff --git a/MaBibli.Shared/Entites/TypeDocument.cs b/MaBibli.Shared/Entites/TypeDocument.cs new file mode 100644 index 0000000..59c09d1 --- /dev/null +++ b/MaBibli.Shared/Entites/TypeDocument.cs @@ -0,0 +1,34 @@ +namespace MaBibli.Shared.Entites; + +/// +/// Nature de l'ouvrage, indépendante de son support (voir ). +/// +/// +/// Une BD est déjà catalogable telle quelle — elle a un ISBN et la BnF la connaît. Ce qui +/// manquait était de pouvoir la distinguer : dit physique ou +/// numérique, pas roman ou bande dessinée. +/// +/// ⚠️ est la valeur par défaut, et ce n'est pas « roman ». Les +/// fiches saisies avant l'arrivée de ce champ ne l'ont pas été avec cette question en tête : les +/// déclarer romans d'office affirmerait quelque chose qu'on ne sait pas. Même raisonnement que +/// l'absence de ligne de statut, qui vaut « non commencé » et non « à lire ». +/// +/// +/// La liste est volontairement courte. Une valeur s'ajoute sans migration — une énumération est +/// stockée en entier — alors que renommer ou fusionner des valeurs déjà posées sur des centaines +/// de fiches, non. Dans le doute, on n'ajoute pas. +/// +/// +/// ⚠️ Les périodiques n'y figurent pas : leur catalogage demande un ISSN et une table de +/// numéros, pas une étiquette de plus. Voir IDEES.md. +/// +/// +public enum TypeDocument +{ + /// On ne sait pas, et on ne prétend pas le savoir. Valeur par défaut. + NonPrecise = 0, + + Roman = 1, + + BandeDessinee = 2, +} diff --git a/MaBibli.Tests/FiltreLivresLocalTests.cs b/MaBibli.Tests/FiltreLivresLocalTests.cs index e271dcb..8ca7e16 100644 --- a/MaBibli.Tests/FiltreLivresLocalTests.cs +++ b/MaBibli.Tests/FiltreLivresLocalTests.cs @@ -30,9 +30,14 @@ public class FiltreLivresLocalTests private static readonly Auteur Zola = Auteur(1, "Émile Zola"); private static readonly Auteur Maupassant = Auteur(2, "Guy de Maupassant"); - private static Livre Livre(int id, string titre, Auteur? auteur, Format format, Statut? statut) + private static Livre Livre( + int id, string titre, Auteur? auteur, Format format, Statut? statut, + TypeDocument type = TypeDocument.NonPrecise) { - var livre = new Livre { Id = id, Titre = titre, Format = format, AjoutePar = Lecteur }; + var livre = new Livre + { + Id = id, Titre = titre, Format = format, TypeDocument = type, AjoutePar = Lecteur, + }; livre.RecalculerFormes(); if (auteur is not null) @@ -50,9 +55,9 @@ public class FiltreLivresLocalTests private static readonly Livre[] Entites = [ - Livre(1, "Germinal", Zola, Format.Physique, Statut.Lu), - Livre(2, "La Bête humaine", Zola, Format.Numerique, Statut.ALire), - Livre(3, "Le Horla", Maupassant, Format.Physique, Statut.EnCours), + Livre(1, "Germinal", Zola, Format.Physique, Statut.Lu, TypeDocument.Roman), + Livre(2, "La Bête humaine", Zola, Format.Numerique, Statut.ALire, TypeDocument.BandeDessinee), + Livre(3, "Le Horla", Maupassant, Format.Physique, Statut.EnCours, TypeDocument.Roman), Livre(4, "Bel-Ami", Maupassant, Format.Numerique, null), Livre(5, "Œuvres complètes", Zola, Format.Physique, Statut.ALire), Livre(6, "L'Éducation sentimentale", null, Format.Physique, null), @@ -68,6 +73,7 @@ public class FiltreLivresLocalTests Id = l.Id, Titre = l.Titre, Format = l.Format, + TypeDocument = l.TypeDocument, DateAjout = DateTime.UtcNow, Statut = l.Statuts.FirstOrDefault(s => s.Utilisateur == Lecteur)?.Statut, Auteurs = l.Auteurs @@ -89,12 +95,20 @@ public class FiltreLivresLocalTests new CritereLivres { Recherche = "introuvable" }, new CritereLivres { Format = Format.Physique }, new CritereLivres { Format = Format.Numerique }, + new CritereLivres { TypeDocument = TypeDocument.Roman }, + new CritereLivres { TypeDocument = TypeDocument.BandeDessinee }, + // « Non précisé » est une valeur filtrable, pas l'absence de filtre. + new CritereLivres { TypeDocument = TypeDocument.NonPrecise }, new CritereLivres { Statut = Statut.Lu }, new CritereLivres { Statut = Statut.ALire }, new CritereLivres { AuteurId = 1 }, new CritereLivres { AuteurId = 2 }, new CritereLivres { AuteurId = 99 }, new CritereLivres { Recherche = "zola", Format = Format.Physique, Statut = Statut.ALire }, + new CritereLivres + { + Recherche = "zola", TypeDocument = TypeDocument.BandeDessinee, Format = Format.Numerique, + }, ]; [Theory] diff --git a/MaBibli.Tests/ServiceCatalogueTests.cs b/MaBibli.Tests/ServiceCatalogueTests.cs index f196f36..4d69944 100644 --- a/MaBibli.Tests/ServiceCatalogueTests.cs +++ b/MaBibli.Tests/ServiceCatalogueTests.cs @@ -348,6 +348,48 @@ public class ServiceCatalogueTests : IDisposable Assert.Equal(["Guy de Maupassant"], relu.Auteurs.Select(a => a.Nom)); } + [Fact] + public async Task Un_livre_sans_type_precise_reste_non_precise() + { + var livre = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!; + + // ⚠️ Le défaut n'est PAS « Roman » : on ne remplit pas une case à la place de + // l'utilisateur, et l'interface n'affiche que ce qui a été choisi. + Assert.Equal(TypeDocument.NonPrecise, livre.TypeDocument); + } + + [Fact] + public async Task Le_type_de_document_est_enregistre_puis_modifiable() + { + var saisie = Saisie("Les fourmis", "Bernard Werber"); + saisie.TypeDocument = TypeDocument.BandeDessinee; + + var cree = (await _service.CreerAsync(saisie, "mathieu")).Livre!; + Assert.Equal(TypeDocument.BandeDessinee, cree.TypeDocument); + + saisie.TypeDocument = TypeDocument.Roman; + var modifie = (await _service.ModifierAsync(cree.Id, saisie, "mathieu")).Livre!; + + Assert.Equal(TypeDocument.Roman, modifie.TypeDocument); + } + + [Fact] + public async Task Le_catalogue_se_filtre_par_type_de_document() + { + var bd = Saisie("Les fourmis", "Bernard Werber"); + bd.TypeDocument = TypeDocument.BandeDessinee; + await _service.CreerAsync(bd, "mathieu"); + await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu"); + + var bds = await _service.ListerAsync( + new CritereLivres { TypeDocument = TypeDocument.BandeDessinee }, "mathieu"); + var nonPrecises = await _service.ListerAsync( + new CritereLivres { TypeDocument = TypeDocument.NonPrecise }, "mathieu"); + + Assert.Equal(["Les fourmis"], bds.Select(l => l.Titre)); + Assert.Equal(["Germinal"], nonPrecises.Select(l => l.Titre)); + } + // ───────────────────────────────────────────────────────────────────────── // Doublons : un AVERTISSEMENT, jamais un refus. Posséder deux exemplaires est légitime — // on garde le sien et on prête l'autre — et l'ISBN est facultatif, donc il ne peut pas