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
+ @Confirmation(pret)
+
@@ -127,11 +131,21 @@
@foreach (var passe in Historique)
{
- @passe.Emprunteur
-
- du @Jour(passe.DatePret) au @Jour(passe.DateRetour!.Value)
- — @Duree(passe.JoursEcoules)
-
+ @* La croix reste SUR la ligne des dates : elle appartient à ce prêt, et
+ reléguée dessous elle se lisait comme une action de la liste entière. *@
+
+
+ @passe.Emprunteur
+
+ du @Jour(passe.DatePret) au @Jour(passe.DateRetour!.Value)
+ — @Duree(passe.JoursEcoules)
+
+
+
+ @Croix(passe)
+
+
+ @Confirmation(passe)
}
@@ -143,7 +157,44 @@
}
+@*
+ ⚠️ Effacer un prêt N'EST PAS le rendre, et les deux ne doivent jamais se confondre. Rendre
+ pose une date et garde la ligne : c'est l'histoire du livre, et la raison d'être de la table.
+ La croix défait une SAISIE — mauvais emprunteur, mauvaise date, prêt noté deux fois. Un
+ historique juste vaut mieux qu'un historique complet mais faux.
+
+ ⚠️ Elle se confirme, et le mot le dit : « définitivement ». C'est la seule action de cet
+ écran qui détruit quelque chose, et elle est à côté de deux boutons qui n'en détruisent
+ aucun.
+*@
@code {
+ /// La croix d'effacement d'une ligne de prêt.
+ private RenderFragment Croix(PretDto pret) =>
+ @ _aEffacer = pret.Id">✕;
+
+ /// 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 @@
_suggestionsOuvertes = true"
placeholder="@Placeholder" autocomplete="off" />
@_message
}
+ @*
+ Ce qui existe déjà ailleurs, proposé dès le focus — un thème est un VOCABULAIRE, il ne
+ vaut que réutilisé : « fantasy » et « Fantaisie » saisis séparément ne regroupent rien.
+
+ ⚠️ La liste ne se referme PAS au blur, et c'est délibéré : le blur précède le clic sur
+ une proposition, et la faire disparaître à ce moment-là rend les propositions
+ incliquables — le piège classique de l'autocomplétion. Elle se referme quand on a choisi.
+ *@
+ @if (Proposees.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 livreSaisir 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)
+{
+
@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à.
+
+ }
+
+
+
+ @(_rafale.ResteATraiter || _rafale.Residus.Count > 0 ? "Reprendre" : "Revoir le compte rendu")
+
+
+ @(_rafale.ResteATraiter || _rafale.Residus.Count > 0
+ ? "Abandonner cette rafale"
+ : "Commencer une nouvelle rafale")
+
+
+
+}
+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.
+
+ @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)
+ {
+
+ 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
+
+ }
+
+
+ Terminer et vider la liste
+
+
+}
+
+@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.
+*@
}
+ @*
+ « 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" />
+ 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)" />
+ 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.
+ *@
+
+ Ajouter
+
+
@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.
+ *@
+
+ Ouvrir(VoieAjout.Manuelle)">Manuellement
+
+ @*
+ ⚠️ 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
+ {
+ En rafale
+ }
+
+ Ouvrir(VoieAjout.Catalogue)">Depuis le catalogue
+
+ @*
+ 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.
+ *@
+ Depuis mes envies
+
+
+ @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. *@
+
+
+
+
+
+ @(SaisieEstUnCode ? "Chercher ce code" : "Ajouter ce tome")
+
+
+
+ @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)
+ {
+ AjouterTomeAsync(livre.Id)">
+ @livre.Titre@(livre.AuteursAffiches is { } a ? $" — {a}" : "")
+
+ }
+
+ }
+
+ @* 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)
+ {
+
+ 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.
+
+
+ @*
+ ⚠️ 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.
*@
@@ -139,7 +352,17 @@ else
@onclick="() => DeplacerAsync(index, index - 1)">▲
}
- @(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
}
+ OuvrirNumero(element)">
+ Numéro
+
+
RetirerAsync(element)">
@@ -229,11 +458,28 @@ else
}
+ @if (_numeroOuvert == element.Id)
+ {
+
+ SurToucheNumero(e, element))"
+ aria-label="@($"Numéro du tome « {element.Titre} »")"
+ autocomplete="off" />
+ EnregistrerNumeroAsync(element)">Enregistrer
+ _numeroOuvert = null">Annuler
+
-
- @*
- 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.
- *@
-
-
-
-
-
- AjouterTomeAsync(null)">
- Ajouter ce tome
-
-
- OuvrirRattachement(AjoutDepuisCatalogue)">
- Depuis le catalogue
-
-
- @*
- 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.
- *@
-
- Depuis mes envies
-
-
- 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 livre in _resultats)
- {
- AjouterTomeAsync(livre.Id)">
- @livre.Titre@(livre.AuteursAffiches is { } a ? $" — {a}" : "")
-
- }
-
- }
- }
@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 À L'ORIGINE, et un segment peut être un LIEN (« En rafale », qui
+ mène à l'écran de rafale) : sans les trois lignes de mise en forme ci-dessous, il gardait sa
+ couleur de lien, son soulignement et son texte collé en haut. Même famille de défaut que les
+ NavLink du lot H — un style pensé pour une balise, appliqué à une autre. */
.segment {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ text-decoration: none;
flex: 1 1 auto;
min-height: 2.5rem;
padding: 0.4rem 0.7rem;
@@ -263,6 +271,11 @@ body {
cursor: pointer;
}
+.segment:disabled {
+ opacity: 0.55;
+ cursor: default;
+}
+
.segment-actif {
background: var(--mb-accent);
border-color: var(--mb-accent);
@@ -447,7 +460,7 @@ body {
gap: 0.4rem;
min-width: 0;
padding: 0.35rem 0;
- color: var(--mb-texte);
+ color: var(--mb-accent);
text-decoration: none;
font-weight: 600;
}
@@ -634,12 +647,50 @@ body {
border-bottom: 1px solid var(--mb-bord);
}
+/* Emprunteur, dates et croix sur UNE ligne : la croix appartient à ce prêt-là. */
+.historique-ligne {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.historique-qui {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
.historique-dates {
display: block;
color: var(--mb-texte-doux);
font-size: 0.85rem;
}
+/* Effacer une ligne de prêt saisie par erreur. Discrète au repos — c'est une correction, pas
+ un geste courant — mais avec une cible tactile entière : 2,75 rem, comme la croix du calque
+ des couvertures. */
+.bouton-croix {
+ min-width: 2.25rem;
+ min-height: 2.25rem;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ color: var(--mb-texte-doux);
+ font-size: 0.95rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.bouton-croix:hover:not(:disabled),
+.bouton-croix:focus-visible {
+ background: var(--mb-fond-doux);
+ color: var(--mb-danger);
+}
+
+.bouton-croix:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
/* Bouton d'action au sein d'une carte : il ne doit pas peser autant qu'un bouton de page,
tout en gardant une cible tactile utilisable. */
.bouton-compact {
@@ -700,6 +751,11 @@ body {
cursor: default;
}
+/* Image cassée, ou absente : le clic ne promet plus un agrandissement — il ouvre l'adresse. */
+.couverture-declencheur.declencheur-correction {
+ cursor: pointer;
+}
+
.calque-couverture {
position: fixed;
inset: 0;
@@ -1175,12 +1231,36 @@ body {
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
+ flex-wrap: wrap;
}
.entete-page .titre-page {
margin-bottom: 0.5rem;
}
+/* Panneau « Ajouter un tome » d'une série : déplié sur place sous l'en-tête, comme les filtres
+ du catalogue. Encadré, pour qu'on voie où il commence et où il finit. */
+.panneau-ajout {
+ margin-bottom: 1rem;
+ padding: 0.75rem;
+ border: 1px solid var(--mb-bord);
+ border-radius: 8px;
+ background: var(--mb-fond-doux);
+}
+
+.panneau-ajout .titre-section {
+ margin-top: 0;
+}
+
+/* Les deux boutons de l'en-tête du catalogue — régler la liste, et y ajouter. Ils
+ s'enroulent plutôt que de pousser le titre hors de l'écran à 320 px. */
+.entete-page-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ flex: 0 0 auto;
+}
+
/* --- Liste d'envies : ordre choisi --- */
/* Colonne de déplacement à gauche de chaque envie. Les flèches sont larges à dessein :
@@ -1205,6 +1285,18 @@ body {
font-variant-numeric: tabular-nums;
}
+/* Le NUMÉRO du tome, à distinguer du rang de lecture — deux natures de valeur qui
+ partagent la même colonne. Sans ce repère, « 4 » se lit indifféremment « quatrième
+ à lire » ou « tome 4 », et les deux diffèrent dès qu'une préquelle est en jeu. */
+.rang-tome {
+ color: var(--mb-accent);
+ font-weight: 600;
+ background: var(--mb-fond-doux);
+ border-radius: 0.25rem;
+ padding: 0.1rem 0.3rem;
+ white-space: nowrap;
+}
+
/* La carte saisie s'efface pendant le glissé : sans ce repère, on ne sait plus laquelle
on déplace une fois le curseur loin de son point de départ. */
.carte-glissee {
@@ -1737,3 +1829,108 @@ body {
flex: 0 0 auto;
white-space: nowrap;
}
+
+/* Vocabulaire déjà employé, proposé sous un champ de liste (les thèmes). Même aspect que les
+ segments : ce sont des valeurs qu'on choisit, pas des actions. */
+.suggestions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ margin-bottom: 0.5rem;
+}
+
+/* ---------------------------------------------------------------------------
+ Catalogage en rafale (lot AB)
+ --------------------------------------------------------------------------- */
+
+/* La douchette tape « Entrée » après chaque lecture : dans un textarea, cela fait un saut de
+ ligne, et la rafale se collecte donc toute seule. Monospace pour que des chiffres alignés
+ se relisent, et `resize: vertical` parce qu'on ne sait pas d'avance combien de livres. */
+.zone-codes {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 0.9rem;
+ line-height: 1.5;
+ resize: vertical;
+ min-height: 8rem;
+}
+
+.case-a-cocher {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0.75rem 0;
+ cursor: pointer;
+}
+
+/* Barre d'avancement. ⚠️ `role="progressbar"` porte l'information pour les lecteurs d'écran ;
+ ceci n'en est que la peinture, d'où l'absence de texte à l'intérieur. */
+.avancement {
+ height: 0.5rem;
+ background: var(--mb-fond-doux);
+ border-radius: 0.25rem;
+ overflow: hidden;
+ margin: 0.75rem 0 0.5rem;
+}
+
+.avancement-barre {
+ height: 100%;
+ background: var(--mb-accent);
+ transition: width 0.2s ease;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .avancement-barre { transition: none; }
+}
+
+.bilan {
+ list-style: none;
+ padding: 0;
+ margin: 0.5rem 0 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.liste-residus {
+ list-style: none;
+ padding: 0;
+ margin: 0.5rem 0 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.carte-residu {
+ border: 1px solid var(--mb-bord);
+ border-radius: 0.4rem;
+ padding: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+/* ⚠️ `min-width: 0` sur l'enfant élastique — la leçon du lot M, qui se repose à chaque étage
+ d'une cascade de flex : sans lui, un ISBN à tirets pousse l'étiquette hors d'un écran de
+ 320 px, et c'est toute la page qui défile. */
+.residu-tete {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.residu-tete .code-isbn {
+ min-width: 0;
+}
+
+.encart-reprise {
+ border: 1px solid var(--mb-bord);
+ border-left: 3px solid var(--mb-accent);
+ border-radius: 0.4rem;
+ padding: 0.85rem 1rem;
+ margin-bottom: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
diff --git a/MaBibli.Client/wwwroot/js/cache-hors-ligne.js b/MaBibli.Client/wwwroot/js/cache-hors-ligne.js
index bdd059a..24350da 100644
--- a/MaBibli.Client/wwwroot/js/cache-hors-ligne.js
+++ b/MaBibli.Client/wwwroot/js/cache-hors-ligne.js
@@ -17,7 +17,12 @@ const MAGASIN = 'instantanes';
// valeur = le Blob de l'image. Sert uniquement à la consultation hors-ligne — en ligne,
// l' normale reste la voie rapide, ce cache se remplit en tâche de fond à côté.
const MAGASIN_COUVERTURES = 'couvertures';
-const VERSION = 2;
+// File de catalogage en rafale (lot AB). ⚠️ MAGASIN SÉPARÉ, et ce n'est pas un détail de
+// rangement : `instantanes` porte ce que le SERVEUR a rendu, donc effaçable sans rien perdre,
+// alors que cette file porte ce que l'UTILISATEUR a scanné et qui n'existe nulle part ailleurs.
+// Les mêler ferait disparaître cinquante scans le jour où l'on ajouterait un « vider le cache ».
+const MAGASIN_RAFALE = 'rafale';
+const VERSION = 3;
let promesseBase = null;
@@ -34,6 +39,7 @@ function ouvrir() {
const base = requete.result;
if (!base.objectStoreNames.contains(MAGASIN)) base.createObjectStore(MAGASIN);
if (!base.objectStoreNames.contains(MAGASIN_COUVERTURES)) base.createObjectStore(MAGASIN_COUVERTURES);
+ if (!base.objectStoreNames.contains(MAGASIN_RAFALE)) base.createObjectStore(MAGASIN_RAFALE);
};
requete.onsuccess = () => resoudre(requete.result);
requete.onerror = () => rejeter(requete.error);
@@ -175,3 +181,31 @@ export function couvertureLiberer(url) {
// Rien à faire : libérer est un agrément, jamais une condition de bon fonctionnement.
}
}
+
+// ---------------------------------------------------------------------------
+// File de catalogue en rafale (lot AB)
+// ---------------------------------------------------------------------------
+// Une seule entrée, sous une clé fixe : il n'y a jamais qu'une rafale en cours. La valeur est
+// la chaîne JSON produite par C#, comme pour les instantanés — une seule forme fait autorité.
+
+const CLE_RAFALE = 'en-cours';
+
+export async function rafaleEcrire(json) {
+ const base = await ouvrir();
+ const tx = base.transaction(MAGASIN_RAFALE, 'readwrite');
+ const requete = tx.objectStore(MAGASIN_RAFALE).put(json, CLE_RAFALE);
+ await attendre(requete, tx);
+}
+
+export async function rafaleLire() {
+ const base = await ouvrir();
+ const tx = base.transaction(MAGASIN_RAFALE, 'readonly');
+ const valeur = await attendre(tx.objectStore(MAGASIN_RAFALE).get(CLE_RAFALE), tx);
+ return valeur ?? null;
+}
+
+export async function rafaleEffacer() {
+ const base = await ouvrir();
+ const tx = base.transaction(MAGASIN_RAFALE, 'readwrite');
+ await attendre(tx.objectStore(MAGASIN_RAFALE).delete(CLE_RAFALE), tx);
+}
diff --git a/MaBibli.Shared/Catalogue/CritereLivres.cs b/MaBibli.Shared/Catalogue/CritereLivres.cs
index 041c2d2..e967f6a 100644
--- a/MaBibli.Shared/Catalogue/CritereLivres.cs
+++ b/MaBibli.Shared/Catalogue/CritereLivres.cs
@@ -62,4 +62,26 @@ public record CritereLivres
///
///
public bool? Prete { get; init; }
+
+ ///
+ /// true = seulement les livres sans couverture, false = seulement ceux qui en
+ /// ont une, null = tous.
+ ///
+ ///
+ /// Répond à « quels livres me reste-t-il à illustrer ? ». Comme , le
+ /// critère est commun au foyer : une couverture décrit l'ouvrage, pas la relation
+ /// d'un lecteur à lui.
+ ///
+ /// ⚠️ « Sans couverture » veut dire « sans URL enregistrée », et rien d'autre. Ce
+ /// critère ne dit pas si l'image se charge : le savoir 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. Une couverture
+ /// cassée reste donc « avec couverture » ici, et c'est volontaire.
+ ///
+ ///
+ /// Une URL vide compte comme absente : les deux implémentations du filtre doivent
+ /// s'accorder là-dessus, un test les y confronte.
+ ///
+ ///
+ public bool? SansCouverture { get; init; }
}
diff --git a/MaBibli.Shared/Catalogue/FiltreLivres.cs b/MaBibli.Shared/Catalogue/FiltreLivres.cs
index 40020aa..3aab8a5 100644
--- a/MaBibli.Shared/Catalogue/FiltreLivres.cs
+++ b/MaBibli.Shared/Catalogue/FiltreLivres.cs
@@ -1,4 +1,5 @@
using MaBibli.Shared.Entites;
+using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
namespace MaBibli.Shared.Catalogue;
@@ -64,6 +65,29 @@ public static class FiltreLivres
: source.Where(l => !l.Prets.Any(p => p.DateRetour == null));
}
+ if (criteres.SansCouverture is { } sansCouverture)
+ {
+ // ⚠️ `IsNullOrEmpty` et non `IsNullOrWhiteSpace` : le second ne se traduit PAS en SQL,
+ // et EF Core basculerait l'évaluation en mémoire — donc sur tout le catalogue chargé.
+ // Une URL vide compte comme absente, comme côté client.
+ source = sansCouverture
+ ? source.Where(l => string.IsNullOrEmpty(l.CoverUrl))
+ : source.Where(l => !string.IsNullOrEmpty(l.CoverUrl));
+ }
+
+ // ⚠️ Un terme qui est un CODE ne se cherche pas dans les mots : il désigne un objet, pas
+ // une œuvre. Les deux branches s'excluent — chercher aussi dans les titres ne rendrait
+ // rien de plus, et laisser la branche des mots seule ne rendrait rien du tout.
+ if (RechercheIsbn.Formes(criteres.Recherche) is { } formes)
+ {
+ var longue = formes[0];
+ var courte = formes.Count > 1 ? formes[1] : null;
+
+ return Trier(source.Where(l =>
+ l.Isbn != null
+ && (l.Isbn.Contains(longue) || (courte != null && l.Isbn.Contains(courte)))));
+ }
+
var terme = NormalisationTexte.Normaliser(criteres.Recherche);
if (terme.Length > 0)
{
diff --git a/MaBibli.Shared/Catalogue/RafaleIsbn.cs b/MaBibli.Shared/Catalogue/RafaleIsbn.cs
new file mode 100644
index 0000000..732b779
--- /dev/null
+++ b/MaBibli.Shared/Catalogue/RafaleIsbn.cs
@@ -0,0 +1,164 @@
+namespace MaBibli.Shared.Catalogue;
+
+///
+/// Où en est un code scanné dans une rafale.
+///
+///
+/// ⚠️ C'est l'existence de ces états qui rend la reprise possible, et la reprise est ce
+/// qui distingue une rafale d'une simple liste de codes. Sans , reprendre une
+/// rafale à moitié traitée recréerait les livres déjà enregistrés — la case « passer si déjà
+/// existant » les rattraperait sans doute, mais par accident, pas par construction.
+///
+public enum EtatCodeRafale
+{
+ /// Scanné, pas encore interrogé.
+ ATraiter = 0,
+
+ /// Livre enregistré. Ne jamais le refaire à la reprise.
+ Cree,
+
+ /// Déjà au catalogue, écarté par la case à cocher.
+ Passe,
+
+ /// Plusieurs notices : l'utilisateur doit choisir l'édition.
+ AChoisir,
+
+ /// Aucune source ne connaît ce code. À saisir à la main.
+ Introuvable,
+
+ /// Code de périodique (préfixe 977) : mis de côté, traité à la fin.
+ Revue,
+
+ /// La source n'a pas répondu. ⚠️ À distinguer d'.
+ Echec,
+}
+
+///
+/// Un code de la file, et ce qu'on en sait.
+///
+public record CodeRafale
+{
+ public required string Code { get; init; }
+
+ public EtatCodeRafale Etat { get; init; } = EtatCodeRafale.ATraiter;
+
+ /// Titre trouvé, ou saisi. Sert à reconnaître la ligne dans le compte rendu.
+ public string? Titre { get; init; }
+
+ /// Renseigné quand l'état est .
+ public int? LivreId { get; init; }
+
+ ///
+ /// Ce qui s'est passé, écrit pour un humain.
+ ///
+ ///
+ /// ⚠️ Jamais un message HTTP brut : c'est la règle déjà tenue partout ailleurs, et le compte
+ /// rendu d'une rafale est précisément un endroit où l'on est tenté de recopier l'erreur telle
+ /// quelle pour aller vite.
+ ///
+ public string? Motif { get; init; }
+
+ /// Reste-t-il quelque chose à faire sur cette ligne ?
+ public bool Termine => Etat is EtatCodeRafale.Cree or EtatCodeRafale.Passe;
+}
+
+///
+/// Une rafale de catalogage : les codes scannés, et où en est chacun.
+///
+///
+/// Persistée dans IndexedDB, magasin rafale — séparé des instantanés, qui portent
+/// ce que le serveur a rendu. Celle-ci porte ce que l'utilisateur a scanné, et qui n'existe
+/// nulle part ailleurs.
+///
+/// ⚠️ Ce ne sont PAS des écritures en attente, et la nuance est ce qui la rend acceptable
+/// dans un projet qui refuse toute file de synchronisation : ce sont des codes à
+/// interroger. Rien n'est promis à la base, rien ne peut entrer en conflit, et l'utilisateur
+/// voit sa liste. Ne pas confondre les deux, sous peine de rouvrir la question.
+///
+///
+public record Rafale
+{
+ public List Codes { get; init; } = [];
+
+ ///
+ /// Passer les livres déjà au catalogue plutôt que de demander à chaque fois.
+ ///
+ ///
+ /// Coché par défaut : quand on catalogue cinquante livres, on ne veut pas être arrêté
+ /// cinquante fois. ⚠️ Mais le compte rendu doit dire combien ont été passés — posséder
+ /// deux exemplaires est légitime (c'est pourquoi l'ISBN ne porte pas d'index unique), et
+ /// passer en silence contredirait cet esprit.
+ ///
+ public bool PasserLesDoublons { get; init; } = true;
+
+ ///
+ /// Série à laquelle rattacher chaque livre créé, quand la rafale a été lancée depuis une saga.
+ ///
+ ///
+ /// ⚠️ Elle vit dans la file et non dans l'écran : une rafale se reprend plus tard, et
+ /// la reprise doit rattacher au même endroit. Elle est reprise du paramètre d'adresse au
+ /// lancement, une fois pour toutes.
+ ///
+ public int? SerieId { get; init; }
+
+ public DateTimeOffset Debut { get; init; } = DateTimeOffset.UtcNow;
+
+ public int Total => Codes.Count;
+
+ public int Traites => Codes.Count(c => c.Etat != EtatCodeRafale.ATraiter);
+
+ public int Crees => Codes.Count(c => c.Etat == EtatCodeRafale.Cree);
+
+ public int Passes => Codes.Count(c => c.Etat == EtatCodeRafale.Passe);
+
+ /// Ce qui attend encore une décision de l'utilisateur.
+ public IReadOnlyList Residus =>
+ [.. Codes.Where(c => c.Etat
+ is EtatCodeRafale.AChoisir or EtatCodeRafale.Introuvable
+ or EtatCodeRafale.Revue or EtatCodeRafale.Echec)];
+
+ public bool ResteATraiter => Codes.Any(c => c.Etat == EtatCodeRafale.ATraiter);
+
+ /// Pourcentage traité, pour l'indicateur d'avancement.
+ public int Pourcentage => Total == 0 ? 0 : Traites * 100 / Total;
+}
+
+///
+/// Découpe une saisie en codes, dans l'ordre, sans doublon.
+///
+public static class SaisieRafale
+{
+ ///
+ /// Un code par ligne — c'est ce que produit une douchette, qui envoie Entrée après
+ /// chaque lecture. Les espaces et tirets sont tolérés, la casse aussi.
+ ///
+ ///
+ /// ⚠️ Les doublons de la saisie sont écartés ici, et non au traitement : scanner deux
+ /// fois le même livre par mégarde est le geste le plus banal d'une rafale, et interroger la
+ /// BnF deux fois pour le même code ne peut rien apporter. Le doublon avec le catalogue,
+ /// lui, est une tout autre question, traitée par PasserLesDoublons.
+ ///
+ public static IReadOnlyList Decouper(string? saisie)
+ {
+ if (string.IsNullOrWhiteSpace(saisie))
+ {
+ return [];
+ }
+
+ var vus = new HashSet(StringComparer.Ordinal);
+ var codes = new List();
+
+ foreach (var ligne in saisie.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries))
+ {
+ var code = new string([.. ligne.Where(c => char.IsAsciiDigit(c) || c is 'X' or 'x')])
+ .ToUpperInvariant();
+
+ if (code.Length > 0 && vus.Add(code))
+ {
+ codes.Add(code);
+ }
+ }
+
+ return codes;
+ }
+}
diff --git a/MaBibli.Shared/Dtos/SerieDto.cs b/MaBibli.Shared/Dtos/SerieDto.cs
index e908dd6..8c7289f 100644
--- a/MaBibli.Shared/Dtos/SerieDto.cs
+++ b/MaBibli.Shared/Dtos/SerieDto.cs
@@ -47,6 +47,9 @@ public record ElementSerieDto
/// Titre du tome : celui du livre quand il est rattaché, sinon celui qui a été saisi.
public required string Titre { get; init; }
+ /// Le numéro imprimé sur le livre. null = inconnu.
+ public string? Numero { get; init; }
+
public int? LivreId { get; init; }
public bool Possede => LivreId is not null;
@@ -76,6 +79,9 @@ public record EnregistrementSerie
///
public record AjoutElementSerie
{
+ /// Le numéro imprimé sur le livre — facultatif.
+ public string? Numero { get; set; }
+
/// Livre du catalogue à placer ici. null = un tome qu'on ne possède pas.
public int? LivreId { get; set; }
diff --git a/MaBibli.Shared/Entites/Serie.cs b/MaBibli.Shared/Entites/Serie.cs
index c7f15c2..aacdf7b 100644
--- a/MaBibli.Shared/Entites/Serie.cs
+++ b/MaBibli.Shared/Entites/Serie.cs
@@ -105,5 +105,34 @@ public class ElementSerie
///
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`).