From 42dec0dbd113485f2bec1e5e2389d9ce8c4c94a4 Mon Sep 17 00:00:00 2001 From: mathieu Date: Wed, 9 Sep 2026 00:04:28 +0200 Subject: [PATCH] =?UTF-8?q?-=20Catalogage=20rapide=20(douchette,=20code-ba?= =?UTF-8?q?rres)=20-=20Cataloguer=20en=20rafale=20=E2=80=94=20nouvel=20?= =?UTF-8?q?=C3=A9cran=20/ajout/rafale=20:=20on=20scanne=20une=20pile=20de?= =?UTF-8?q?=20livres=20=C3=A0=20la=20suite=20dans=20une=20zone=20de=20text?= =?UTF-8?q?e,=20chaque=20code=20est=20trait=C3=A9=20(BnF=20puis=20OpenLibr?= =?UTF-8?q?ary),=20les=20doublons=20connus=20sont=20pass=C3=A9s=20automati?= =?UTF-8?q?quement.=20La=20collecte=20marche=20m=C3=AAme=20hors-ligne.=20L?= =?UTF-8?q?e=20compte=20rendu=20liste=20maintenant=20les=20livres=20cr?= =?UTF-8?q?=C3=A9=C3=A9s,=20en=20lien=20vers=20leur=20fiche,=20et=20reste?= =?UTF-8?q?=20consultable=20en=20revenant=20sur=20l'=C3=A9cran=20m=C3=AAme?= =?UTF-8?q?=20apr=C3=A8s=20une=20rafale=20enti=C3=A8rement=20r=C3=A9ussie.?= =?UTF-8?q?=20Le=20catalogue=20reconna=C3=AEt=20un=20ISBN=20dans=20sa=20ba?= =?UTF-8?q?rre=20de=20recherche=20(13=20ou=2010=20chiffres,=20avec=20ou=20?= =?UTF-8?q?sans=20tirets)=20:=20scanner=20un=20livre=20en=20main=20ouvre?= =?UTF-8?q?=20directement=20sa=20fiche=20s'il=20n'y=20en=20a=20qu'un.=20Un?= =?UTF-8?q?=20bouton=20=C2=AB=20Scanner=20=C2=BB=20l'alimente,=20actif=20h?= =?UTF-8?q?ors-ligne.=20-=20Ajouter=20un=20tome=20=C3=A0=20une=20s=C3=A9ri?= =?UTF-8?q?e=20accepte=20aussi=20un=20ISBN=20dans=20le=20champ=20manuel=20?= =?UTF-8?q?:=20le=20catalogue=20est=20cherch=C3=A9=20d'abord=20(rattacheme?= =?UTF-8?q?nt=20direct=20si=20un=20seul=20exemplaire),=20sinon=20la=20BnF?= =?UTF-8?q?=20prend=20le=20relais.=20S=C3=A9ries=20et=20sagas=20-=20Num?= =?UTF-8?q?=C3=A9ro=20de=20tome=20distinct=20de=20la=20position=20de=20lec?= =?UTF-8?q?ture=20:=20on=20peut=20indiquer=20=C2=AB=20c'est=20le=20tome=20?= =?UTF-8?q?7=20=C2=BB=20m=C3=AAme=20si=20on=20ne=20poss=C3=A8de=20pas=20le?= =?UTF-8?q?s=20six=20premiers=20;=20l'ordre=20de=20lecture=20reste=20un=20?= =?UTF-8?q?r=C3=A9glage=20s=C3=A9par=C3=A9=20(utile=20pour=20les=20pr?= =?UTF-8?q?=C3=A9quelles).=20-=20Tri=20par=20num=C3=A9ro=20en=20plus=20du?= =?UTF-8?q?=20tri=20par=20ordre=20de=20lecture=20quand=20des=20tomes=20en?= =?UTF-8?q?=20portent=20un.=20-=20Panneau=20=C2=AB=20Ajouter=20=C2=BB=20re?= =?UTF-8?q?group=C3=A9=20et=20repliable=20sur=20la=20fiche=20d'une=20s?= =?UTF-8?q?=C3=A9rie=20(manuellement=20/=20en=20rafale=20/=20depuis=20le?= =?UTF-8?q?=20catalogue=20/=20depuis=20les=20envies),=20au=20lieu=20de=20q?= =?UTF-8?q?uatre=20formulaires=20ouverts=20en=20permanence.=20-=20Filtre?= =?UTF-8?q?=20catalogue=20=C2=AB=20sans=20couverture=20=C2=BB=20pour=20rep?= =?UTF-8?q?=C3=A9rer=20les=20livres=20=C3=A0=20illustrer.=20Le=20catalogue?= =?UTF-8?q?=20groupe=20les=20tomes=20d'une=20m=C3=AAme=20s=C3=A9rie=20sous?= =?UTF-8?q?=20un=20bloc=20repliable,=20avec=20un=20d=C3=A9compte=20plus=20?= =?UTF-8?q?clair=20(affich=C3=A9s=20/=20poss=C3=A9d=C3=A9s=20/=20total).?= =?UTF-8?q?=20-=20Corrections=20directes=20sur=20la=20fiche=20-=20Effacer?= =?UTF-8?q?=20un=20pr=C3=AAt=20saisi=20par=20erreur=20(bouton=20=E2=9C=95?= =?UTF-8?q?=20sur=20chaque=20ligne,=20avec=20confirmation),=20sans=20passe?= =?UTF-8?q?r=20par=20=C2=AB=20rendre=20=C2=BB.=20-=20Corriger=20une=20couv?= =?UTF-8?q?erture=20manquante=20ou=20cass=C3=A9e=20en=20cliquant=20dessus?= =?UTF-8?q?=20:=20le=20champ=20d'adresse=20s'ouvre=20focalis=C3=A9,=20Entr?= =?UTF-8?q?=C3=A9e=20enregistre.=20=C3=89tendu=20aux=20num=C3=A9ros=20de?= =?UTF-8?q?=20revue=20dans=20la=20derni=C3=A8re=20modification.=20-=20Les?= =?UTF-8?q?=20th=C3=A8mes=20d=C3=A9j=C3=A0=20utilis=C3=A9s=20dans=20la=20b?= =?UTF-8?q?iblioth=C3=A8que=20sont=20propos=C3=A9s=20=C3=A0=20la=20frappe.?= =?UTF-8?q?=20-=20La=20recherche=20d'un=20livre=20=C3=A0=20rattacher=20mon?= =?UTF-8?q?tre=20des=20suggestions=20d=C3=A8s=20le=20focus,=20sans=20atten?= =?UTF-8?q?dre=20de=20taper.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Visuel : un rendu manquant après une écriture asynchrone dans le formulaire de livre, une bascule de rôle cassée, des débordements à 320 px, et le style d'un bouton-lien qui restait souligné. --- .../20260907234923_NumeroDeTome.Designer.cs | 696 ++++++++++++++++ .../Migrations/20260907234923_NumeroDeTome.cs | 28 + .../MaBibliDbContextModelSnapshot.cs | 3 + MaBibli.Api/Endpoints/LivresEndpoints.cs | 2 + MaBibli.Api/Endpoints/PretsEndpoints.cs | 14 + MaBibli.Api/Endpoints/SeriesEndpoints.cs | 15 + MaBibli.Api/Services/Prets/ServicePrets.cs | 27 + MaBibli.Api/Services/Series/ServiceSeries.cs | 41 + MaBibli.Client/Composants/Couverture.razor | 60 +- .../Composants/FormulaireLivre.razor | 59 +- .../Composants/GroupeCatalogue.razor | 32 +- MaBibli.Client/Composants/Libelles.cs | 35 + MaBibli.Client/Composants/PretsLivre.razor | 87 +- MaBibli.Client/Composants/SaisieListe.razor | 72 ++ MaBibli.Client/Pages/AjoutIsbn.razor | 51 +- MaBibli.Client/Pages/AjoutManuel.razor | 21 +- MaBibli.Client/Pages/AjoutRafale.razor | 741 ++++++++++++++++++ MaBibli.Client/Pages/Catalogue.razor | 195 ++++- MaBibli.Client/Pages/FicheLivre.razor | 114 ++- MaBibli.Client/Pages/Revue.razor | 142 +++- MaBibli.Client/Pages/Serie.razor | 670 +++++++++++++--- MaBibli.Client/Services/CacheHorsLigne.cs | 52 ++ MaBibli.Client/Services/FiltreLivresLocal.cs | 36 +- MaBibli.Client/Services/RemonteeRoutes.cs | 4 + MaBibli.Client/Services/ServiceLivresApi.cs | 26 + MaBibli.Client/wwwroot/css/app.css | 199 ++++- MaBibli.Client/wwwroot/js/cache-hors-ligne.js | 36 +- MaBibli.Shared/Catalogue/CritereLivres.cs | 22 + MaBibli.Shared/Catalogue/FiltreLivres.cs | 24 + MaBibli.Shared/Catalogue/RafaleIsbn.cs | 164 ++++ MaBibli.Shared/Dtos/SerieDto.cs | 6 + MaBibli.Shared/Entites/Serie.cs | 31 +- MaBibli.Shared/Isbn/RechercheIsbn.cs | 83 ++ MaBibli.Shared/Textes/TriNaturel.cs | 121 +++ MaBibli.Tests/FiltreLivresLocalTests.cs | 60 +- MaBibli.Tests/LibellesTests.cs | 37 + MaBibli.Tests/RafaleIsbnTests.cs | 133 ++++ MaBibli.Tests/RechercheIsbnTests.cs | 70 ++ MaBibli.Tests/RemonteeRoutesTests.cs | 3 + MaBibli.Tests/ServicePretsTests.cs | 32 + MaBibli.Tests/TriNaturelTests.cs | 92 +++ docs/architecture.md | 3 +- docs/installer.md | 4 +- 43 files changed, 4154 insertions(+), 189 deletions(-) create mode 100644 MaBibli.Api/Data/Migrations/20260907234923_NumeroDeTome.Designer.cs create mode 100644 MaBibli.Api/Data/Migrations/20260907234923_NumeroDeTome.cs create mode 100644 MaBibli.Client/Pages/AjoutRafale.razor create mode 100644 MaBibli.Shared/Catalogue/RafaleIsbn.cs create mode 100644 MaBibli.Shared/Isbn/RechercheIsbn.cs create mode 100644 MaBibli.Shared/Textes/TriNaturel.cs create mode 100644 MaBibli.Tests/LibellesTests.cs create mode 100644 MaBibli.Tests/RafaleIsbnTests.cs create mode 100644 MaBibli.Tests/RechercheIsbnTests.cs create mode 100644 MaBibli.Tests/TriNaturelTests.cs diff --git a/MaBibli.Api/Data/Migrations/20260907234923_NumeroDeTome.Designer.cs b/MaBibli.Api/Data/Migrations/20260907234923_NumeroDeTome.Designer.cs new file mode 100644 index 0000000..325bd95 --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260907234923_NumeroDeTome.Designer.cs @@ -0,0 +1,696 @@ +// +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("20260907234923_NumeroDeTome")] + partial class NumeroDeTome + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NumeroRevueId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NumeroRevueId", "TitreNormalise") + .IsUnique(); + + b.ToTable("ArticlesUne"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CleRegroupement") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Nom") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NomNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CleRegroupement") + .IsUnique(); + + b.HasIndex("NomNormalise"); + + b.ToTable("Auteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.BibliographieMasquee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Utilisateur", "AuteurId"); + + b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre") + .IsUnique(); + + b.ToTable("BibliographiesMasquees"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Numero") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SerieId") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LivreId"); + + b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId"); + + b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId") + .IsUnique() + .HasFilter("\"LivreId\" IS NOT NULL"); + + b.ToTable("ElementsSerie"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("NombrePages") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeDocument") + .HasColumnType("INTEGER"); + + b.Property("UrlNotice") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Isbn"); + + b.HasIndex("TitreNormalise"); + + b.ToTable("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "AuteurId"); + + b.HasIndex("AuteurId"); + + b.ToTable("LivreAuteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Annee") + .HasColumnType("TEXT"); + + b.Property("Auteur") + .HasColumnType("TEXT"); + + b.Property("AuteurNormalise") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Rang") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur"); + + b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre") + .IsUnique(); + + b.ToTable("LivresSouhaites"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "ThemeId"); + + b.HasIndex("ThemeId"); + + b.ToTable("LivreThemes"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("DateParution") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Numero") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NumeroNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevueId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RevueId", "NumeroNormalise") + .IsUnique(); + + b.ToTable("NumerosRevue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DatePret") + .HasColumnType("TEXT"); + + b.Property("DateRetour") + .HasColumnType("TEXT"); + + b.Property("Emprunteur") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours") + .IsUnique() + .HasFilter("\"DateRetour\" IS NULL"); + + b.ToTable("Prets"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurAId") + .HasColumnType("INTEGER"); + + b.Property("AuteurBId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuteurAId", "AuteurBId") + .IsUnique(); + + b.ToTable("RapprochementsRefuses"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Issn") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TitreNormalise") + .IsUnique(); + + b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn") + .IsUnique() + .HasFilter("\"Issn\" IS NOT NULL"); + + b.ToTable("Revues"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.RevueSouhaitee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Issn") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Numero") + .HasColumnType("TEXT"); + + b.Property("NumeroNormalise") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + 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_RevuesSouhaitees_Utilisateur"); + + b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" }, "IX_RevuesSouhaitees_Utilisateur_Revue") + .IsUnique(); + + b.ToTable("RevuesSouhaitees"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SerieParenteId") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SerieParenteId"); + + b.HasIndex("TitreNormalise") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateMaj") + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Statut") + .HasColumnType("INTEGER"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Utilisateur"); + + b.HasIndex("LivreId", "Utilisateur") + .IsUnique(); + + b.ToTable("StatutsLecture"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Nom") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NomNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NomNormalise") + .IsUnique(); + + b.ToTable("Themes"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b => + { + b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue") + .WithMany("Articles") + .HasForeignKey("NumeroRevueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NumeroRevue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany() + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("MaBibli.Shared.Entites.Serie", "Serie") + .WithMany("Elements") + .HasForeignKey("SerieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + + b.Navigation("Serie"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur") + .WithMany("Livres") + .HasForeignKey("AuteurId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Auteurs") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Auteur"); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Themes") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MaBibli.Shared.Entites.Theme", "Theme") + .WithMany("Livres") + .HasForeignKey("ThemeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Livre"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.HasOne("MaBibli.Shared.Entites.Revue", "Revue") + .WithMany("Numeros") + .HasForeignKey("RevueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Revue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Prets") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente") + .WithMany("SousSeries") + .HasForeignKey("SerieParenteId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("SerieParente"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Statuts") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Navigation("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Navigation("Auteurs"); + + b.Navigation("Prets"); + + b.Navigation("Statuts"); + + b.Navigation("Themes"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.Navigation("Articles"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => + { + b.Navigation("Numeros"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.Navigation("Elements"); + + b.Navigation("SousSeries"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b => + { + b.Navigation("Livres"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MaBibli.Api/Data/Migrations/20260907234923_NumeroDeTome.cs b/MaBibli.Api/Data/Migrations/20260907234923_NumeroDeTome.cs new file mode 100644 index 0000000..97ca2ea --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260907234923_NumeroDeTome.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + /// + public partial class NumeroDeTome : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Numero", + table: "ElementsSerie", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Numero", + table: "ElementsSerie"); + } + } +} diff --git a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs index 84937f5..ac2160a 100644 --- a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs +++ b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs @@ -109,6 +109,9 @@ namespace MaBibli.Api.Data.Migrations b.Property("LivreId") .HasColumnType("INTEGER"); + b.Property("Numero") + .HasColumnType("TEXT"); + b.Property("Position") .HasColumnType("INTEGER"); diff --git a/MaBibli.Api/Endpoints/LivresEndpoints.cs b/MaBibli.Api/Endpoints/LivresEndpoints.cs index 13f37cd..dd18a94 100644 --- a/MaBibli.Api/Endpoints/LivresEndpoints.cs +++ b/MaBibli.Api/Endpoints/LivresEndpoints.cs @@ -32,6 +32,7 @@ public static class LivresEndpoints Statut? statut, int? auteurId, bool? prete, + bool? sansCouverture, IServiceCatalogue service, IFournisseurUtilisateur utilisateurs, CancellationToken ct) => @@ -44,6 +45,7 @@ public static class LivresEndpoints Statut = statut, AuteurId = auteurId, Prete = prete, + SansCouverture = sansCouverture, }; var utilisateur = utilisateurs.Obtenir().Identifiant; diff --git a/MaBibli.Api/Endpoints/PretsEndpoints.cs b/MaBibli.Api/Endpoints/PretsEndpoints.cs index 120a8e9..cb76a74 100644 --- a/MaBibli.Api/Endpoints/PretsEndpoints.cs +++ b/MaBibli.Api/Endpoints/PretsEndpoints.cs @@ -53,6 +53,20 @@ public static class PretsEndpoints .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound); + prets.MapDelete("/{id:int}", async (int id, IServicePrets service, CancellationToken ct) => + { + var supprime = await service.SupprimerAsync(id, ct); + return supprime ? Results.NoContent() : Results.NotFound(); + }) + .WithName("SupprimerPret") + .WithSummary("Efface un prêt saisi par erreur — ce n'est pas « rendre ».") + .WithDescription( + "Rendre pose une date de retour et conserve la ligne : c'est l'histoire du livre. " + + "Effacer défait une saisie fautive (mauvais emprunteur, mauvaise date, prêt noté " + + "deux fois). Un historique juste vaut mieux qu'un historique complet mais faux.") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound); + var parLivre = routes.MapGroup("/api/livres/{livreId:int}/prets").WithTags("Prêts"); parLivre.MapGet("/", async (int livreId, IServicePrets service, CancellationToken ct) => diff --git a/MaBibli.Api/Endpoints/SeriesEndpoints.cs b/MaBibli.Api/Endpoints/SeriesEndpoints.cs index 5ddbe99..acb38ea 100644 --- a/MaBibli.Api/Endpoints/SeriesEndpoints.cs +++ b/MaBibli.Api/Endpoints/SeriesEndpoints.cs @@ -106,6 +106,21 @@ public static class SeriesEndpoints .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound); + groupe.MapPut("/elements/{elementId:int}", async ( + int elementId, + AjoutElementSerie saisie, + IServiceSeries service, + CancellationToken ct) => + Repondre(await service.ModifierElementAsync(elementId, saisie, ct))) + .WithName("ModifierElementSerie") + .WithSummary("Corrige le titre et le numéro d'un tome, sans toucher au livre rattaché.") + .WithDescription( + "Le numéro est celui imprimé sur le livre (« 7 », « Hors-série »), à ne pas " + + "confondre avec la position, qui est le rang dans l'ordre de lecture.") + .Produces() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + groupe.MapDelete("/elements/{elementId:int}", async ( int elementId, IServiceSeries service, CancellationToken ct) => await service.RetirerElementAsync(elementId, ct) diff --git a/MaBibli.Api/Services/Prets/ServicePrets.cs b/MaBibli.Api/Services/Prets/ServicePrets.cs index 1673b67..5514b62 100644 --- a/MaBibli.Api/Services/Prets/ServicePrets.cs +++ b/MaBibli.Api/Services/Prets/ServicePrets.cs @@ -29,6 +29,17 @@ public interface IServicePrets Task PreterAsync(int livreId, EnregistrementPret saisie, CancellationToken ct = default); Task RendreAsync(int pretId, RetourPret retour, CancellationToken ct = default); + + /// + /// Efface un prêt, en cours ou clos. false s'il n'existe pas. + /// + /// + /// ⚠️ Ce n'est pas « rendre », et les deux ne doivent jamais être confondus. Rendre + /// pose une date et conserve la ligne : c'est l'histoire du livre, et c'est la raison d'être + /// de la table. Effacer défait une saisie — mauvais emprunteur, mauvaise date, prêt + /// noté deux fois. Un historique juste vaut mieux qu'un historique complet mais faux. + /// + Task SupprimerAsync(int pretId, CancellationToken ct = default); } /// @@ -175,6 +186,22 @@ public sealed class ServicePrets(MaBibliDbContext db) : IServicePrets return ResultatPret.Ok((await ObtenirAsync(pret.Id, ct))!); } + public async Task SupprimerAsync(int pretId, CancellationToken ct = default) + { + var pret = await db.Prets.FirstOrDefaultAsync(p => p.Id == pretId, ct); + if (pret is null) + { + return false; + } + + // Aucune identité n'entre ici, comme partout dans ce service : le prêt est commun au + // foyer, et celui qui constate l'erreur n'est pas forcément celui qui l'a faite. + db.Prets.Remove(pret); + await db.SaveChangesAsync(ct); + + return true; + } + private async Task ObtenirAsync(int pretId, CancellationToken ct) { var pret = await Requete().FirstOrDefaultAsync(p => p.Id == pretId, ct); diff --git a/MaBibli.Api/Services/Series/ServiceSeries.cs b/MaBibli.Api/Services/Series/ServiceSeries.cs index 358baa4..de1bb44 100644 --- a/MaBibli.Api/Services/Series/ServiceSeries.cs +++ b/MaBibli.Api/Services/Series/ServiceSeries.cs @@ -37,6 +37,10 @@ public interface IServiceSeries Task RattacherLivreAsync(int elementId, int? livreId, CancellationToken ct = default); + /// Corrige le titre et le numéro d'une place, sans toucher au livre rattaché. + Task ModifierElementAsync( + int elementId, AjoutElementSerie saisie, CancellationToken ct = default); + Task RetirerElementAsync(int elementId, CancellationToken ct = default); Task ReordonnerAsync( @@ -232,6 +236,7 @@ public sealed class ServiceSeries(MaBibliDbContext db, IServiceSouhaits souhaits SerieId = serieId, LivreId = saisie.LivreId, Titre = titre, + Numero = saisie.Numero, Position = serie.Elements.Count == 0 ? 0 : serie.Elements.Max(e => e.Position) + 1, }; @@ -276,6 +281,41 @@ public sealed class ServiceSeries(MaBibliDbContext db, IServiceSouhaits souhaits return await RelireAsync(element.SerieId, ct); } + /// + /// ⚠️ Ce point d'entrée existe pour que le numéro de tome puisse se saisir après coup. + /// Sans lui, il ne serait renseignable qu'à la création — c'est-à-dire jamais en pratique, + /// puisqu'on découvre souvent le numéro en ayant le livre en main. Même défaut que celui + /// rencontré sur les numéros de revue, et corrigé de la même façon. + /// + /// Le livre rattaché n'est pas touché : c'est l'affaire de + /// . Un point d'entrée, un geste. + /// + /// + public async Task ModifierElementAsync( + int elementId, AjoutElementSerie saisie, CancellationToken ct = default) + { + var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct); + if (element is null) + { + return ResultatSerie.Introuvable; + } + + var titre = (saisie.Titre ?? string.Empty).Trim(); + if (titre.Length == 0) + { + // Le titre survit à la suppression du livre : une place sans titre serait un trou + // anonyme, que plus rien ne permettrait d'identifier. + return ResultatSerie.Invalide("Le titre du tome est obligatoire."); + } + + element.Titre = titre; + element.Numero = saisie.Numero; + element.RecalculerFormes(); + + await db.SaveChangesAsync(ct); + return await RelireAsync(element.SerieId, ct); + } + public async Task RetirerElementAsync(int elementId, CancellationToken ct = default) { var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct); @@ -468,6 +508,7 @@ public sealed class ServiceSeries(MaBibliDbContext db, IServiceSouhaits souhaits // Le titre du livre prime quand il est là : c'est lui qui a pu être corrigé // depuis la saisie du tome. Le titre stocké reste le filet en cas de suppression. Titre = e.Livre?.Titre ?? e.Titre, + Numero = e.Numero, LivreId = e.LivreId, Auteurs = e.Livre is null || e.Livre.Auteurs.Count == 0 ? null diff --git a/MaBibli.Client/Composants/Couverture.razor b/MaBibli.Client/Composants/Couverture.razor index 93d89e2..511804c 100644 --- a/MaBibli.Client/Composants/Couverture.razor +++ b/MaBibli.Client/Composants/Couverture.razor @@ -42,7 +42,24 @@ @if (ADesPixels) { - @if (Agrandissable) + @* ⚠️ L'image cassée est désormais un état C# (`_cassee`), posé par @onerror, et non plus + une classe ajoutée par du JavaScript en ligne. C'est ce qui permet d'en faire quelque + chose : proposer de corriger l'URL, là où l'on constate qu'elle est morte. + + `onerror` ne remonte pas dans le DOM, mais Blazor le connaît comme événement + non propagatif et pose son écouteur sur l'élément même : @onerror fonctionne bien + sur une . *@ + @if (Corrigeable) + { + + } + else if (Agrandissable && !_cassee) { @* Un vrai } else { Couverture de @Titre + @onerror="() => _cassee = true" /> } } + else if (OnCorriger.HasDelegate) + { + @* + ⚠️ Pas de couverture DU TOUT, et pourtant cliquable : c'est le seul écart à la règle + « le substitut à initiale n'est jamais cliquable ». Elle valait tant que le clic + servait à AGRANDIR — il n'y avait rien à agrandir. Ici il sert à en POSER une, et + c'est précisément le livre qui n'en a pas qui en a besoin. + + Ailleurs (cartes du catalogue, séries, envies), aucune correction n'est offerte : + le paramètre n'est pas passé, et le substitut y reste un décor inerte. + *@ + + } @if (_agrandie) @@ -95,6 +128,22 @@ /// Le clic sur l'image l'affiche en grand. Sans effet s'il n'y a pas de couverture. [Parameter] public bool Agrandissable { get; set; } + /// + /// Que faire quand l'image manque ou ne se charge pas — ouvrir le champ de son adresse. + /// + /// + /// ⚠️ Renseigné, il remplace l'agrandissement sur une image cassée, et rend cliquable + /// le substitut à initiale d'un livre sans couverture. Agrandir une image qui ne + /// s'affiche pas n'a jamais servi à rien ; en poser une là où l'on constate qu'elle manque + /// est exactement ce qu'on veut faire à cet instant. + /// + [Parameter] public EventCallback OnCorriger { get; set; } + + /// L'image a échoué à se charger : 404 de l'hébergeur, adresse morte, 502 passager. + private bool _cassee; + + private bool Corrigeable => _cassee && OnCorriger.HasDelegate; + private bool _agrandie; private bool _aFocaliser; private ElementReference _calque; @@ -141,6 +190,9 @@ } _urlResoluePour = jeton; + + // Nouvelle image : ce qu'on savait de la précédente ne dit plus rien de celle-ci. + _cassee = false; await LibererAsync(); // Le cache d'abord, EN LIGNE COMME HORS LIGNE : une image déjà sur l'appareil s'affiche diff --git a/MaBibli.Client/Composants/FormulaireLivre.razor b/MaBibli.Client/Composants/FormulaireLivre.razor index ee17445..06e6204 100644 --- a/MaBibli.Client/Composants/FormulaireLivre.razor +++ b/MaBibli.Client/Composants/FormulaireLivre.razor @@ -1,3 +1,5 @@ +@inject ServiceLivresApi Api + @* Formulaire d'une fiche livre, partagé par l'ajout (ISBN ou manuel) et l'édition. @@ -53,8 +55,14 @@ qui le suit rogné — le séparateur était le seul caractère qu'on ne pouvait pas taper. Voir CLAUDE.md. *@ + @* + ⚠️ Les thèmes déjà employés sont PROPOSÉS : un thème est un vocabulaire, il ne vaut que + réutilisé. « fantasy » et « Fantaisie » saisis séparément ne regroupent rien, et le + filtre par thème n'y verrait que deux étiquettes sans rapport. + *@ @* @@ -67,7 +75,7 @@ ⚠️ Les rôles n'apparaissent qu'à partir de DEUX auteurs, décision inchangée : un roman n'a qu'un signataire dont le rôle ne se pose pas. Le défaut reste « non précisé ». *@ - @@ -205,18 +213,61 @@ /// /// ⚠️ Sa référence doit rester stable tant que la saisie ne change pas : le composant /// se remet à zéro quand la liste qu'on lui prête change d'identité, et il perdrait donc à - /// chaque rendu ce qui est en cours de frappe. + /// chaque rendu ce qui est en cours de frappe. D'où la construction paresseuse, invalidée + /// par OnParametersSet et par elle seule. + /// + /// ⚠️ Elle ne peut PAS être construite dans OnParametersSet uniquement : dès qu'un + /// composant a un OnInitializedAsync qui attend vraiment (ici la lecture des thèmes + /// connus), Blazor rend une première fois avant d'appeler OnParametersSet. Ce + /// rendu-là voyait un null, et l'enfant levait une NullReferenceException — + /// invisible à l'écran, qui finissait par s'afficher, mais bien présente en console. + /// /// - private NomsAuteurs _noms = default!; + private NomsAuteurs? _noms; + + private NomsAuteurs Noms => _noms ??= new NomsAuteurs(Saisie.Auteurs); private EnregistrementLivre? _saisiePrecedente; + /// + /// Les thèmes déjà employés dans la bibliothèque, proposés sous le champ. + /// + /// + /// ⚠️ Déduits du catalogue, et non d'un point d'entrée « /api/themes » : la liste des + /// livres est déjà lue partout, elle porte les thèmes, et surtout elle a son instantané — + /// une nouvelle vue de l'API en exigerait un autre, faute de quoi les propositions + /// disparaîtraient hors-ligne. Le catalogue d'un foyer se charge entier, c'est déjà le parti + /// pris de la détection de doublons. + /// + /// Une lecture ratée ne coûte rien : sans propositions, le champ redevient celui d'avant. + /// + /// + private IReadOnlyList _themesConnus = []; + + protected override async Task OnInitializedAsync() + { + try + { + _themesConnus = + [ + .. (await Api.ListerAsync(new CritereLivres())) + .SelectMany(l => l.Themes) + .DistinctBy(NormalisationTexte.Normaliser, StringComparer.Ordinal) + .OrderBy(NormalisationTexte.Normaliser, StringComparer.Ordinal), + ]; + } + catch (Exception) + { + _themesConnus = []; + } + } + protected override void OnParametersSet() { if (!ReferenceEquals(_saisiePrecedente, Saisie)) { _saisiePrecedente = Saisie; - _noms = new NomsAuteurs(Saisie.Auteurs); + _noms = null; } } diff --git a/MaBibli.Client/Composants/GroupeCatalogue.razor b/MaBibli.Client/Composants/GroupeCatalogue.razor index 61ea0d5..4ca8374 100644 --- a/MaBibli.Client/Composants/GroupeCatalogue.razor +++ b/MaBibli.Client/Composants/GroupeCatalogue.razor @@ -8,8 +8,13 @@ tenue par l'arbre des séries : l'inverse obligerait à ouvrir chaque bloc pour retrouver un livre qu'on voyait très bien avant. - ⚠️ Replié, le bloc annonce toujours ce qu'il contient (« 5 tomes ») : sans ce compte, le - compteur du haut du catalogue paraîtrait mentir. + ⚠️ Replié, le bloc annonce toujours ce qu'il contient : sans ce compte, le compteur du haut + du catalogue paraîtrait mentir. + + ⚠️ Et ce compte distingue trois choses (2026-09-08) : ce qui est AFFICHÉ (filtre compris), ce + qu'on POSSÈDE de cette série, et ce qu'elle compte de tomes. Un seul nombre suivait le + filtre — « 3 tomes » devenait « 1 tome » sous une recherche, et se lisait « cette série n'a + qu'un tome ». Les égalités se replient : sans filtre et série complète, on relit « 3 tomes ». *@
  • @@ -38,7 +43,7 @@
      @foreach (var sous in Entree.SousEntrees) { - + }
    } @@ -71,6 +76,25 @@ /// [Parameter, EditorRequired] public ISet Replies { get; set; } = new HashSet(); + /// + /// Compteurs par série, descendance comprise : ce qu'on possède, et ce que la série recense. + /// + /// + /// ⚠️ Calculés une fois par l'écran (TotauxSeries), et non ici : le catalogue se relit + /// à chaque frappe dans la recherche, et chaque bloc les recalculerait pour tout l'arbre. + /// C'est aussi ce qui garantit le même compte que la fiche de la série. + /// + [Parameter] public IReadOnlyDictionary? Totaux { get; set; } + + private (int Possedes, int Total) Compte => + Totaux is not null && Totaux.TryGetValue(Entree.Serie!.Id, out var connu) + ? connu + : (Entree.NombreLivres, Entree.NombreLivres); + + private int Possedes => Compte.Possedes; + + private int Total => Compte.Total; + private bool Deplie => !Replies.Contains(Entree.Serie!.Id); private void Basculer() diff --git a/MaBibli.Client/Composants/Libelles.cs b/MaBibli.Client/Composants/Libelles.cs index 2ebff97..5b47610 100644 --- a/MaBibli.Client/Composants/Libelles.cs +++ b/MaBibli.Client/Composants/Libelles.cs @@ -66,4 +66,39 @@ public static class Libelles MaBibli.Shared.Entites.TypeDocument.BandeDessinee => "type-bd", _ => string.Empty, }; + + /// + /// Ce qu'annonce un bloc de série au catalogue : ce qui y est affiché, ce qu'on possède, et + /// ce que la série compte de tomes. + /// + /// + /// ⚠️ Le compte d'un bloc changeait avec le filtre : « 3 tomes » devenait « 1 tome » + /// dès qu'une recherche écartait les autres, et se lisait alors comme « cette série n'a qu'un + /// tome ». Le nombre affiché ne disait pas de quoi il parlait. + /// + /// Trois nombres seraient du bruit quand ils coïncident : les égalités se replient donc. + /// Un fonds complet et non filtré redit simplement « 3 tomes », comme avant. + /// + /// + /// Tomes de ce bloc actuellement à l'écran, filtre appliqué. + /// Tomes de cette série présents au catalogue, descendance comprise. + /// Tomes recensés dans la série, ceux qui manquent compris. + public static string CompteBloc(int affiches, int possedes, int total) + { + var tomes = total > 1 ? "tomes" : "tome"; + + if (affiches == possedes && possedes == total) + { + return $"{total} {tomes}"; + } + + if (affiches == possedes) + { + return $"{possedes} sur {total} {tomes}"; + } + + return possedes == total + ? $"{affiches} affiché{(affiches > 1 ? "s" : "")} sur {total} {tomes}" + : $"{affiches} affiché{(affiches > 1 ? "s" : "")} · {possedes} sur {total} {tomes}"; + } } diff --git a/MaBibli.Client/Composants/PretsLivre.razor b/MaBibli.Client/Composants/PretsLivre.razor index c9e2dc5..cea0e17 100644 --- a/MaBibli.Client/Composants/PretsLivre.razor +++ b/MaBibli.Client/Composants/PretsLivre.razor @@ -69,8 +69,12 @@ à @pret.Emprunteur depuis le @Jour(pret.DatePret) (@Duree(pret.JoursEcoules)) + + @Croix(pret)

    + @Confirmation(pret) +
    ; + + /// La demande de confirmation, sous la ligne visée seulement. + private RenderFragment Confirmation(PretDto pret) => @ + @if (_aEffacer == pret.Id) + { +

    + Effacer définitivement ce prêt ? Il disparaîtra de l'historique du livre. +

    +
    + + +
    + } +
    ; + [Parameter, EditorRequired] public int LivreId { get; set; } /// Format du livre : un ebook ne se prête pas. @@ -169,6 +220,9 @@ private string? _erreur; private int _livreCharge; + /// Prêt dont l'effacement attend confirmation, s'il y en a un. + private int? _aEffacer; + private PretDto? EnCours => _prets?.FirstOrDefault(p => p.EstEnCours); private IReadOnlyList Historique => @@ -279,6 +333,29 @@ await OnChangement.InvokeAsync(); } + private async Task EffacerAsync(PretDto pret) + { + _occupe = true; + _erreur = null; + + var erreur = await Api.SupprimerPretAsync(pret.Id); + + _occupe = false; + _aEffacer = null; + + if (erreur is not null) + { + _erreur = erreur; + return; + } + + await ChargerAsync(); + + // Effacer le prêt EN COURS remet le livre à la maison : la fiche porte cet état, elle + // doit donc se relire, comme après un retour. + await OnChangement.InvokeAsync(); + } + private static string Jour(DateTime instant) => instant.ToLocalTime().ToString("dd/MM/yyyy"); diff --git a/MaBibli.Client/Composants/SaisieListe.razor b/MaBibli.Client/Composants/SaisieListe.razor index 65d9050..a3ec9ee 100644 --- a/MaBibli.Client/Composants/SaisieListe.razor +++ b/MaBibli.Client/Composants/SaisieListe.razor @@ -22,6 +22,7 @@
    + } +
    + } + @if (Valeurs.Count > 0) {
      @@ -97,6 +119,16 @@ ///
  • [Parameter] public RenderFragment? Complement { get; set; } + /// + /// Valeurs déjà employées ailleurs, proposées sous le champ. null = aucune proposition. + /// + /// + /// ⚠️ N'a de sens que pour un vocabulaire — les thèmes, qui ne valent que réutilisés. + /// Elle n'en a aucun pour des valeurs uniques à leur fiche (titres d'articles à la une, noms + /// d'auteurs d'un livre donné), où elle ne ferait que du bruit. + /// + [Parameter] public IReadOnlyList? Suggestions { get; set; } + /// /// Prévient le parent que la liste a changé. /// @@ -116,6 +148,45 @@ private string? _message; + /// Les propositions sont dépliées depuis le premier focus, jusqu'à ce qu'on choisisse. + private bool _suggestionsOuvertes; + + /// Nombre de propositions affichées : au-delà, la liste cache le formulaire. + private const int MaximumPropose = 8; + + /// + /// Ce qui reste à proposer : ni ce qui est déjà sur la fiche, ni ce qui ne correspond pas à + /// ce qu'on tape. + /// + private IReadOnlyList Proposees + { + get + { + if (!_suggestionsOuvertes || Suggestions is null) + { + return []; + } + + var deja = Valeurs.Select(NormalisationTexte.Normaliser).ToHashSet(StringComparer.Ordinal); + var terme = NormalisationTexte.Normaliser(_saisie); + + return + [ + .. Suggestions + .Where(s => !deja.Contains(NormalisationTexte.Normaliser(s))) + .Where(s => terme.Length == 0 + || NormalisationTexte.Normaliser(s).Contains(terme, StringComparison.Ordinal)) + .Take(MaximumPropose), + ]; + } + } + + private void Choisir(string suggestion) + { + _saisie = suggestion; + Valider(); + } + /// Liste servie au rendu précédent, pour repérer que le parent en a changé. private IList? _precedente; @@ -137,6 +208,7 @@ _saisie = null; _indexModifie = null; _message = null; + _suggestionsOuvertes = false; } /// diff --git a/MaBibli.Client/Pages/AjoutIsbn.razor b/MaBibli.Client/Pages/AjoutIsbn.razor index 5cb7c18..d2e25e5 100644 --- a/MaBibli.Client/Pages/AjoutIsbn.razor +++ b/MaBibli.Client/Pages/AjoutIsbn.razor @@ -72,6 +72,10 @@ question. *@ Saisir un livre Saisir une revue + + @* Une pile de livres et une douchette : on collecte d'abord, on traite ensuite. + Reste ici, et non dans le menu : c'est une façon d'ajouter, pas une destination. *@ + Cataloguer en rafale } @@ -225,7 +229,29 @@ private EnregistrementLivre _saisie = new(); private DoublonsLivre? _doublons; - protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + /// + /// Code transmis par un autre écran — un résidu de rafale à trancher. + /// + /// + /// ⚠️ La recherche s'enchaîne d'elle-même, comme après un scan : arriver ici depuis un + /// compte rendu de rafale signifie qu'on a déjà décidé de reprendre ce code. Demander un + /// clic de plus sur « Chercher » serait le détour que cet écran documente déjà comme un + /// défaut (voir l'étape « Recherche »). + /// + [SupplyParameterFromQuery(Name = "code")] + public string? CodePropose { get; set; } + + protected override void OnInitialized() + { + Reseau.Change += SurChangementReseau; + + if (!string.IsNullOrWhiteSpace(CodePropose)) + { + _isbn = CodePropose.Trim(); + } + } + + private bool _repriseFaite; /// /// Donne le focus au champ ISBN dès que l'étape de saisie s'affiche. @@ -237,6 +263,29 @@ /// protected override async Task OnAfterRenderAsync(bool premierRendu) { + // ⚠️ AVANT le garde de focus ci-dessous, qui rend la main dès que le focus est posé : + // greffée après, la reprise ne serait jouée qu'une fois sur deux, selon l'étape. + // + // ⚠️ Et surtout PAS conditionnée à `premierRendu` : au premier rendu, l'état réseau + // n'est pas encore établi (il s'initialise de façon asynchrone), donc `EnLigne` est + // faux et la reprise ne partait jamais. Constaté à l'écran — le champ se préremplissait + // bien, la recherche ne s'enchaînait pas. C'est `_repriseFaite` qui garantit l'unicité, + // pas le premier rendu ; l'événement réseau provoque le rendu où la condition passe. + if (!_repriseFaite && !string.IsNullOrWhiteSpace(CodePropose) && Reseau.EnLigne) + { + _repriseFaite = true; + await ChercherAsync(); + + // ⚠️ `StateHasChanged` est INDISPENSABLE ici, et son absence ne se voit pas : + // `OnAfterRenderAsync` n'est pas un gestionnaire d'événement, donc Blazor ne + // redessine rien de lui-même. La recherche partait bien — sonde à l'appui — et + // remplissait `_candidats`, mais l'écran restait sur l'étape de saisie. + // Même famille que « un composant qui écrit dans un objet prêté doit prévenir son + // parent » : le symptôme trompe, on cherche un appel qui n'a pas eu lieu là où il + // n'y a qu'un rendu manquant. + StateHasChanged(); + } + if (_etape != Etape.Saisie || !_focusAFaire) { return; diff --git a/MaBibli.Client/Pages/AjoutManuel.razor b/MaBibli.Client/Pages/AjoutManuel.razor index 3721c6c..fabca1c 100644 --- a/MaBibli.Client/Pages/AjoutManuel.razor +++ b/MaBibli.Client/Pages/AjoutManuel.razor @@ -38,7 +38,26 @@ else @code { private readonly EnregistrementLivre _saisie = new(); - protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + /// + /// ISBN transmis par un autre écran — un résidu de rafale qu'aucune source ne connaît. + /// + /// + /// ⚠️ Sans cette reprise, le lien du compte rendu de rafale menait à un formulaire + /// vide : il fallait retaper le code qu'on venait de scanner, c'est-à-dire refaire + /// exactement ce que la rafale existe pour éviter. Le lien compilait et ne servait à rien. + /// + [SupplyParameterFromQuery(Name = "isbn")] + public string? IsbnPropose { get; set; } + + protected override void OnInitialized() + { + Reseau.Change += SurChangementReseau; + + if (!string.IsNullOrWhiteSpace(IsbnPropose)) + { + _saisie.Isbn = IsbnPropose.Trim(); + } + } private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged); diff --git a/MaBibli.Client/Pages/AjoutRafale.razor b/MaBibli.Client/Pages/AjoutRafale.razor new file mode 100644 index 0000000..ebbcfb5 --- /dev/null +++ b/MaBibli.Client/Pages/AjoutRafale.razor @@ -0,0 +1,741 @@ +@page "/ajout/rafale" +@inject ServiceLivresApi Api +@inject CacheHorsLigne Cache +@inject EtatReseau Reseau +@inject NavigationManager Nav +@implements IDisposable + +MaBibli — cataloguer en rafale + +

    Cataloguer en rafale

    + +@* + Lancée depuis une saga, la rafale rattache chaque livre créé à la série. ⚠️ La cible vit + dans la FILE (Rafale.SerieId), pas dans cet écran : une rafale se reprend plus tard, et la + reprise doit rattacher au même endroit. +*@ +@if (SerieVisee is { } visee) +{ +

    + Les livres créés seront ajoutés à la série + @visee.Titre. +

    +} + +@* + Le geste visé : une pile de livres et une douchette. Celle-ci se présente au système comme + un CLAVIER — elle tape les chiffres puis « Entrée ». Or dans une zone de texte multiligne, + « Entrée » fait un saut de ligne : la rafale se collecte donc toute seule, sans mécanique + dédiée. C'est ce qui rend cet écran bien moins coûteux qu'une file interactive. +*@ + +@if (_erreur is { } erreur) +{ + +} + +@if (_repriseProposee) +{ + @* + ⚠️ Sans cette proposition, la file existerait en base et personne ne la retrouverait : + on rescannerait tout. C'est la contrepartie directe de « reprendre plus tard ». + *@ +
    + @* + ⚠️ La file est proposée MÊME TERMINÉE (2026-09-08). Elle ne l'était que s'il restait + à traiter ou des résidus : une rafale entièrement réussie devenait donc inatteignable + alors qu'elle dormait dans IndexedDB, et la liste des livres créés — avec ses liens + vers chaque fiche — était perdue au premier changement d'écran. + *@ + @if (_rafale.ResteATraiter || _rafale.Residus.Count > 0) + { +

    + Une rafale de @_rafale.Total code@(_rafale.Total > 1 ? "s" : "") + est restée en cours@(_rafale.Traites > 0 ? $", {_rafale.Traites} déjà traité{(_rafale.Traites > 1 ? "s" : "")}" : ""). +

    + } + else + { +

    + Votre dernière rafale est terminée : + @_rafale.Crees livre@(_rafale.Crees > 1 ? "s" : "") ajouté@(_rafale.Crees > 1 ? "s" : ""). + Son compte rendu, et les liens vers chaque fiche, sont encore là. +

    + } + +
    + + +
    +
    +} +else if (_phase == Phase.Collecte) +{ +

    + Scannez les codes-barres à la suite : chaque lecture ajoute une ligne. Vous pouvez aussi + les taper ou les coller, un par ligne. Rien n'est envoyé avant que vous ne lanciez le + traitement. +

    + + @* + ⚠️ La zone est focalisée à l'ouverture, comme le champ ISBN de l'écran d'ajout : une + douchette « tape » dès qu'on scanne, et sans le focus le premier code partait dans le + vide. C'est une étape de moins, et c'est celle qu'on oublie en ayant les mains prises. + + ⚠️ Le focus ne se reprend pas à chaque rendu (`_focusAFaire`) : il serait arraché à + chaque frappe, et la zone se réécrit à chaque caractère. + *@ +
    + + +
    + +

    + @{ + var codes = SaisieRafale.Decouper(_saisie).Count; + } + @if (codes == 0) + { + Aucun code pour l'instant. + } + else + { + @codes code@(codes > 1 ? "s" : "") — les doublons de saisie sont écartés. + } +

    + + @* + Cochée par défaut : cataloguer cinquante livres ne doit pas être interrompu cinquante + fois. ⚠️ Mais le compte rendu DIT combien ont été passés — posséder deux exemplaires est + légitime, et passer en silence contredirait cet esprit. + *@ + + +
    + +
    + + @if (!Reseau.EnLigne) + { + @* + La COLLECTE marche hors-ligne, pas le traitement : c'est le seul endroit du projet + où l'on accumule quelque chose sans réseau. ⚠️ Ce ne sont pas des écritures en + attente, ce sont des codes à interroger — rien n'est promis à la base. + *@ +

    + Vous pouvez scanner sans réseau : la liste est conservée sur cet appareil. Le + traitement, lui, interroge la BnF et attendra le retour du réseau. +

    + } +} +else if (_phase == Phase.Traitement) +{ +

    Traitement en cours

    + +
    +
    +
    + +

    + @_rafale.Pourcentage % — @_rafale.Traites sur @_rafale.Total + @if (_codeEnCours is { } encours) + { + · @FormatageIsbn.Afficher(encours) + } +

    + +
    + +
    + +

    + Vous pourrez reprendre plus tard : ce qui est déjà enregistré ne sera pas refait. +

    +} +else +{ +

    Compte rendu

    + +
      +
    • @_rafale.Crees livre@(_rafale.Crees > 1 ? "s" : "") ajouté@(_rafale.Crees > 1 ? "s" : "")
    • + @if (_rafale.Passes > 0) + { + @* + ⚠️ Se COMPTE, ne se tait pas : sans cette ligne on croirait avoir ajouté + cinquante livres alors qu'on en a ajouté quarante-deux. + *@ +
    • @_rafale.Passes déjà au catalogue, passé@(_rafale.Passes > 1 ? "s" : "")
    • + } + @if (_rafale.Residus.Count > 0) + { +
    • @_rafale.Residus.Count à regarder ci-dessous
    • + } +
    + + @* + ⚠️ La liste des livres ajoutés est la moitié utile du compte rendu : sans elle, on sait + qu'on a créé vingt fiches et l'on n'a aucun moyen de les retrouver autrement qu'en + fouillant le catalogue à la main. Or c'est juste après la rafale qu'on veut les + compléter — un scan ne donne ni type de document, ni thèmes, ni série. + + Chaque ligne mène donc à sa fiche, et un bouton mène droit à son écran d'édition. + *@ + @if (Ajoutes.Count > 0) + { +

    Livres ajoutés

    + + + } + + @if (_rafale.ResteATraiter) + { +
    + +
    + } + + @if (_rafale.Residus.Count > 0) + { +

    À reprendre

    + +
      + @foreach (var ligne in _rafale.Residus) + { +
    • +
      + @FormatageIsbn.Afficher(ligne.Code) + @Libelle(ligne.Etat) +
      + + @if (!string.IsNullOrWhiteSpace(ligne.Titre)) + { +

      @ligne.Titre

      + } + + @if (!string.IsNullOrWhiteSpace(ligne.Motif)) + { +

      @ligne.Motif

      + } + +
      + @* + Chaque résidu renvoie vers l'écran qui sait le traiter, plutôt que + de réimplémenter ici le choix d'édition ou la fiche revue. + *@ + + @(ligne.Etat == EtatCodeRafale.Revue ? "Ouvrir la revue" : "Reprendre") + + + +
      +
    • + } +
    + } + +

    + Cette liste reste consultable en revenant sur cet écran, tant que vous ne l'avez pas + vidée. +

    + +
    + @* Venu d'une saga, on y retourne : c'est là qu'on voit ce qui manque encore. *@ + @if (SerieVisee is { } retour) + { + + Revenir à @retour.Titre + + } + + +
    +} + +@code { + private enum Phase { Collecte, Traitement, Bilan } + + /// + /// Série d'où l'on vient, quand la rafale a été lancée depuis une saga. + /// + /// + /// ⚠️ Ce paramètre ne sert qu'à amorcer la file : une fois lancée, c'est + /// Rafale.SerieId qui commande, sans quoi une reprise ouverte depuis une autre + /// adresse rattacherait ailleurs. + /// + [SupplyParameterFromQuery(Name = "serie")] + public int? SerieId { get; set; } + + private SerieDto? _serie; + + private Phase _phase = Phase.Collecte; + private Rafale _rafale = new(); + private string _saisie = string.Empty; + private bool _passerLesDoublons = true; + private bool _repriseProposee; + private bool _interrompu; + private string? _codeEnCours; + private string? _erreur; + + /// + /// ISBN déjà au catalogue, sous leurs DEUX formes. + /// + /// + /// ⚠️ Lu une seule fois par traitement, et volontairement : le catalogue ne bouge que par + /// nos propres créations, qu'on y ajoute au fil de l'eau. + /// + private HashSet _isbnDuCatalogue = new(StringComparer.Ordinal); + + private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; + + /// La série à laquelle rattacher : celle de la file en cours, sinon celle de l'adresse. + private SerieDto? SerieVisee => _serie; + + private int? CibleSerie => _rafale.SerieId ?? SerieId; + + /// + /// Nom de la série visée, pour le dire plutôt que de rattacher en silence. + /// + /// + /// Lu depuis l'instantané des séries, donc sans coût ni dépendance au réseau. Une lecture + /// ratée ne retire rien : le rattachement, lui, ne dépend pas de ce nom. + /// + private async Task ChargerSerieAsync() + { + if (CibleSerie is not { } id) + { + _serie = null; + return; + } + + try + { + _serie = (await Api.ListerSeriesAsync()).FirstOrDefault(s => s.Id == id); + } + catch (Exception) + { + _serie = null; + } + } + + /// Zone de collecte, focalisée à l'ouverture pour qu'une douchette y tape d'emblée. + private ElementReference _zoneCodes; + + private bool _focusAFaire = true; + + /// Les livres réellement créés par cette rafale, dans l'ordre où ils ont été scannés. + private IReadOnlyList Ajoutes => + [.. _rafale.Codes.Where(c => c.Etat == EtatCodeRafale.Cree && c.LivreId is not null)]; + + /// + /// ⚠️ Une seule fois par entrée dans l'étape de collecte : reprendre le focus à chaque rendu + /// arracherait le curseur en pleine frappe, et la zone se réécrit à chaque caractère. + /// + protected override async Task OnAfterRenderAsync(bool premierRendu) + { + if (_focusAFaire && _phase == Phase.Collecte && !_repriseProposee) + { + _focusAFaire = false; + await _zoneCodes.FocusAsync(); + } + } + + protected override async Task OnInitializedAsync() + { + Reseau.Change += SurReseau; + + // ⚠️ Une rafale inachevée doit se proposer d'elle-même : sans cela, elle existe dans + // IndexedDB et personne ne la retrouve. + // ⚠️ Toute file non vidée est proposée, terminée ou non : c'est le seul chemin de retour + // vers la liste des livres qu'une rafale vient de créer. + var reprise = await Cache.LireRafaleAsync(); + if (reprise is { Total: > 0 }) + { + _rafale = reprise; + _passerLesDoublons = reprise.PasserLesDoublons; + _repriseProposee = true; + } + + await ChargerSerieAsync(); + } + + public void Dispose() => Reseau.Change -= SurReseau; + + private void SurReseau() => InvokeAsync(StateHasChanged); + + private void Reprendre() + { + _repriseProposee = false; + _phase = Phase.Bilan; + } + + private async Task AbandonnerAsync() + { + await Cache.EffacerRafaleAsync(); + _rafale = new Rafale(); + _saisie = string.Empty; + _repriseProposee = false; + _phase = Phase.Collecte; + + // On revient à une zone vide : c'est une nouvelle rafale, elle mérite le même focus + // que la première. + _focusAFaire = true; + + // ⚠️ La file effacée emportait sa série : sans cette relecture, le bandeau continuait + // d'annoncer « les livres seront ajoutés à la série X » pour une rafale neuve qui, + // ouverte sans paramètre d'adresse, ne rattachera nulle part. + await ChargerSerieAsync(); + } + + private async Task LancerAsync() + { + var codes = SaisieRafale.Decouper(_saisie); + if (codes.Count == 0) + { + return; + } + + _rafale = new Rafale + { + Codes = [.. codes.Select(c => new CodeRafale { Code = c })], + PasserLesDoublons = _passerLesDoublons, + SerieId = SerieId, + }; + + await Cache.EcrireRafaleAsync(_rafale); + await TraiterAsync(); + } + + /// + /// Traite la file, un code à la fois. + /// + /// + /// ⚠️ Un par un, délibérément. Cinquante livres font jusqu'à cent requêtes — chaque + /// ISBN est cherché en 13 puis en 10 — et c'est le SERVEUR qui appelle la BnF. Les lancer + /// ensemble lui ferait ouvrir cent connexions vers catalogue.bnf.fr. Séquentiel, la + /// progression se voit et une coupure ne perd que le code en cours. + /// + /// La file est réécrite après chaque code : c'est ce qui rend la reprise exacte. + /// + /// + private async Task TraiterAsync() + { + _phase = Phase.Traitement; + _interrompu = false; + _erreur = null; + + await ChargerIsbnDuCatalogueAsync(); + + for (var i = 0; i < _rafale.Codes.Count; i++) + { + if (_interrompu || !Reseau.EnLigne) + { + break; + } + + if (_rafale.Codes[i].Etat != EtatCodeRafale.ATraiter) + { + continue; // Déjà traité : une reprise ne refait jamais ce qui est fait. + } + + _codeEnCours = _rafale.Codes[i].Code; + StateHasChanged(); + + _rafale.Codes[i] = await TraiterUnAsync(_rafale.Codes[i]); + + await Cache.EcrireRafaleAsync(_rafale); + StateHasChanged(); + } + + _codeEnCours = null; + _phase = Phase.Bilan; + } + + /// + /// Les ISBN déjà possédés, dans les deux formes, pour reconnaître un doublon sans requête. + /// + /// + /// ⚠️ Les deux formes sont indispensables : un livre saisi avant 2007 porte un + /// ISBN-10 en base, alors qu'un scanner lit toujours un EAN-13. Ne comparer qu'une forme + /// ferait rescanner tout le fonds ancien comme s'il était neuf — c'est le même piège que + /// celui de la recherche BnF, à l'autre bout de la chaîne. + /// + private async Task ChargerIsbnDuCatalogueAsync() + { + try + { + var livres = await Api.ListerAsync(new CritereLivres()); + _isbnDuCatalogue = new HashSet(StringComparer.Ordinal); + + foreach (var isbn in livres.Select(l => IsbnUtils.Normaliser(l.Isbn)).OfType()) + { + _isbnDuCatalogue.Add(isbn); + + if (IsbnUtils.TryConvertirEnIsbn10(isbn, out var court) && court is not null) + { + _isbnDuCatalogue.Add(court); + } + } + } + catch (Exception) + { + // Sans cette liste, on retombe simplement sur la détection du serveur à la + // création : plus lente, mais jamais fausse. + _isbnDuCatalogue = []; + } + } + + private bool DejaAuCatalogue(string code) + { + if (_isbnDuCatalogue.Contains(code)) + { + return true; + } + + return IsbnUtils.TryConvertirEnIsbn10(code, out var court) + && court is not null + && _isbnDuCatalogue.Contains(court); + } + + private async Task TraiterUnAsync(CodeRafale code) + { + // ⚠️ AVANT le lookup, et avant le choix d'édition. Constaté à l'écran : sans ce test, + // un livre déjà possédé dont la BnF rend trois notices ressortait « à choisir » — on + // demandait de trancher l'édition d'un livre qu'on allait de toute façon passer. + // Épargne au passage une requête BnF par livre déjà catalogué. + if (_rafale.PasserLesDoublons && DejaAuCatalogue(code.Code)) + { + return code with + { + Etat = EtatCodeRafale.Passe, + Motif = "Déjà au catalogue.", + }; + } + + ResultatLookupIsbn? lookup; + try + { + lookup = await Api.ChercherIsbnAsync(code.Code); + } + catch (Exception) + { + return code with + { + Etat = EtatCodeRafale.Echec, + Motif = "La recherche n'a pas abouti. Réessayez plus tard.", + }; + } + + if (lookup is null) + { + return code with + { + Etat = EtatCodeRafale.Echec, + Motif = "La recherche n'a pas abouti. Réessayez plus tard.", + }; + } + + // Un préfixe 977 est un périodique : il porte un ISSN, donc un titre de revue, et son + // numéro se saisit à la main. Il ne peut pas suivre le chemin des livres. + if (lookup.Periodique is { } revue) + { + return code with + { + Etat = EtatCodeRafale.Revue, + Titre = revue.Titre, + Motif = "Magazine : le numéro de parution se saisit à la main.", + }; + } + + if (lookup.Candidats.Count == 0) + { + return code with + { + Etat = EtatCodeRafale.Introuvable, + Motif = "Aucune source ne connaît ce code. À saisir à la main.", + }; + } + + // ⚠️ Plusieurs notices : on NE choisit PAS à la place de l'utilisateur. C'est la règle + // actée « notices multiples : demander systématiquement », et elle vaut ici aussi — + // sauf qu'on demande à la fin, pas au milieu de la rafale. + if (lookup.Candidats.Count > 1) + { + return code with + { + Etat = EtatCodeRafale.AChoisir, + Titre = lookup.Candidats[0].Titre, + Motif = $"{lookup.Candidats.Count} éditions possibles : à choisir.", + }; + } + + var candidat = lookup.Candidats[0]; + + // Même construction que l'écran d'ajout unitaire : `Format.Physique` parce qu'un livre + // scanné est un objet qu'on tient, et `Statut.ALire` parce qu'on vient de l'acquérir. + var saisie = new EnregistrementLivre + { + Isbn = code.Code, + Titre = candidat.Titre, + Auteur = candidat.Auteur, + Editeur = candidat.Editeur, + NombrePages = candidat.NombrePages, + CoverUrl = candidat.CoverUrl, + UrlNotice = candidat.UrlNotice, + Format = Format.Physique, + Statut = Statut.ALire, + }; + + var resultat = await Api.CreerAsync(saisie); + + if (resultat.Doublons is { } doublons) + { + // Passer est le défaut, mais cela se compte : voir le compte rendu. + return _rafale.PasserLesDoublons + ? code with + { + Etat = EtatCodeRafale.Passe, + Titre = candidat.Titre, + Motif = doublons.Message, + } + : code with + { + Etat = EtatCodeRafale.AChoisir, + Titre = candidat.Titre, + Motif = doublons.Message, + }; + } + + if (resultat.Livre is { } livre) + { + // Un code scanné deux fois dans la MÊME rafale est déjà écarté par la découpe ; + // ceci couvre le cas de deux codes différents qui désignent le même livre. + _isbnDuCatalogue.Add(code.Code); + + return code with + { + Etat = EtatCodeRafale.Cree, + Titre = livre.Titre, + LivreId = livre.Id, + Motif = await RattacherAsync(livre), + }; + } + + return code with + { + Etat = EtatCodeRafale.Echec, + Titre = candidat.Titre, + Motif = resultat.Erreur ?? "L'enregistrement n'a pas abouti.", + }; + } + + /// + /// Range le livre créé dans la série visée, s'il y en a une. + /// + /// + /// ⚠️ Un rattachement raté ne remet pas la ligne en échec : le livre existe, et le + /// redire « à traiter » le recréerait à la reprise. On le dit dans le compte rendu, où la + /// ligne mène déjà à sa fiche — c'est là qu'on répare, en un geste. + /// + private async Task RattacherAsync(LivreDto livre) + { + if (_rafale.SerieId is not { } serieId) + { + return null; + } + + var resultat = await Api.AjouterElementSerieAsync( + serieId, new AjoutElementSerie { LivreId = livre.Id }); + + return resultat.EstOk + ? null + : "Livre enregistré, mais il n'a pas pu être ajouté à la série."; + } + + private async Task RetirerAsync(CodeRafale code) + { + _rafale.Codes.RemoveAll(c => c.Code == code.Code); + await Cache.EcrireRafaleAsync(_rafale); + } + + /// + /// Où reprendre un résidu — l'écran qui sait déjà traiter ce cas. + /// + /// + /// ⚠️ On ne réimplémente ici ni le choix d'édition, ni la fiche revue, ni la saisie + /// manuelle : trois copies d'un écran existant divergeraient. + /// + private static string LienDeReprise(CodeRafale code) => code.Etat switch + { + EtatCodeRafale.Introuvable => $"/ajout/manuel?isbn={code.Code}", + _ => $"/ajout/isbn?code={code.Code}", + }; + + private static string Libelle(EtatCodeRafale etat) => etat switch + { + EtatCodeRafale.AChoisir => "Plusieurs éditions", + EtatCodeRafale.Introuvable => "Introuvable", + EtatCodeRafale.Revue => "Magazine", + EtatCodeRafale.Echec => "Échec", + _ => string.Empty, + }; + + private static string ClasseEtat(EtatCodeRafale etat) => etat switch + { + EtatCodeRafale.Revue => "etiquette-type", + EtatCodeRafale.Echec => "etiquette-manquant", + _ => "etiquette-prete", + }; +} diff --git a/MaBibli.Client/Pages/Catalogue.razor b/MaBibli.Client/Pages/Catalogue.razor index c2a372b..d5c8d8a 100644 --- a/MaBibli.Client/Pages/Catalogue.razor +++ b/MaBibli.Client/Pages/Catalogue.razor @@ -1,6 +1,7 @@ @page "/" @inject ServiceLivresApi Api @inject EtatReseau Reseau +@inject NavigationManager Navigation @implements IDisposable MaBibli — catalogue @@ -11,14 +12,36 @@ rarement. Le compteur sur le bouton est ce qui rend le repli sans danger — un filtre actif et replié serait invisible, et l'on conclurait que des livres ont disparu. *@ +@* + ⚠️ « Ajouter un ouvrage » est ICI, à côté de « Filtrer », et non plus dans une barre + flottante en bas : c'est le geste le plus fréquent après la recherche, et les deux boutons + du haut se manœuvrent d'une main. Il n'y en a qu'UN — deux entrées identiques sur le même + écran seraient du bruit, et le projet désencombre par le regroupement, jamais par + l'effacement. + + ⚠️ Hors-ligne il est DÉSACTIVÉ, pas masqué : un bouton disparu est indiscernable d'une + fonction supprimée, et rien n'apprendrait à l'utilisateur que c'est le réseau. +*@

    Catalogue

    - +
    + + + @if (Reseau.EnLigne) + { + Ajouter un ouvrage + } + else + { + + } +
    @if (_auteur is not null) @@ -30,11 +53,28 @@

    } -
    - + + +
    +@if (_scanOuvert) +{ + +} + @if (_filtresOuverts) {
    @@ -110,6 +150,32 @@
    } + @* + « Quels livres me reste-t-il à illustrer ? » — la seule question à laquelle ce + filtre répond, et la raison pour laquelle il existe. + + ⚠️ « Sans couverture » veut dire « sans URL enregistrée », PAS « dont l'image ne + se charge pas ». Savoir la seconde demanderait d'interroger chaque URL, ce que + CLAUDE.md interdit : OpenLibrary rend des 502 intermittents, et le même livre + entrerait et sortirait du filtre d'un rafraîchissement à l'autre. + + ⚠️ La ligne disparaît quand le fonds est homogène — tout illustré, ou rien. + Contrairement aux formats, les DEUX extrêmes sont inutiles ici : si aucun livre + n'a de couverture, la liste complète EST déjà celle des livres à illustrer. + Et comme partout, cela ne se déduit QUE d'un chargement sans critère. + *@ + @if (CouverturesInegales) + { +
    + + + +
    + } + @* ⚠️ Grouper ne CACHE rien — c'est pourquoi cette bascule n'entre pas dans le compteur du bouton « Filtrer », qui ne compte que ce qui rendrait des livres invisibles. Elle @@ -168,7 +234,7 @@ else if (_livres is not null) { @if (entree.EstGroupe) { - + } else { @@ -180,29 +246,6 @@ else if (_livres is not null) } -@* - Une seule entrée d'ajout, et plus aucun lien de navigation : les quatre écrans sont dans - les onglets, en bas. - - « Saisie manuelle » est retirée d'ici — elle reste accessible un écran plus loin, depuis - « Ajouter », qui la propose dès qu'un ISBN ne donne rien. - - ⚠️ Hors-ligne le bouton est DÉSACTIVÉ, pas masqué : un bouton disparu est indiscernable - d'une fonction supprimée, et rien n'apprendrait à l'utilisateur que c'est le réseau. Le - désencombrement demandé passe par le regroupement (deux boutons en un), pas par l'effacement. -*@ - - @code { /// Restreint le catalogue à un auteur. Vient de « ?auteur=… », posé par les liens de la liste. [SupplyParameterFromQuery(Name = "auteur")] @@ -236,6 +279,16 @@ else if (_livres is not null) /// private readonly HashSet _replies = []; + /// + /// Ce que chaque série possède et recense, descendance comprise. + /// + /// + /// ⚠️ Recalculé seulement quand les séries changent, et non à chaque chargement du + /// catalogue : celui-ci se relit à chaque frappe dans la recherche. + /// + private IReadOnlyDictionary _totauxSeries = + new Dictionary(); + /// Le catalogue tel qu'il s'affiche : livres seuls et blocs de série mêlés. private IReadOnlyList Entrees => GroupementCatalogue.Grouper(_livres ?? [], _grouper ? _series : null); @@ -246,6 +299,8 @@ else if (_livres is not null) private TypeDocument? _type; private Statut? _statut; private bool? _prete; + + private bool? _sansCouverture; private bool _chargement; private bool _filtresOuverts; private string? _erreur; @@ -277,6 +332,19 @@ else if (_livres is not null) private bool QuelqueChoseEstPrete => _fondsAvecPret is not false; + /// + /// Vrai si le fonds contient à la fois des livres avec et sans couverture — ou qu'on ne le + /// sait pas encore. + /// + /// + /// ⚠️ Il faut les DEUX, contrairement au filtre de prêt qui se contente d'un prêt en cours : + /// sur un fonds entièrement dépourvu de couvertures, « sans couverture » rendrait la liste + /// entière, et ne serait donc pas un filtre. Dans le doute, la ligne reste affichée. + /// + private bool? _fondsCouverturesInegales; + + private bool CouverturesInegales => _fondsCouverturesInegales is not false; + /// /// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ». /// @@ -287,14 +355,15 @@ else if (_livres is not null) /// private int FiltresActifs => (_format is null ? 0 : 1) + (_type is null ? 0 : 1) + (_statut is null ? 0 : 1) - + (_prete is null ? 0 : 1); + + (_prete is null ? 0 : 1) + (_sansCouverture 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 || _type is not null - || _statut is not null || _prete is not null || AuteurId is not null; + || _statut is not null || _prete is not null || _sansCouverture is not null + || AuteurId is not null; /// /// La liste se relit à chaque bascule du réseau : au retour, pour reprendre les données du @@ -315,10 +384,12 @@ else if (_livres is not null) try { _series = await Api.ListerSeriesAsync(); + _totauxSeries = TotauxSeries.Calculer(_series); } catch (Exception) { _series = null; + _totauxSeries = new Dictionary(); } } @@ -351,6 +422,48 @@ else if (_livres is not null) await ChargerAsync(); } + /// Le scanner est ouvert au-dessus de la liste, pour retrouver un livre en main. + private bool _scanOuvert; + + /// + /// Un code lu remplit la recherche, exactement comme une douchette l'aurait tapé. + /// + /// + /// Le code reste dans le champ : un décodage erroné se corrige et se relance, comme sur + /// l'écran d'ajout. ⚠️ L'add-on EAN-2 est ignoré — il ne concerne que les revues, qui ne + /// sont pas au catalogue. + /// + private async Task SurCodeScanneAsync(CodeScanne lecture) + { + _scanOuvert = false; + _recherche = lecture.Code; + await ChargerAsync(); + } + + /// + /// Ouvre la fiche quand le code désigne un seul livre du catalogue. + /// + /// + /// C'est tout l'intérêt du scan : on tient le livre, on veut sa fiche, pas une liste d'un + /// élément à cliquer. + /// + /// ⚠️ Deux conditions, et les deux comptent. Un seul résultat, sinon on choisirait à + /// la place de l'utilisateur — deux exemplaires du même ISBN sont légitimes. Et un + /// code complet et valide : sur un fragment en cours de frappe, le résultat unique + /// est un hasard, et l'écran sauterait sous les doigts. + /// + /// + private bool OuvrirSiUnique() + { + if (_livres is not { Count: 1 } || !RechercheIsbn.EstCodeComplet(_recherche)) + { + return false; + } + + Navigation.NavigateTo($"livres/{_livres[0].Id}"); + return true; + } + private Task FiltrerFormat(Format? format) { _format = format; @@ -375,6 +488,12 @@ else if (_livres is not null) return ChargerAsync(); } + private Task FiltrerCouverture(bool? sansCouverture) + { + _sansCouverture = sansCouverture; + return ChargerAsync(); + } + /// Vrai quand la liste demandée est le fonds entier, sans restriction d'aucune sorte. private static bool EstSansCritere(CritereLivres criteres) => string.IsNullOrWhiteSpace(criteres.Recherche) @@ -382,6 +501,7 @@ else if (_livres is not null) && criteres.TypeDocument is null && criteres.Statut is null && criteres.Prete is null + && criteres.SansCouverture is null && criteres.AuteurId is null; private async Task ChargerAsync() @@ -403,16 +523,25 @@ else if (_livres is not null) TypeDocument = _type, Statut = _statut, Prete = _prete, + SansCouverture = _sansCouverture, AuteurId = AuteurId, }; _livres = await Api.ListerAsync(criteres, jeton); + if (OuvrirSiUnique()) + { + return; + } + if (EstSansCritere(criteres)) { _formatsDuFonds = _livres.Select(l => l.Format).ToHashSet(); _typesDuFonds = _livres.Select(l => l.TypeDocument).ToHashSet(); _fondsAvecPret = _livres.Any(l => l.PreteA is not null); + _fondsCouverturesInegales = + _livres.Any(l => string.IsNullOrEmpty(l.CoverUrl)) + && _livres.Any(l => !string.IsNullOrEmpty(l.CoverUrl)); } } catch (OperationCanceledException) diff --git a/MaBibli.Client/Pages/FicheLivre.razor b/MaBibli.Client/Pages/FicheLivre.razor index 866ec76..04bf3a7 100644 --- a/MaBibli.Client/Pages/FicheLivre.razor +++ b/MaBibli.Client/Pages/FicheLivre.razor @@ -91,8 +91,17 @@ else }
    + @* + ⚠️ Une couverture CASSÉE devient un point de correction : cliquer dessus ouvre son + adresse, focalisée, et « Entrée » enregistre. C'est là qu'on constate le défaut, et + c'est donc là qu'il faut pouvoir le réparer — passer par « Éditer » puis descendre + au dernier champ du formulaire pour corriger un lien mort était un détour. + + Une couverture qui s'affiche, elle, s'agrandit comme avant. + *@ + Classe="couverture-grande" Agrandissable="true" + OnCorriger="OuvrirCorrectionCouverture" />

    @_livre.Titre

    @@ -181,6 +190,32 @@ else
    + @if (_correctionCouverture) + { +
    + + + + + +
    + +

    + Collez l'adresse d'une image, puis Entrée. Vider le champ retire la couverture. +

    + } + @* Hors « Éditer » volontairement : changer son statut est l'action la plus fréquente de l'application, et elle ne touche qu'à MA lecture, jamais à la fiche commune. *@
    @@ -368,6 +403,83 @@ else ///
    private async Task RechargerAsync() => _livre = await Api.ObtenirAsync(Id) ?? _livre; + /// Champ d'adresse de la couverture, ouvert depuis l'image cassée elle-même. + private bool _correctionCouverture; + + private string _urlCouverture = string.Empty; + + private ElementReference _champUrlCouverture; + + private bool _focusCouverture; + + private void OuvrirCorrectionCouverture() + { + _erreur = null; + _urlCouverture = _livre?.CoverUrl ?? string.Empty; + _correctionCouverture = true; + + // ⚠️ Le focus est demandé UNE fois, au prochain rendu : le reprendre à chaque rendu + // l'arracherait pendant qu'on colle ou qu'on tape. + _focusCouverture = true; + } + + protected override async Task OnAfterRenderAsync(bool premierRendu) + { + if (_focusCouverture) + { + _focusCouverture = false; + await _champUrlCouverture.FocusAsync(); + } + } + + private async Task SurToucheCouverture(KeyboardEventArgs e) + { + if (e.Key == "Enter") + { + await EnregistrerCouvertureAsync(); + } + else if (e.Key is "Escape" or "Esc") + { + _correctionCouverture = false; + } + } + + /// + /// Enregistre la seule adresse de couverture, sans passer par l'écran d'édition. + /// + /// + /// Même exception que le statut de lecture et les prêts : ce n'est pas « modifier la fiche », + /// c'est réparer ce qu'on a sous les yeux. La saisie du formulaire d'édition est mise à jour + /// dans la foulée, sinon un passage ultérieur en édition réécrirait l'ancienne adresse. + /// + private async Task EnregistrerCouvertureAsync() + { + if (_saisie is null) + { + return; + } + + var url = _urlCouverture.Trim(); + _saisie.CoverUrl = string.IsNullOrEmpty(url) ? null : url; + + _enregistrement = true; + _erreur = null; + + var resultat = await Api.ModifierAsync(Id, _saisie); + + _enregistrement = false; + + if (!resultat.EstOk) + { + _erreur = resultat.Erreur; + return; + } + + _livre = resultat.Valeur; + _saisie = Recopier(_livre!); + _correctionCouverture = false; + } + private async Task ChangerStatutAsync(Statut? statut) { _statutEnCours = true; diff --git a/MaBibli.Client/Pages/Revue.razor b/MaBibli.Client/Pages/Revue.razor index 5f61f1b..459627d 100644 --- a/MaBibli.Client/Pages/Revue.razor +++ b/MaBibli.Client/Pages/Revue.razor @@ -189,15 +189,53 @@ else } else { - @* La couverture d'un numéro ne vient d'aucune source — l'ISSN désigne la - revue, pas la parution : sans URL collée, c'est le substitut à - initiale qui s'affiche, et il n'est pas cliquable. *@ + @* + La couverture d'un numéro ne vient d'AUCUNE source — l'ISSN désigne + la revue, pas la parution : elle se colle à la main, ou il n'y en a + pas. C'est donc ici que le clic pour la poser sert le plus. + + ⚠️ Même principe que la fiche livre : cliquer une couverture absente + ou cassée ouvre son adresse, focalisée, et Entrée enregistre. Ce + n'est pas « modifier le numéro » — c'est réparer ce qu'on a sous les + yeux, et c'est pourquoi ce geste-là existe aussi en consultation, + contrairement à « Modifier » et « Retirer ». + *@ + Classe="couverture-petite" Agrandissable="true" + OnCorriger="() => OuvrirCorrectionCouverture(numero)" />

    N° @numero.Numero

    + @if (_correctionCouverture == numero.Id) + { +
    + + + + + +
    + +

    + Collez l'adresse d'une image, puis Entrée. Vider le champ + retire la couverture. +

    + } +

    @if (numero.DateParution is { } parution) { @@ -300,6 +338,15 @@ else ///

    Numéro dont le retrait attend confirmation — null = aucun. private int? _confirmeRetrait; + /// Numéro dont l'adresse de couverture est ouverte — null = aucun. + private int? _correctionCouverture; + + private string _urlCouverture = string.Empty; + + private ElementReference _champUrlCouverture; + + private bool _focusCouverture; + /// /// Écran d'édition de la revue : nom, ISSN, éditeur, suppression, et la seule place où un /// numéro se modifie ou se retire. @@ -440,6 +487,87 @@ else private void FermerEdition() => _edition = null; + /// + /// Ouvre l'adresse de la couverture d'un numéro, depuis l'image elle-même. + /// + /// + /// ⚠️ Le focus est demandé une seule fois, au prochain rendu : le reprendre à chaque + /// rendu l'arracherait pendant qu'on colle ou qu'on tape. + /// + private void OuvrirCorrectionCouverture(NumeroRevueDto numero) + { + _erreur = null; + _confirmeRetrait = null; + _correctionCouverture = numero.Id; + _urlCouverture = numero.CoverUrl ?? string.Empty; + _focusCouverture = true; + } + + protected override async Task OnAfterRenderAsync(bool premierRendu) + { + if (_focusCouverture) + { + _focusCouverture = false; + await _champUrlCouverture.FocusAsync(); + } + } + + private async Task SurToucheCouverture(KeyboardEventArgs e, NumeroRevueDto numero) + { + if (e.Key == "Enter") + { + await EnregistrerCouvertureAsync(numero); + } + else if (e.Key is "Escape" or "Esc") + { + _correctionCouverture = null; + } + } + + /// + /// N'enregistre que l'adresse de la couverture, sans passer par l'écran d'édition. + /// + /// + /// ⚠️ Le point d'entrée REMPLACE le numéro entier, articles à la une compris : tout ce + /// que porte la ligne doit être recopié dans la charge utile, faute de quoi poser une + /// couverture effacerait la une. C'est la même précaution que dans OuvrirEdition, et + /// c'est le genre d'oubli qu'aucun message d'erreur ne signalerait. + /// + private async Task EnregistrerCouvertureAsync(NumeroRevueDto numero) + { + if (!Reseau.EnLigne || _enCours) + { + return; + } + + var url = _urlCouverture.Trim(); + + _enCours = true; + _erreur = null; + + var resultat = await Api.ModifierNumeroAsync( + numero.Id, + new AjoutNumeroRevue + { + Numero = numero.Numero, + DateParution = numero.DateParution, + Note = numero.Note, + CoverUrl = string.IsNullOrEmpty(url) ? null : url, + Articles = [.. numero.Articles], + }); + + _enCours = false; + + if (!resultat.EstOk) + { + _erreur = resultat.Erreur; + return; + } + + Appliquer(resultat.Valeur!); + _correctionCouverture = null; + } + private async Task EnregistrerNumeroAsync() { if (_edition is not { } numeroId @@ -490,6 +618,12 @@ else _edition = null; } + // Le numéro retiré portait peut-être le champ d'adresse ouvert : il n'a plus de cible. + if (_correctionCouverture == numero.Id) + { + _correctionCouverture = null; + } + if (_erreur is null) { await ChargerAsync(); diff --git a/MaBibli.Client/Pages/Serie.razor b/MaBibli.Client/Pages/Serie.razor index 7aa6492..6d5fee0 100644 --- a/MaBibli.Client/Pages/Serie.razor +++ b/MaBibli.Client/Pages/Serie.razor @@ -58,6 +58,18 @@ else
    @if (Reseau.EnLigne) { + @* + « Ajouter » se déplie sur place, comme « Filtrer » au catalogue : les + quatre voies et leur formulaire vivaient en bas de page, après une saga + de vingt tomes qu'il fallait dérouler pour les atteindre. Recliquer + referme tout, la voie ouverte comprise. + *@ + + @if (_serie.Elements.Count > 1 || _filles.Count > 1) { Changer l'ordre @@ -73,6 +85,187 @@ else }
    + @if (ModeConsultation && _ajoutOuvert) + { +
    +

    Ajouter un tome

    + + @* + QUATRE voies, annoncées d'abord, la vue correspondante ensuite. Une saga se recense + d'un coup — souvent avant d'en posséder la moitié — puis se remplit au fil des achats, + et l'on n'entre pas les tomes de la même façon selon qu'on les a en main, au catalogue, + ou seulement en tête. Un tome saisi sans livre N'EST PAS un brouillon : c'est ce qui + rend le trou visible. + + ⚠️ Rien n'est déplié tant qu'on n'a pas choisi : les quatre formulaires empilés + occupaient l'écran sous la liste des tomes, et l'on ne voyait plus la saga. + *@ +
    + + + @* + ⚠️ La rafale MÈNE À L'ÉCRAN EXISTANT (`/ajout/rafale?serie=…`), elle n'est pas + réimplémentée ici : la file, sa persistance et sa reprise sont un mécanisme entier, + et une seconde copie divergerait. La série voyage dans l'adresse, et la rafale la + retient dans sa file — c'est ce qui fait qu'une reprise rattache au bon endroit. + *@ + @if (Reseau.EnLigne) + { + En rafale + } + else + { + + } + + + + @* + Lot P — le chemin inverse de « place vide → envie ». + + ⚠️ Ce geste n'ÉPARGNE QU'UNE RESAISIE : il crée une place ordinaire, LivreId à + NULL, titre repris de l'envie. Rien ne relie les deux en base, et c'est délibéré — + les séries sont COMMUNES au foyer, la liste d'envies est PERSONNELLE, et son sens + même est de préparer un cadeau sans que l'autre le voie venir. Une clé étrangère + vers l'envie afficherait « tome 3 souhaité par untel » à tout le monde. + *@ + +
    + + @if (_voie == VoieAjout.Manuelle) + { + @* + ⚠️ Le champ accepte un TITRE ou un ISBN, et fait deux choses différentes selon ce + qu'on y met : un titre recense une place (le tome qu'on n'a pas encore), un code + désigne un livre — et on le cherche alors d'abord au catalogue, ensuite à la BnF. + Sans cela, scanner un code créait un tome intitulé « 9782… ». + *@ +
    + + + @* Facultatif, et volontairement à côté du titre : on le connaît en tenant le livre. *@ + +
    + +
    + +
    + + @if (_lookupEnCours) + { + + } + + @* Le code désigne plusieurs livres DÉJÀ possédés : on ne choisit pas à la place de + l'utilisateur — deux exemplaires d'un même ISBN sont légitimes. *@ + @if (_ajoutParCode && _resultats.Count > 0) + { +
    +

    Ce code est déjà au catalogue. Quel exemplaire rattacher ?

    + + @foreach (var livre in _resultats) + { + + } +
    + } + + @* Rien au catalogue : la cascade a répondu, on crée le livre puis on le rattache. *@ + @if (_ajoutParCode && _lookupCandidats.Count > 0) + { +
    + @foreach (var candidat in _lookupCandidats) + { +
    +
    +

    @candidat.Titre

    +

    @candidat.Auteur

    +

    + @(candidat.Editeur ?? "éditeur inconnu") + · @(candidat.Annee ?? "année inconnue") +

    + +
    +
    + } +
    + } + } + + @if (_voie == VoieAjout.Envies) + { +
    + @if (_enviesChargement) + { + + } + else if (_envies.Count == 0) + { +

    + Votre liste d'envies est vide — ou ne contient rien qui ne soit déjà un tome + de cette série. +

    + } + else + { +

    + Le tome est ajouté comme manquant, avec le titre de l'envie. + L'envie reste dans votre liste : rien n'a été acheté, et + personne d'autre ne verra qu'elle est à vous. +

    + + @foreach (var envie in _envies) + { + + } + } +
    + } + + @if (_voie == VoieAjout.Catalogue) + { +
    + + + @foreach (var livre in _resultats) + { + + } +
    + } +
    + } + @if (ModeOrdre) {

    @@ -117,7 +310,27 @@ else @if (!ModeEdition && _serie.Elements.Count > 0) { -

    Ordre de lecture

    +
    +

    @(TriParNumero ? "Tomes" : "Ordre de lecture")

    + + @* + ⚠️ Le tri est un CHOIX D'ÉCRAN, non persistant — comme le pliage des séries, et + contrairement au rang des envies, qui est un ordre choisi et stocké. Ranger la + liste autrement pour la lire ne change rien à l'ordre de lecture de la saga. + + Il n'apparaît qu'à partir de deux tomes ET si au moins un porte un numéro : + sinon les deux tris donneraient la même liste. + *@ + @if (TriPossible) + { +
    + + +
    + } +
    @* ⚠️ L'ordre est STOCKÉ, jamais déduit d'une année de publication : une préquelle se @@ -126,7 +339,7 @@ else téléphone qu'on consulte une saga en librairie. *@
      - @foreach (var (element, index) in _serie.Elements.Select((e, i) => (e, i))) + @foreach (var (element, index) in TomesAffiches.Select((e, i) => (e, i))) {
    • @@ -139,7 +352,17 @@ else @onclick="() => DeplacerAsync(index, index - 1)">▲ } - + @* + ⚠️ La pastille ne porte QUE le numéro imprimé sur le tome, et rien + quand il n'y en a pas — en consultation comme en mode ordre. Le rang + de lecture ne s'affiche plus nulle part : deux natures de valeur dans + la même colonne, « 4 » se lisait indifféremment « quatrième à lire » + ou « tome 4 ». L'ordre, lui, reste stocké et se lit dans la liste. + *@ + @if (element.Numero is { } numero && !string.IsNullOrWhiteSpace(numero)) + { + @numero + } @if (ModeOrdre) { @@ -221,6 +444,12 @@ else } + +
      } + @if (_numeroOuvert == element.Id) + { +
      + + + +
      + } + @if (_rattachement == element.Id) {
      @foreach (var resultat in _resultats) @@ -321,102 +567,6 @@ else
    } - @if (ModeConsultation) - { -

    Ajouter un tome

    - - @* - Deux voies, parce qu'une saga se recense d'un coup — souvent avant d'en posséder la - moitié — puis se remplit au fil des achats. Un tome saisi sans livre N'EST PAS un - brouillon : c'est ce qui rend le trou visible. - *@ -
    - -
    - -
    - - - - - @* - Lot P — le chemin inverse de « place vide → envie ». - - ⚠️ Ce geste n'ÉPARGNE QU'UNE RESAISIE : il crée une place ordinaire, LivreId à - NULL, titre repris de l'envie. Rien ne relie les deux en base, et c'est délibéré — - les séries sont COMMUNES au foyer, la liste d'envies est PERSONNELLE, et son sens - même est de préparer un cadeau sans que l'autre le voie venir. Une clé étrangère - vers l'envie afficherait « tome 3 souhaité par untel » à tout le monde. - *@ - -
    - - @if (_enviesOuvertes) - { -
    - @if (_enviesChargement) - { - - } - else if (_envies.Count == 0) - { -

    - Votre liste d'envies est vide — ou ne contient rien qui ne soit déjà un tome - de cette série. -

    - } - else - { -

    - Le tome est ajouté comme manquant, avec le titre de l'envie. - L'envie reste dans votre liste : rien n'a été acheté, et - personne d'autre ne verra qu'elle est à vous. -

    - - @foreach (var envie in _envies) - { - - } - } -
    - } - - @if (_rattachement == AjoutDepuisCatalogue) - { -
    - - - @foreach (var livre in _resultats) - { - - } -
    - } - } @if (ModeEdition) { @@ -475,9 +625,6 @@ else } @code { - /// Marqueur de la recherche « ajouter depuis le catalogue », qui ne vise aucun élément. - private const int AjoutDepuisCatalogue = -1; - [Parameter] public int Id { get; set; } private IReadOnlyList _toutes = []; @@ -500,6 +647,48 @@ else private string _nouveauTome = string.Empty; + private string _nouveauNumero = string.Empty; + + /// Par où l'on ajoute un tome. Rien n'est déplié tant qu'on n'a pas choisi. + /// + /// ⚠️ « En rafale » n'y figure pas : c'est un LIEN vers l'écran de rafale, pas une vue de + /// celui-ci. La file, sa persistance et sa reprise sont un mécanisme entier, qu'une seconde + /// implémentation ferait diverger. + /// + private enum VoieAjout { Aucune, Manuelle, Catalogue, Envies } + + private VoieAjout _voie = VoieAjout.Aucune; + + /// Le panneau d'ajout est déplié, comme les filtres du catalogue. + private bool _ajoutOuvert; + + /// La saisie manuelle a été reconnue comme un code, et non comme un titre. + private bool _ajoutParCode; + + private ElementReference _champNouveauTome; + private ElementReference _champRecherche; + + /// Champ à focaliser au prochain rendu, s'il y en a un. + /// + /// ⚠️ Une seule fois par ouverture : reprendre le focus à chaque rendu l'arracherait en + /// pleine frappe, ces champs se reliant à chaque caractère. + /// + private VoieAjout? _focusAFaire; + + /// Tome dont le champ « numéro » est déplié, s'il y en a un. + private int? _numeroOuvert; + + private string _numeroSaisi = string.Empty; + + /// + /// Trier par numéro de tome plutôt que par ordre de lecture. + /// + /// + /// ⚠️ Confort de lecture, non persistant — même règle que le pliage des séries au + /// catalogue. L'ordre de lecture, lui, est stocké : c'est une propriété de la saga. + /// + private bool _triParNumero; + private int? _rattachement; private string _recherche = string.Empty; private IReadOnlyList _resultats = []; @@ -511,7 +700,6 @@ else /// Le scanner est ouvert sur la place visée par le lookup ISBN (lot J1). private bool _lookupScan; - private bool _enviesOuvertes; private bool _enviesChargement; private IReadOnlyList _envies = []; @@ -531,6 +719,144 @@ else /// private bool ModeOrdre => Suffixe("/ordre"); + /// + /// ⚠️ Le tri par numéro est ignoré en mode ordre : on y déplace des rangs, et réordonner + /// une liste triée autrement ferait sauter les tomes sous les flèches. + /// + private bool TriParNumero => _triParNumero && !ModeOrdre; + + /// + /// Le choix n'apparaît qu'à partir de deux tomes, et si au moins un porte un numéro : + /// sinon les deux tris rendraient exactement la même liste. + /// + private bool TriPossible => + !ModeOrdre + && _serie is { } serie + && serie.Elements.Count > 1 + && serie.Elements.Any(e => !string.IsNullOrWhiteSpace(e.Numero)); + + /// Les tomes dans l'ordre demandé — celui de lecture par défaut. + private IReadOnlyList TomesAffiches + { + get + { + var elements = _serie?.Elements ?? []; + + // Le tri du serveur (Position) est déjà le bon : on ne le refait pas pour rien. + return TriParNumero + ? [.. elements.OrderBy(e => e.Numero, TriNaturel.Comparateur).ThenBy(e => e.Id)] + : elements; + } + } + + /// + /// Ouvre ou ferme le panneau d'ajout. + /// + /// + /// ⚠️ Le refermer referme tout : la voie choisie, ce qui était tapé, et les résultats + /// d'une recherche de code. Un panneau qui se rouvre sur l'état d'avant ferait croire à une + /// saisie en cours alors qu'on l'avait quittée. + /// + private void BasculerAjout() + { + _ajoutOuvert = !_ajoutOuvert; + + if (!_ajoutOuvert) + { + Ouvrir(VoieAjout.Aucune); + } + } + + /// Vrai quand ce qui est tapé dans la saisie manuelle est un code et non un titre. + private bool SaisieEstUnCode => RechercheIsbn.Formes(_nouveauTome) is not null; + + /// + /// Déplie une voie d'ajout, ou la referme si c'est celle qui était ouverte. + /// + private void Ouvrir(VoieAjout voie) + { + _erreur = null; + _message = null; + + if (_voie == voie) + { + _voie = VoieAjout.Aucune; + FermerRecherche(); + return; + } + + _voie = voie; + _focusAFaire = voie; + _nouveauTome = string.Empty; + _nouveauNumero = string.Empty; + _ajoutParCode = false; + _lookupCandidats = []; + + // Les deux recherches de catalogue partagent le champ et les résultats : ouvrir celle-ci + // ferme celle d'une place précise, sinon les deux listes se marcheraient dessus. + FermerRecherche(); + } + + protected override async Task OnAfterRenderAsync(bool premierRendu) + { + if (_focusAFaire is not { } voie) + { + return; + } + + _focusAFaire = null; + + switch (voie) + { + case VoieAjout.Manuelle: + await _champNouveauTome.FocusAsync(); + break; + + case VoieAjout.Catalogue: + await _champRecherche.FocusAsync(); + await ProposerAsync(); + StateHasChanged(); + break; + } + } + + private void OuvrirNumero(ElementSerieDto element) + { + _numeroOuvert = element.Id; + _numeroSaisi = element.Numero ?? string.Empty; + } + + private async Task SurToucheNumero(KeyboardEventArgs e, ElementSerieDto element) + { + if (e.Key == "Enter" && Reseau.EnLigne) + { + await EnregistrerNumeroAsync(element); + } + else if (e.Key == "Escape") + { + _numeroOuvert = null; + } + } + + private Task EnregistrerNumeroAsync(ElementSerieDto element) => + EcrireAsync(async () => + { + // Le titre est renvoyé tel quel : ce point d'entrée corrige le numéro, pas le tome. + // ⚠️ `element.Titre` peut être celui du LIVRE rattaché ; c'est bien lui qu'on veut + // conserver comme filet, la place n'en portant pas d'autre une fois détachée. + var resultat = await Api.ModifierElementSerieAsync( + element.Id, + new AjoutElementSerie { Titre = element.Titre, Numero = _numeroSaisi }); + + if (resultat.EstOk) + { + _numeroOuvert = null; + _numeroSaisi = string.Empty; + } + + return resultat; + }); + /// Ni édition de la fiche, ni réordonnancement : l'écran qu'on ouvre pour regarder. private bool ModeConsultation => !ModeEdition && !ModeOrdre; @@ -632,19 +958,91 @@ else { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouveauTome) && Reseau.EnLigne) { - await AjouterTomeAsync(null); + await AjouterSaisieAsync(); } } + /// + /// Traite la saisie manuelle : un titre recense une place, un code désigne un livre. + /// + /// + /// ⚠️ Le catalogue d'abord, la BnF ensuite (choisi avec l'utilisateur le 2026-09-08). + /// Un tome d'une saga est souvent déjà dans la bibliothèque — c'est même le cas ordinaire + /// quand on range une série après coup : passer directement à la cascade distante recréerait + /// un livre qu'on possède, et l'on n'aurait plus qu'un avertissement de doublon à trancher. + /// + /// Un seul livre au catalogue est rattaché sans rien demander ; plusieurs sont + /// proposés, parce que posséder deux exemplaires d'un même ISBN est légitime et qu'on ne + /// choisit pas à la place de quelqu'un. + /// + /// + private async Task AjouterSaisieAsync() + { + if (!SaisieEstUnCode) + { + _ajoutParCode = false; + await AjouterTomeAsync(null); + return; + } + + _ajoutParCode = true; + _erreur = null; + _message = null; + _resultats = []; + _lookupCandidats = []; + _lookupEnCours = true; + + var possedes = await Api.ListerAsync(new CritereLivres { Recherche = _nouveauTome }); + + if (possedes.Count == 1) + { + _lookupEnCours = false; + await AjouterTomeAsync(possedes[0].Id); + return; + } + + if (possedes.Count > 1) + { + _lookupEnCours = false; + _resultats = possedes; + return; + } + + // Rien au catalogue : la cascade BnF → OpenLibrary. ⚠️ Aucune place n'est visée — le + // livre choisi en créera une, contrairement au lookup d'un tome manquant déjà recensé. + _lookupEnCours = false; + _lookupElement = null; + _lookupIsbn = _nouveauTome; + await ChercherLookupAsync(); + } + private async Task AjouterTomeAsync(int? livreId) { + // ⚠️ Le numéro saisi est transmis DANS LES DEUX CAS : on le connaît en tenant le livre, + // et le perdre parce qu'on a rattaché un exemplaire du catalogue n'aurait aucun sens. await EcrireAsync(() => Api.AjouterElementSerieAsync( - Id, new AjoutElementSerie { LivreId = livreId, Titre = livreId is null ? _nouveauTome : null })); + Id, + new AjoutElementSerie + { + LivreId = livreId, + Titre = livreId is null ? _nouveauTome : null, + Numero = _nouveauNumero, + })); if (_erreur is null) { _nouveauTome = string.Empty; + _nouveauNumero = string.Empty; + _ajoutParCode = false; + _lookupCandidats = []; FermerRecherche(); + + // On ajoute rarement un seul tome depuis le catalogue : la voie reste ouverte, et + // reprend sa proposition de départ plutôt que de rester sur une liste vide. + if (_voie == VoieAjout.Catalogue) + { + await ProposerAsync(); + } } } @@ -747,9 +1145,16 @@ else /// private async Task OuvrirEnviesAsync() { + if (_voie == VoieAjout.Envies) + { + _voie = VoieAjout.Aucune; + return; + } + _erreur = null; _message = null; - _enviesOuvertes = true; + _voie = VoieAjout.Envies; + _rattachement = null; _enviesChargement = true; var toutes = await Api.ListerSouhaitsAsync(); @@ -786,7 +1191,9 @@ else if (_erreur is null) { - _enviesOuvertes = false; + // La voie se referme : le message dit ce qui s'est passé, et la liste d'envies + // rouverte au même endroit laisserait croire qu'il reste quelque chose à cliquer. + _voie = VoieAjout.Aucune; _envies = []; _message = $"« {envie.Titre} » est recensé comme tome manquant. " + "Il reste dans votre liste d'envies."; @@ -861,13 +1268,13 @@ else } } + /// + /// ⚠️ Deux usages, et la différence tient à _lookupElement : rattaché à une place + /// déjà recensée (bouton « Ajouter par ISBN » d'un tome manquant), ou — s'il n'y en a + /// pas — créant la place, ce qui est le cas de la saisie manuelle par code. + /// private async Task AjouterCommeLivreAsync(CandidatLivre candidat) { - if (_lookupElement is null) - { - return; - } - _enCours = true; _erreur = null; var saisie = new EnregistrementLivre @@ -895,10 +1302,21 @@ else return; } - await EcrireAsync(() => Api.RattacherLivreSerieAsync(_lookupElement.Id, resultat.Livre!.Id)); + if (_lookupElement is { } place) + { + await EcrireAsync(() => Api.RattacherLivreSerieAsync(place.Id, resultat.Livre!.Id)); + } + else + { + // Saisie manuelle par code : la place n'existe pas encore, ce livre la crée. + await AjouterTomeAsync(resultat.Livre!.Id); + } + if (_erreur is null) { _lookupElement = null; + _lookupCandidats = []; + _ajoutParCode = false; } } @@ -951,6 +1369,9 @@ else private void OuvrirRattachement(int cible) { + // Une place précise et « depuis le catalogue » partagent champ et résultats : l'une + // ferme l'autre. + _voie = VoieAjout.Aucune; _rattachement = cible; _recherche = string.Empty; _resultats = []; @@ -963,13 +1384,32 @@ else _resultats = []; } + /// + /// Montre déjà des livres alors que rien n'est tapé. + /// + /// + /// ⚠️ C'est ce qui dit qu'il y a une recherche : un champ vide sous une liste vide ne + /// se distingue pas d'un formulaire qui attend un titre exact. On propose donc les premiers + /// livres, que la frappe restreint ensuite. + /// + private async Task ProposerAsync() + { + if (!string.IsNullOrWhiteSpace(_recherche)) + { + return; + } + + _resultats = (await Api.ListerAsync(new CritereLivres())).Take(8).ToList(); + } + private async Task ChercherAsync(ChangeEventArgs e) { _recherche = e.Value?.ToString() ?? string.Empty; if (string.IsNullOrWhiteSpace(_recherche)) { - _resultats = []; + // Effacer ce qu'on a tapé ramène la proposition de départ, pas le vide. + await ProposerAsync(); return; } diff --git a/MaBibli.Client/Services/CacheHorsLigne.cs b/MaBibli.Client/Services/CacheHorsLigne.cs index 0f44259..2905783 100644 --- a/MaBibli.Client/Services/CacheHorsLigne.cs +++ b/MaBibli.Client/Services/CacheHorsLigne.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using MaBibli.Shared.Catalogue; using Microsoft.JSInterop; namespace MaBibli.Client.Services; @@ -145,6 +146,57 @@ public sealed class CacheHorsLigne(IJSRuntime js) : IAsyncDisposable } } + // ----------------------------------------------------------------------- + // File de catalogage en rafale (lot AB) + // ----------------------------------------------------------------------- + // ⚠️ Magasin SÉPARÉ des instantanés, et volontairement : ceux-ci portent ce que le serveur + // a rendu, effaçable sans rien perdre. Celle-ci porte ce que l'utilisateur a scanné, et qui + // n'existe nulle part ailleurs. + + /// Range la rafale en cours. Appelée à chaque scan : perdre la file serait cuisant. + public async Task EcrireRafaleAsync(Rafale rafale) + { + try + { + var module = await ModuleAsync(); + await module.InvokeVoidAsync("rafaleEcrire", JsonSerializer.Serialize(rafale, Json)); + } + catch (Exception) + { + // ⚠️ On ne marque PAS le cache indisponible ici : ne pas pouvoir ranger la file + // n'empêche pas de la traiter dans la session en cours. C'est la reprise ultérieure + // qui est perdue, et l'écran le dit. + } + } + + /// Relit la rafale interrompue, s'il y en a une. + public async Task LireRafaleAsync() + { + try + { + var module = await ModuleAsync(); + var brut = await module.InvokeAsync("rafaleLire"); + return brut is null ? null : JsonSerializer.Deserialize(brut, Json); + } + catch (Exception) + { + return null; + } + } + + public async Task EffacerRafaleAsync() + { + try + { + var module = await ModuleAsync(); + await module.InvokeVoidAsync("rafaleEffacer"); + } + catch (Exception) + { + // Sans conséquence : une file qu'on n'a pas pu effacer sera écrasée à la suivante. + } + } + /// État déclaré par le navigateur. Voir la nuance dans cache-hors-ligne.js. public async Task EnLigneAsync() { diff --git a/MaBibli.Client/Services/FiltreLivresLocal.cs b/MaBibli.Client/Services/FiltreLivresLocal.cs index 970c9ee..323dcad 100644 --- a/MaBibli.Client/Services/FiltreLivresLocal.cs +++ b/MaBibli.Client/Services/FiltreLivresLocal.cs @@ -1,5 +1,6 @@ using MaBibli.Shared.Catalogue; using MaBibli.Shared.Dtos; +using MaBibli.Shared.Isbn; using MaBibli.Shared.Textes; namespace MaBibli.Client.Services; @@ -61,6 +62,23 @@ public static class FiltreLivresLocal livres = livres.Where(l => (l.PreteA is not null) == prete); } + if (criteres.SansCouverture is { } sansCouverture) + { + // Même convention que côté serveur : une URL vide vaut une URL absente. + livres = livres.Where(l => string.IsNullOrEmpty(l.CoverUrl) == sansCouverture); + } + + // Même exclusion que côté serveur : un code désigne un objet, des mots désignent une + // œuvre. Les deux branches ne se mélangent pas. + if (RechercheIsbn.Formes(criteres.Recherche) is { } formes) + { + livres = livres.Where(l => + l.Isbn is not null + && formes.Any(forme => l.Isbn.Contains(forme, StringComparison.Ordinal))); + + return Ordonner(livres); + } + var terme = NormalisationTexte.Normaliser(criteres.Recherche); if (terme.Length > 0) { @@ -73,12 +91,20 @@ public static class FiltreLivresLocal || RapprochementAuteurs.Cle(a.Nom).Contains(cle, StringComparison.Ordinal))); } - // Même ordre que le serveur : titre normalisé, puis identifiant pour rendre le tri - // déterministe. La comparaison est ordinale parce que c'est ce que fait SQLite sur une - // colonne sans collation — un tri « intelligent » ici ferait diverger les deux vues. - return livres + return Ordonner(livres); + } + + /// + /// Même ordre que le serveur : titre normalisé, puis identifiant pour rendre le tri + /// déterministe. + /// + /// + /// La comparaison est ordinale parce que c'est ce que fait SQLite sur une colonne sans + /// collation — un tri « intelligent » ici ferait diverger les deux vues. + /// + private static IReadOnlyList Ordonner(IEnumerable livres) => + livres .OrderBy(l => NormalisationTexte.Normaliser(l.Titre), StringComparer.Ordinal) .ThenBy(l => l.Id) .ToList(); - } } diff --git a/MaBibli.Client/Services/RemonteeRoutes.cs b/MaBibli.Client/Services/RemonteeRoutes.cs index 4ebb81c..60ffc58 100644 --- a/MaBibli.Client/Services/RemonteeRoutes.cs +++ b/MaBibli.Client/Services/RemonteeRoutes.cs @@ -62,6 +62,10 @@ public static class RemonteeRoutes // catalogue est la destination de menu de ce qu'on y saisit. ("/ajout/manuel", Racine), ("/ajout/isbn", Racine), + + // ⚠️ La rafale remonte à « /ajout », et non au catalogue : on y arrive depuis l'écran + // d'ajout, et l'on veut souvent y revenir pour saisir autrement ce qui a résisté. + ("/ajout/rafale", "/ajout"), ("/ajout", Racine), // Revues. ⚠️ « /revues/ajout » est écrit AVANT « /revues/{id} » : « ajout » n'est pas un diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs index cb43f0e..96845fa 100644 --- a/MaBibli.Client/Services/ServiceLivresApi.cs +++ b/MaBibli.Client/Services/ServiceLivresApi.cs @@ -103,6 +103,11 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat parametres.Add($"prete={(prete ? "true" : "false")}"); } + if (criteres.SansCouverture is { } sansCouverture) + { + parametres.Add($"sansCouverture={(sansCouverture ? "true" : "false")}"); + } + var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty); var (ok, liste) = await EssayerAsync(() => http.GetFromJsonAsync>(url, Json, ct)); @@ -387,6 +392,16 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat () => http.PostAsJsonAsync($"api/livres/{livreId}/prets", saisie, Json, ct), ct); } + /// + /// Efface un prêt saisi par erreur. + /// + /// + /// ⚠️ À ne pas confondre avec , qui conserve la ligne en lui + /// posant une date de retour. Celle-ci défait une saisie fautive, et rien d'autre. + /// + public Task SupprimerPretAsync(int pretId, CancellationToken ct = default) => + SupprimerAsync($"api/prets/{pretId}", ct); + /// Clôt un prêt. Le prêt n'est pas supprimé : il devient l'historique du livre. public async Task> RendreAsync( int pretId, DateTime? dateRetour = null, CancellationToken ct = default) => @@ -1038,6 +1053,17 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat () => http.PostAsJsonAsync($"api/series/{serieId}/elements", saisie, Json, ct), ct); /// Rattache un livre possédé à une place, ou l'en détache avec null. + /// + /// Corrige le titre et le numéro d'une place, sans toucher au livre rattaché. + /// + /// + /// ⚠️ Distinct de : un point d'entrée, un geste. + /// + public async Task> ModifierElementSerieAsync( + int elementId, AjoutElementSerie saisie, CancellationToken ct = default) => + await EcrireAsync( + () => http.PutAsJsonAsync($"api/series/elements/{elementId}", saisie, Json, ct), ct); + public async Task> RattacherLivreSerieAsync( int elementId, int? livreId, CancellationToken ct = default) => await EcrireAsync( diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index b800a00..651dbfa 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -251,7 +251,15 @@ body { margin-bottom: 0.5rem; } +/* ⚠️ Écrit pour des
    public string Titre { get; set; } = string.Empty; - public void RecalculerFormes() => Titre = Titre.Trim(); + /// + /// Le numéro imprimé sur le livre — « 7 », « Hors-série », « 3.5 ». null = inconnu. + /// + /// + /// ⚠️ À ne pas confondre avec . La position est le rang dans + /// l'ordre de lecture ; le numéro est l'étiquette éditoriale. Les deux + /// coïncident tant qu'on possède la série depuis le début, et divergent par nature + /// pour une préquelle : dans La Légende de Drizzt, le premier livre à lire porte + /// « 4 » sur sa couverture. + /// + /// ⚠️ C'est aussi pourquoi le numéro ne pouvait pas se loger dans Position : + /// PUT /api/series/{id}/ordre renumérote toutes les positions, donc un numéro rangé + /// là serait détruit au premier réordonnancement par flèches. + /// + /// + /// Une chaîne, jamais un entier — même choix que NumeroRevue.Numero : les + /// sagas produisent des « hors-série », des « 3.5 » et des « intégrale 1-3 » qu'un + /// numérique refuserait. + /// + /// + public string? Numero { get; set; } + + public void RecalculerFormes() + { + Titre = Titre.Trim(); + + // Une chaîne vide vaut une absence de numéro : sans cela, un champ effacé se + // distinguerait d'un champ jamais rempli, pour rien. + Numero = string.IsNullOrWhiteSpace(Numero) ? null : Numero.Trim(); + } } diff --git a/MaBibli.Shared/Isbn/RechercheIsbn.cs b/MaBibli.Shared/Isbn/RechercheIsbn.cs new file mode 100644 index 0000000..a87636f --- /dev/null +++ b/MaBibli.Shared/Isbn/RechercheIsbn.cs @@ -0,0 +1,83 @@ +namespace MaBibli.Shared.Isbn; + +/// +/// Reconnaît qu'un terme de recherche est un code et non des mots, et donne les formes +/// sous lesquelles le chercher dans le catalogue. +/// +/// +/// +/// Le geste visé : scanner un livre qu'on tient en main pour retrouver sa fiche. La douchette +/// « tape » l'EAN-13 dans le champ de recherche, la caméra le pose de la même façon — mais la +/// recherche du catalogue ne portait que sur le titre et les auteurs, donc ne rendait rien. +/// +/// +/// ⚠️ Les deux formes d'ISBN sont indispensables, ici comme partout ailleurs dans le +/// projet : un livre paru avant 2007 porte un ISBN-10 en base, alors qu'un code-barres se lit +/// toujours en 13. Ne chercher qu'une forme ferait répondre « vous ne l'avez pas » à propos d'un +/// livre posé sur l'étagère. +/// +/// +/// ⚠️ Le seuil de huit chiffres n'est pas décoratif. Sans lui, chercher « 1984 » — un +/// titre — remonterait tous les livres dont l'ISBN contient ces quatre chiffres, c'est-à-dire du +/// bruit à la place d'une réponse. Aucun titre ne s'écrit avec huit chiffres d'affilée. +/// +/// +public static class RechercheIsbn +{ + /// En deçà, un terme numérique est un titre bien plus probablement qu'un code. + private const int ChiffresMinimum = 8; + + /// + /// Les formes à confronter à Livre.Isbn, ou null si le terme n'est pas un code. + /// + /// + /// Un terme n'est un code que s'il ne contient rien d'autre que des chiffres, la + /// ponctuation d'un ISBN (tirets, espaces, points) et l'éventuel X final : « Zola 1984 » + /// reste une recherche de mots. + /// + public static IReadOnlyList? Formes(string? recherche) + { + if (string.IsNullOrWhiteSpace(recherche) || !QueDuCode(recherche)) + { + return null; + } + + var chiffres = IsbnUtils.Normaliser(recherche); + if (chiffres is null || chiffres.Length < ChiffresMinimum) + { + return null; + } + + // La conversion 13 → 10 ne vaut que pour un ISBN-13 complet et valide ; sur un fragment, + // elle n'a pas de sens et on s'en passe — la forme longue suffit alors. + return IsbnUtils.TryConvertirEnIsbn10(chiffres, out var court) && court is not null + ? [chiffres, court] + : [chiffres]; + } + + /// Vrai si le terme est un ISBN complet et valide — donc s'il désigne UNE édition. + /// + /// C'est ce qui autorise le catalogue à ouvrir directement la fiche quand un seul livre + /// correspond : sur un fragment, le même résultat unique serait un hasard de frappe. + /// + public static bool EstCodeComplet(string? recherche) => + QueDuCode(recherche) && IsbnUtils.EstValide(IsbnUtils.Normaliser(recherche)); + + private static bool QueDuCode(string? recherche) + { + if (string.IsNullOrWhiteSpace(recherche)) + { + return false; + } + + foreach (var c in recherche) + { + if (!char.IsDigit(c) && c is not ('-' or ' ' or '.' or 'x' or 'X')) + { + return false; + } + } + + return true; + } +} diff --git a/MaBibli.Shared/Textes/TriNaturel.cs b/MaBibli.Shared/Textes/TriNaturel.cs new file mode 100644 index 0000000..d522713 --- /dev/null +++ b/MaBibli.Shared/Textes/TriNaturel.cs @@ -0,0 +1,121 @@ +namespace MaBibli.Shared.Textes; + +/// +/// Compare des libellés qui contiennent des nombres, en lisant les nombres comme des nombres. +/// +/// +/// Le tri ordinaire range les tomes 1, 10, 11, 2, 9 — parce que le caractère 1 +/// précède le 2. C'est correct alphabétiquement, et absurde pour une saga. Ce +/// comparateur découpe le libellé en blocs de chiffres et blocs de texte, et compare chaque +/// bloc de chiffres comme un entier. +/// +/// ⚠️ Aucune collation culturelle, comme partout ailleurs dans le projet +/// (GroupementCatalogue, FiltreLivresLocal) : la comparaison des blocs de texte +/// est ordinale, sur leur forme normalisée. C'est ce qui garantit que deux appareils +/// classent une série dans le même ordre, quelle que soit la langue du système. +/// +/// +/// Ordre retenu, tranché avec l'utilisateur le 2026-08-22 : +/// +/// +/// le libellé vide — ce dont on ignore le numéro ; +/// ce qui ne commence pas par un chiffre (« Hors-série », « Intégrale 1-3 »), +/// entre eux de a à z ; +/// ce qui commence par un chiffre, de 0 à 9 en tri naturel. +/// +/// +/// ⚠️ Cet ordre diverge de celui des numéros de revue, où ce qui n'a pas de date +/// ferme la liste. C'est délibéré : devant un kiosque on cherche le dernier paru, +/// devant une étagère on cherche ce qu'il reste à compléter. Ne pas uniformiser les deux au +/// nom de la cohérence — ce serait remplacer deux règles justes par une qui se trompe une +/// fois sur deux. +/// +/// +public static class TriNaturel +{ + /// Comparateur prêt à l'emploi pour OrderBy. + public static IComparer Comparateur { get; } = new ComparateurNaturel(); + + /// + /// Compare deux libellés. null et la chaîne vide sont équivalents et passent en tête. + /// + public static int Comparer(string? gauche, string? droite) + { + var a = NormalisationTexte.Normaliser(gauche); + var b = NormalisationTexte.Normaliser(droite); + + // 1. Le vide d'abord — on ignore le numéro, et c'est ce qui demande une décision. + if (a.Length == 0 || b.Length == 0) + { + return a.Length == b.Length ? 0 : a.Length == 0 ? -1 : 1; + } + + // 2. Puis ce qui ne commence pas par un chiffre. Un seul test sur le PREMIER caractère : + // « Intégrale 1-3 » est du texte, « 3.5 » est un nombre, et la frontière est là. + var aChiffre = char.IsAsciiDigit(a[0]); + var bChiffre = char.IsAsciiDigit(b[0]); + + if (aChiffre != bChiffre) + { + return aChiffre ? 1 : -1; + } + + return ComparerBlocs(a, b); + } + + /// + /// Parcourt les deux libellés en parallèle, bloc de chiffres contre bloc de chiffres, + /// bloc de texte contre bloc de texte. + /// + private static int ComparerBlocs(string a, string b) + { + int i = 0, j = 0; + + while (i < a.Length && j < b.Length) + { + if (char.IsAsciiDigit(a[i]) && char.IsAsciiDigit(b[j])) + { + var debutA = i; + var debutB = j; + while (i < a.Length && char.IsAsciiDigit(a[i])) i++; + while (j < b.Length && char.IsAsciiDigit(b[j])) j++; + + // ⚠️ Comparaison par LONGUEUR puis par caractères, jamais par conversion en int : + // un numéro n'est pas borné par int.MaxValue, et « 000000000000000012 » ne doit + // pas faire échouer le tri. Les zéros de tête sont ignorés d'abord. + var chiffresA = a.AsSpan(debutA, i - debutA).TrimStart('0'); + var chiffresB = b.AsSpan(debutB, j - debutB).TrimStart('0'); + + if (chiffresA.Length != chiffresB.Length) + { + return chiffresA.Length < chiffresB.Length ? -1 : 1; + } + + var ecart = chiffresA.SequenceCompareTo(chiffresB); + if (ecart != 0) + { + return Math.Sign(ecart); + } + } + else + { + var ecart = a[i].CompareTo(b[j]); + if (ecart != 0) + { + return Math.Sign(ecart); + } + + i++; + j++; + } + } + + // Le plus court d'abord quand l'un est le préfixe de l'autre : « 3 » avant « 3 bis ». + return (a.Length - i).CompareTo(b.Length - j); + } + + private sealed class ComparateurNaturel : IComparer + { + public int Compare(string? x, string? y) => Comparer(x, y); + } +} diff --git a/MaBibli.Tests/FiltreLivresLocalTests.cs b/MaBibli.Tests/FiltreLivresLocalTests.cs index 25818fb..249342f 100644 --- a/MaBibli.Tests/FiltreLivresLocalTests.cs +++ b/MaBibli.Tests/FiltreLivresLocalTests.cs @@ -33,11 +33,13 @@ public class FiltreLivresLocalTests private static Livre Livre( int id, string titre, Auteur? auteur, Format format, Statut? statut, TypeDocument type = TypeDocument.NonPrecise, - string? preteA = null, bool pretClos = false) + string? preteA = null, bool pretClos = false, string? couverture = null, + string? isbn = null) { var livre = new Livre { Id = id, Titre = titre, Format = format, TypeDocument = type, AjoutePar = Lecteur, + CoverUrl = couverture, Isbn = isbn, }; livre.RecalculerFormes(); @@ -70,12 +72,20 @@ public class FiltreLivresLocalTests private static readonly Livre[] Entites = [ - Livre(1, "Germinal", Zola, Format.Physique, Statut.Lu, TypeDocument.Roman), - Livre(2, "La Bête humaine", Zola, Format.Numerique, Statut.ALire, TypeDocument.BandeDessinee), + // ⚠️ Germinal est rangé sous son ISBN-10, comme tout ce qui a paru avant 2007 — alors + // qu'un code-barres se lit toujours en 13. C'est le cas que la recherche par code doit + // rattraper, sans quoi tout le fonds ancien serait introuvable à la douchette. + Livre(1, "Germinal", Zola, Format.Physique, Statut.Lu, TypeDocument.Roman, + couverture: "https://exemple.test/germinal.jpg", isbn: "2253004227"), + Livre(2, "La Bête humaine", Zola, Format.Numerique, Statut.ALire, TypeDocument.BandeDessinee, + isbn: "9782070612758"), Livre(3, "Le Horla", Maupassant, Format.Physique, Statut.EnCours, TypeDocument.Roman, - preteA: "Marie"), + preteA: "Marie", couverture: "https://exemple.test/horla.jpg"), Livre(4, "Bel-Ami", Maupassant, Format.Numerique, null, preteA: "Paul", pretClos: true), - Livre(5, "Œuvres complètes", Zola, Format.Physique, Statut.ALire), + + // ⚠️ Couverture VIDE, pas absente. C'est le cas où les deux implémentations pourraient + // diverger sans qu'on le voie : le serveur écrit `IsNullOrEmpty` en SQL, le client en C#. + Livre(5, "Œuvres complètes", Zola, Format.Physique, Statut.ALire, couverture: ""), Livre(6, "L'Éducation sentimentale", null, Format.Physique, null), ]; @@ -95,6 +105,8 @@ public class FiltreLivresLocalTests // Ce que l'API expose : l'emprunteur du prêt EN COURS, rien de l'historique clos. PreteA = l.Prets.FirstOrDefault(p => p.DateRetour is null)?.Emprunteur, + CoverUrl = l.CoverUrl, + Isbn = l.Isbn, Auteurs = l.Auteurs .Select(la => new AuteurDto { Id = la.AuteurId, Nom = la.Auteur!.Nom }) .ToList(), @@ -112,6 +124,25 @@ public class FiltreLivresLocalTests new CritereLivres { Recherche = "bete" }, new CritereLivres { Recherche = "oeuvres" }, new CritereLivres { Recherche = "introuvable" }, + + // Recherche par CODE : elle exclut la recherche par mots, et confronte les deux formes + // d'ISBN. Un fonds ancien est rangé en 10, une douchette lit toujours en 13. + new CritereLivres { Recherche = "9782253004226" }, // 13 scanné → fiche rangée en 10 + new CritereLivres { Recherche = "2253004227" }, // 10 tapé tel quel + new CritereLivres { Recherche = "978-2-07-061275-8" }, // tirets de l'affichage + new CritereLivres { Recherche = "9782070612758" }, + new CritereLivres { Recherche = "97820706" }, // fragment : préfixe d'un ISBN + new CritereLivres { Recherche = "9999999999999" }, // aucun livre + new CritereLivres { Recherche = "9782070612758", Format = Format.Physique }, + new CritereLivres { SansCouverture = true }, + new CritereLivres { SansCouverture = false }, + + // Croisements : le critère doit se composer avec les autres, pas les remplacer. + new CritereLivres { SansCouverture = true, Format = Format.Physique }, + new CritereLivres { SansCouverture = true, Recherche = "zola" }, + new CritereLivres { SansCouverture = false, Statut = Statut.Lu }, + new CritereLivres { SansCouverture = true, Prete = false }, + new CritereLivres { Format = Format.Physique }, new CritereLivres { Format = Format.Numerique }, new CritereLivres { TypeDocument = TypeDocument.Roman }, @@ -186,6 +217,25 @@ public class FiltreLivresLocalTests l => l.Id == 4); } + [Fact] + public void Un_code_barres_retrouve_un_livre_range_sous_son_isbn_10() + { + // Le geste visé : le livre est en main, la douchette tape son EAN-13 dans la recherche. + var resultat = FiltreLivresLocal.Appliquer( + Instantane, new CritereLivres { Recherche = "9782253004226" }); + + Assert.Equal(1, Assert.Single(resultat).Id); + } + + [Fact] + public void Un_titre_numerique_reste_une_recherche_de_mots() + { + // ⚠️ Sans le seuil de huit chiffres, « 1984 » remonterait tous les livres dont l'ISBN + // contient ces quatre chiffres — ici Le Petit Prince, dont l'ISBN se termine par 1275 8. + Assert.Empty(FiltreLivresLocal.Appliquer(Instantane, new CritereLivres { Recherche = "0612" })); + Assert.Empty(FiltreLivresLocal.Appliquer(Instantane, new CritereLivres { Recherche = "2070612" })); + } + [Fact] public void Un_instantane_vide_ne_fait_pas_echouer_la_recherche() => Assert.Empty(FiltreLivresLocal.Appliquer([], new CritereLivres { Recherche = "zola" })); diff --git a/MaBibli.Tests/LibellesTests.cs b/MaBibli.Tests/LibellesTests.cs new file mode 100644 index 0000000..762726f --- /dev/null +++ b/MaBibli.Tests/LibellesTests.cs @@ -0,0 +1,37 @@ +using MaBibli.Client.Composants; + +namespace MaBibli.Tests; + +/// +/// Le compte annoncé par un bloc de série au catalogue. +/// +/// +/// Défaut d'origine : le compte suivait le filtre. « 3 tomes » devenait « 1 tome » dès qu'une +/// recherche écartait les autres, et se lisait comme « cette série n'a qu'un tome ». Le nombre +/// ne disait pas de quoi il parlait. +/// +public class LibellesTests +{ + [Fact] + public void Sans_filtre_et_serie_complete_un_seul_nombre_suffit() + => Assert.Equal("3 tomes", Libelles.CompteBloc(affiches: 3, possedes: 3, total: 3)); + + [Fact] + public void Sans_filtre_la_serie_incomplete_dit_ce_qui_manque() + => Assert.Equal("3 sur 7 tomes", Libelles.CompteBloc(affiches: 3, possedes: 3, total: 7)); + + [Fact] + public void Sous_filtre_une_serie_complete_distingue_l_affiche_du_possede() + => Assert.Equal("1 affiché sur 3 tomes", Libelles.CompteBloc(affiches: 1, possedes: 3, total: 3)); + + [Fact] + public void Sous_filtre_une_serie_incomplete_porte_les_trois_nombres() + => Assert.Equal("1 affiché · 3 sur 7 tomes", Libelles.CompteBloc(affiches: 1, possedes: 3, total: 7)); + + [Fact] + public void Le_pluriel_suit_le_total_pas_l_affiche() + { + Assert.Equal("1 tome", Libelles.CompteBloc(1, 1, 1)); + Assert.Equal("2 affichés sur 5 tomes", Libelles.CompteBloc(2, 5, 5)); + } +} diff --git a/MaBibli.Tests/RafaleIsbnTests.cs b/MaBibli.Tests/RafaleIsbnTests.cs new file mode 100644 index 0000000..acd7a75 --- /dev/null +++ b/MaBibli.Tests/RafaleIsbnTests.cs @@ -0,0 +1,133 @@ +using MaBibli.Shared.Catalogue; + +namespace MaBibli.Tests; + +/// +/// La file de catalogage en rafale : découpe de la saisie, et comptes du compte rendu. +/// +public class RafaleIsbnTests +{ + [Fact] + public void Un_code_par_ligne() + { + Assert.Equal( + ["9782070612758", "9782253004226"], + SaisieRafale.Decouper("9782070612758\n9782253004226")); + } + + [Fact] + public void Les_lignes_vides_et_les_retours_windows_sont_tolerés() + { + Assert.Equal( + ["1112223334445", "5554443332221"], + SaisieRafale.Decouper("1112223334445\r\n\r\n5554443332221\r\n")); + } + + [Fact] + public void Les_tirets_et_espaces_de_la_douchette_disparaissent() + { + // Une douchette tape des chiffres, mais une saisie manuelle peut recopier la forme + // imprimée : la normalisation est la même que celle d'IsbnUtils. + Assert.Equal(["9782070612758"], SaisieRafale.Decouper("978-2-07-061275-8")); + } + + [Fact] + public void Le_X_final_d_un_isbn10_survit() + { + Assert.Equal(["222403313X"], SaisieRafale.Decouper("222403313x")); + } + + [Fact] + public void Un_code_scanne_deux_fois_n_entre_qu_une_fois() + { + // ⚠️ Rescanner par mégarde est le geste le plus banal d'une rafale. Interroger la BnF + // deux fois pour le même code ne peut rien apporter. + Assert.Equal( + ["9782070612758", "9782253004226"], + SaisieRafale.Decouper("9782070612758\n9782253004226\n9782070612758")); + } + + [Fact] + public void L_ordre_de_saisie_est_conserve() + { + // On repasse sur la pile dans l'ordre où on l'a scannée : c'est ce qui permet de + // retrouver le livre suivant sur la table. + Assert.Equal(["333", "111", "222"], SaisieRafale.Decouper("333\n111\n222")); + } + + [Fact] + public void Une_saisie_vide_ne_donne_aucun_code() + { + Assert.Empty(SaisieRafale.Decouper(null)); + Assert.Empty(SaisieRafale.Decouper(" \n\n ")); + } + + private static Rafale Rafale(params EtatCodeRafale[] etats) => new() + { + Codes = [.. etats.Select((e, i) => new CodeRafale { Code = $"code{i}", Etat = e })], + }; + + [Fact] + public void Le_pourcentage_ne_compte_que_ce_qui_est_traite() + { + var rafale = Rafale( + EtatCodeRafale.Cree, EtatCodeRafale.Passe, + EtatCodeRafale.ATraiter, EtatCodeRafale.ATraiter); + + Assert.Equal(50, rafale.Pourcentage); + Assert.True(rafale.ResteATraiter); + } + + [Fact] + public void Une_rafale_vide_ne_divise_pas_par_zero() + { + Assert.Equal(0, new Rafale().Pourcentage); + } + + [Fact] + public void Les_residus_sont_ce_qui_attend_une_decision() + { + var rafale = Rafale( + EtatCodeRafale.Cree, EtatCodeRafale.Passe, EtatCodeRafale.AChoisir, + EtatCodeRafale.Introuvable, EtatCodeRafale.Revue, EtatCodeRafale.Echec); + + // ⚠️ Ni les créés ni les passés : ils sont terminés, et les rappeler ferait croire + // qu'il reste du travail dessus. + Assert.Equal(4, rafale.Residus.Count); + Assert.All(rafale.Residus, c => Assert.False(c.Termine)); + } + + [Fact] + public void Les_passes_se_comptent_a_part_des_crees() + { + // ⚠️ C'est ce qui empêche de croire qu'on a ajouté cinquante livres quand on en a + // ajouté quarante-deux : posséder deux exemplaires est légitime, passer en silence non. + var rafale = Rafale( + EtatCodeRafale.Cree, EtatCodeRafale.Cree, EtatCodeRafale.Passe); + + Assert.Equal(2, rafale.Crees); + Assert.Equal(1, rafale.Passes); + } + + [Fact] + public void Passer_les_doublons_est_coche_par_defaut() + { + Assert.True(new Rafale().PasserLesDoublons); + } + + [Fact] + public void Un_code_cree_ou_passe_est_termine_les_autres_non() + { + Assert.True(new CodeRafale { Code = "x", Etat = EtatCodeRafale.Cree }.Termine); + Assert.True(new CodeRafale { Code = "x", Etat = EtatCodeRafale.Passe }.Termine); + + foreach (var etat in new[] + { + EtatCodeRafale.ATraiter, EtatCodeRafale.AChoisir, + EtatCodeRafale.Introuvable, EtatCodeRafale.Revue, EtatCodeRafale.Echec, + }) + { + Assert.False(new CodeRafale { Code = "x", Etat = etat }.Termine); + } + } +} diff --git a/MaBibli.Tests/RechercheIsbnTests.cs b/MaBibli.Tests/RechercheIsbnTests.cs new file mode 100644 index 0000000..fa99e31 --- /dev/null +++ b/MaBibli.Tests/RechercheIsbnTests.cs @@ -0,0 +1,70 @@ +using MaBibli.Shared.Isbn; + +namespace MaBibli.Tests; + +/// +/// Reconnaissance d'un code dans le champ de recherche du catalogue. +/// +/// +/// L'enjeu n'est pas de trouver un ISBN — c'est de ne pas en voir un là où il n'y en a +/// pas : « 1984 » est un titre, et le confondre avec un fragment de code remplacerait une +/// réponse par du bruit. +/// +public class RechercheIsbnTests +{ + [Theory] + [InlineData("Germinal")] + [InlineData("Zola 1984")] + [InlineData("1984")] + [InlineData("2026")] + [InlineData("1234567")] // sept chiffres : sous le seuil + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Ce_qui_n_est_pas_un_code_reste_une_recherche_de_mots(string? terme) + => Assert.Null(RechercheIsbn.Formes(terme)); + + [Fact] + public void Un_ean13_est_cherche_sous_ses_deux_formes() + { + // ⚠️ Le cœur de la fonctionnalité : la douchette lit toujours un 13, alors qu'un livre + // paru avant 2007 est rangé sous son 10. + var formes = RechercheIsbn.Formes("9782253004226"); + + Assert.NotNull(formes); + Assert.Equal(["9782253004226", "2253004227"], formes); + } + + [Fact] + public void Les_tirets_de_l_affichage_se_recollent() + => Assert.Equal( + RechercheIsbn.Formes("9782253004226"), + RechercheIsbn.Formes("978-2-25-300422-6")); + + [Fact] + public void Un_fragment_assez_long_reste_un_code_mais_sans_seconde_forme() + { + // La conversion 13 → 10 n'a pas de sens sur un fragment : elle exige un ISBN complet. + var formes = RechercheIsbn.Formes("97822530"); + + Assert.Equal(["97822530"], formes); + } + + [Fact] + public void Un_isbn_10_terminant_par_X_est_reconnu() + { + var formes = RechercheIsbn.Formes("2-266-11156-X"); + + Assert.Equal(["226611156X"], formes); + } + + [Theory] + [InlineData("9782253004226", true)] + [InlineData("978-2-25-300422-6", true)] + [InlineData("2253004227", true)] + [InlineData("9782253004227", false)] // clé fausse : ce n'est pas une édition, c'est une faute + [InlineData("97822530", false)] // fragment : le résultat unique serait un hasard + [InlineData("Germinal", false)] + public void Seul_un_code_complet_et_valide_autorise_l_ouverture_directe(string terme, bool attendu) + => Assert.Equal(attendu, RechercheIsbn.EstCodeComplet(terme)); +} diff --git a/MaBibli.Tests/RemonteeRoutesTests.cs b/MaBibli.Tests/RemonteeRoutesTests.cs index 015cc52..cc95765 100644 --- a/MaBibli.Tests/RemonteeRoutesTests.cs +++ b/MaBibli.Tests/RemonteeRoutesTests.cs @@ -27,6 +27,9 @@ public class RemonteeRoutesTests [InlineData("/ajout", "/")] [InlineData("/ajout/isbn", "/")] [InlineData("/ajout/manuel", "/")] + // ⚠️ La rafale remonte à « /ajout », pas au catalogue : on veut souvent y revenir pour + // saisir autrement ce qui a résisté au traitement. + [InlineData("/ajout/rafale", "/ajout")] // Revues [InlineData("/revues/ajout", "/revues")] [InlineData("/revues/12/edition", "/revues/12")] diff --git a/MaBibli.Tests/ServicePretsTests.cs b/MaBibli.Tests/ServicePretsTests.cs index af990bd..d19d40a 100644 --- a/MaBibli.Tests/ServicePretsTests.cs +++ b/MaBibli.Tests/ServicePretsTests.cs @@ -201,6 +201,38 @@ public class ServicePretsTests : IDisposable Assert.True((await _prets.RendreAsync(999, new RetourPret())).EstIntrouvable); } + [Fact] + public async Task Supprimer_efface_un_pret_clos_de_l_historique() + { + // ⚠️ C'est l'inverse exact de « rendre » : on défait une SAISIE (mauvais emprunteur, + // mauvaise date, prêt noté deux fois), on ne clôt pas un prêt réel. + var livreId = await LivreAsync(); + var pret = (await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" })).Pret!; + await _prets.RendreAsync(pret.Id, new RetourPret()); + + Assert.True(await _prets.SupprimerAsync(pret.Id)); + + Assert.Empty(await _prets.HistoriqueAsync(livreId) ?? []); + Assert.Empty(await _db.Prets.ToListAsync()); + } + + [Fact] + public async Task Supprimer_le_pret_en_cours_remet_le_livre_a_la_maison() + { + var livreId = await LivreAsync(); + var pret = (await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" })).Pret!; + + Assert.True(await _prets.SupprimerAsync(pret.Id)); + + // L'index unique partiel ne bloque plus : la place est libre, le livre peut repartir. + Assert.Empty(await _prets.EnCoursAsync()); + Assert.Null((await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Marie" })).Erreur); + } + + [Fact] + public async Task Supprimer_un_pret_inconnu_ne_fait_rien() + => Assert.False(await _prets.SupprimerAsync(999)); + [Fact] public async Task Rendre_refuse_une_date_anterieure_au_pret() { diff --git a/MaBibli.Tests/TriNaturelTests.cs b/MaBibli.Tests/TriNaturelTests.cs new file mode 100644 index 0000000..1464d11 --- /dev/null +++ b/MaBibli.Tests/TriNaturelTests.cs @@ -0,0 +1,92 @@ +using MaBibli.Shared.Textes; + +namespace MaBibli.Tests; + +/// +/// Le tri des numéros de tome — celui qui range « 2 » avant « 10 ». +/// +public class TriNaturelTests +{ + private static List Trier(params string?[] valeurs) => + valeurs.OrderBy(v => v, TriNaturel.Comparateur).ToList(); + + [Fact] + public void Les_nombres_se_lisent_comme_des_nombres() + { + // Le défaut que ce comparateur existe pour corriger : un tri ordinal range + // 1, 10, 11, 2, 9 — parce que le caractère « 1 » précède le « 2 ». + Assert.Equal( + ["1", "2", "9", "10", "11"], + Trier("11", "2", "10", "1", "9")); + } + + [Fact] + public void Les_trois_blocs_sont_dans_l_ordre_decide() + { + // Tranché avec l'utilisateur : vide, puis non-numériques a→z, puis chiffres 0→9. + Assert.Equal( + [null, "", "Hors-série", "Intégrale 1-3", "1", "2", "10"], + Trier("10", "Intégrale 1-3", "2", null, "Hors-série", "1", "")); + } + + [Fact] + public void Un_numero_decimal_se_range_entre_ses_voisins() + { + Assert.Equal(["3", "3.5", "4"], Trier("4", "3.5", "3")); + } + + [Fact] + public void Le_prefixe_passe_avant_ce_qui_le_prolonge() + { + Assert.Equal(["3", "3 bis"], Trier("3 bis", "3")); + } + + [Fact] + public void Les_zeros_de_tete_ne_changent_pas_l_ordre() + { + Assert.Equal(0, TriNaturel.Comparer("007", "7")); + Assert.Equal(["007", "8"], Trier("8", "007")); + } + + [Fact] + public void Un_tres_grand_nombre_ne_fait_pas_echouer_le_tri() + { + // ⚠️ Le comparateur ne convertit jamais en int : rien ne borne un numéro saisi + // à la main, et un dépassement serait une exception au milieu d'un affichage. + var enorme = new string('9', 40); + Assert.True(TriNaturel.Comparer("12", enorme) < 0); + } + + [Fact] + public void Le_tri_ignore_la_casse_et_les_accents() + { + // Même normalisation que partout ailleurs : « Édition » et « edition » se rencontrent. + Assert.Equal(0, TriNaturel.Comparer("Hors-Série", "hors-serie")); + } + + [Fact] + public void Le_texte_se_compare_de_a_a_z() + { + Assert.Equal( + ["Alpha", "Hors-série", "Zeta"], + Trier("Zeta", "Alpha", "Hors-série")); + } + + [Fact] + public void Le_tri_est_stable_et_symetrique() + { + // Un comparateur incohérent fait lever OrderBy sur de gros jeux : on vérifie que + // comparer(a,b) et comparer(b,a) sont bien opposés sur toutes les paires. + string?[] valeurs = [null, "", "1", "2", "10", "3.5", "3 bis", "Hors-série", "007", "Zeta"]; + + foreach (var a in valeurs) + { + foreach (var b in valeurs) + { + Assert.Equal( + Math.Sign(TriNaturel.Comparer(a, b)), + -Math.Sign(TriNaturel.Comparer(b, a))); + } + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 3e510a3..c42b1e1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,8 +4,7 @@ Ce que fait chaque morceau, la décision qui le tient, et le piège à ne pas ro Pour installer, voir [installer.md](installer.md). Pour sortir une version, [publier-une-version.md](publier-une-version.md). Le détail complet des décisions, avec -Le périmètre de la V1, les invariants à ne pas casser et les pistes encore ouvertes -sont dans `CLAUDE.md`. +leurs mesures est dans `CLAUDE.md`. | | | |---|---| diff --git a/docs/installer.md b/docs/installer.md index ecbd94b..ce93959 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -15,7 +15,7 @@ Pour comprendre le code, voir [architecture.md](architecture.md). ## Le paquet YunoHost -MaBibli se déploie via un **second dépôt**, [`mabibli_ynh`](https://forge.example.org/mabibli_ynh), +MaBibli se déploie via un **second dépôt**, [`mabibli_ynh`](https://git.akbar.nohost.me/mathieu/mabibli_ynh), qui **ne contient aucun code C#** : il porte le manifeste, les configurations nginx et systemd, et les scripts d'installation. Toute la documentation, elle, vit ici — ce fichier est le point d'entrée unique du projet. @@ -198,7 +198,7 @@ curl -fsSL "https://forge.example.org/mabibli_ynh/raw/branch/main/manifest.toml" #### 5. Installer ```bash -sudo yunohost app install https://forge.example.org/mabibli_ynh --debug +sudo yunohost app install https://git.akbar.nohost.me/mathieu/mabibli_ynh --debug ``` YunoHost demande le domaine (celui créé à l'étape 1) et le groupe autorisé (`all_users`).