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