Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47d371fa3a | ||
|
|
61d4fb1678 | ||
|
|
c5b4e117ba | ||
|
|
d76fb10ee0 | ||
|
|
42dec0dbd1 | ||
|
|
bb071c3db9 |
@@ -0,0 +1,696 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using MaBibli.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MaBibli.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(MaBibliDbContext))]
|
||||
[Migration("20260907234923_NumeroDeTome")]
|
||||
partial class NumeroDeTome
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("NumeroRevueId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NumeroRevueId", "TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ArticlesUne");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CleRegroupement")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NomNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CleRegroupement")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("NomNormalise");
|
||||
|
||||
b.ToTable("Auteurs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.BibliographieMasquee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AuteurId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Utilisateur")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Utilisateur", "AuteurId");
|
||||
|
||||
b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("BibliographiesMasquees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SerieId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LivreId");
|
||||
|
||||
b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId");
|
||||
|
||||
b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"LivreId\" IS NOT NULL");
|
||||
|
||||
b.ToTable("ElementsSerie");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AjoutePar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Editeur")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Format")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Isbn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("NombrePages")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("TypeDocument")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UrlNotice")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Isbn");
|
||||
|
||||
b.HasIndex("TitreNormalise");
|
||||
|
||||
b.ToTable("Livres");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
|
||||
{
|
||||
b.Property<int>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AuteurId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("LivreId", "AuteurId");
|
||||
|
||||
b.HasIndex("AuteurId");
|
||||
|
||||
b.ToTable("LivreAuteurs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Annee")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Auteur")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AuteurNormalise")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Editeur")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Isbn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Rang")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Utilisateur")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur");
|
||||
|
||||
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("LivresSouhaites");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
|
||||
{
|
||||
b.Property<int>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ThemeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("LivreId", "ThemeId");
|
||||
|
||||
b.HasIndex("ThemeId");
|
||||
|
||||
b.ToTable("LivreThemes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DateParution")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NumeroNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RevueId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RevueId", "NumeroNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("NumerosRevue");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("DatePret")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DateRetour")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Emprunteur")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId");
|
||||
|
||||
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours")
|
||||
.IsUnique()
|
||||
.HasFilter("\"DateRetour\" IS NULL");
|
||||
|
||||
b.ToTable("Prets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AuteurAId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AuteurBId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AuteurAId", "AuteurBId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RapprochementsRefuses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AjoutePar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Editeur")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Issn\" IS NOT NULL");
|
||||
|
||||
b.ToTable("Revues");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.RevueSouhaitee", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Issn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NumeroNormalise")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<int>("Rang")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Utilisateur")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex(new[] { "Utilisateur" }, "IX_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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AjoutePar")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAjout")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("SerieParenteId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitreNormalise")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SerieParenteId");
|
||||
|
||||
b.HasIndex("TitreNormalise")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("DateMaj")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Statut")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Utilisateur")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Utilisateur");
|
||||
|
||||
b.HasIndex("LivreId", "Utilisateur")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("StatutsLecture");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Nom")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MaBibli.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class NumeroDeTome : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Numero",
|
||||
table: "ElementsSerie",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Numero",
|
||||
table: "ElementsSerie");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,9 @@ namespace MaBibli.Api.Data.Migrations
|
||||
b.Property<int?>("LivreId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Numero")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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<SerieDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapDelete("/elements/{elementId:int}", async (
|
||||
int elementId, IServiceSeries service, CancellationToken ct) =>
|
||||
await service.RetirerElementAsync(elementId, ct)
|
||||
|
||||
@@ -29,6 +29,17 @@ public interface IServicePrets
|
||||
Task<ResultatPret> PreterAsync(int livreId, EnregistrementPret saisie, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatPret> RendreAsync(int pretId, RetourPret retour, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Efface un prêt, en cours ou clos. <c>false</c> s'il n'existe pas.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Ce n'est pas « rendre »</b>, 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 <b>défait une saisie</b> — mauvais emprunteur, mauvaise date, prêt
|
||||
/// noté deux fois. Un historique juste vaut mieux qu'un historique complet mais faux.
|
||||
/// </remarks>
|
||||
Task<bool> SupprimerAsync(int pretId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -175,6 +186,22 @@ public sealed class ServicePrets(MaBibliDbContext db) : IServicePrets
|
||||
return ResultatPret.Ok((await ObtenirAsync(pret.Id, ct))!);
|
||||
}
|
||||
|
||||
public async Task<bool> 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<PretDto?> ObtenirAsync(int pretId, CancellationToken ct)
|
||||
{
|
||||
var pret = await Requete().FirstOrDefaultAsync(p => p.Id == pretId, ct);
|
||||
|
||||
@@ -37,6 +37,10 @@ public interface IServiceSeries
|
||||
|
||||
Task<ResultatSerie> RattacherLivreAsync(int elementId, int? livreId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Corrige le titre et le numéro d'une place, sans toucher au livre rattaché.</summary>
|
||||
Task<ResultatSerie> ModifierElementAsync(
|
||||
int elementId, AjoutElementSerie saisie, CancellationToken ct = default);
|
||||
|
||||
Task<bool> RetirerElementAsync(int elementId, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatSerie> 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);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ⚠️ Ce point d'entrée existe pour que le <b>numéro de tome</b> 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.
|
||||
/// <para>
|
||||
/// Le livre rattaché n'est <b>pas</b> touché : c'est l'affaire de
|
||||
/// <see cref="RattacherLivreAsync"/>. Un point d'entrée, un geste.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<ResultatSerie> 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<bool> 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
|
||||
|
||||
@@ -214,13 +214,17 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
|
||||
souhait.Isbn = isbn;
|
||||
souhait.Note = Vide(saisie.Note);
|
||||
|
||||
// ⚠️ La couverture n'est écrite QUE si la charge utile en porte une, contrairement aux
|
||||
// autres champs. Raison : aucun écran n'offre de champ « URL de couverture » pour une
|
||||
// envie — décision actée, un tel champ inviterait à coller des liens morts. Un
|
||||
// remplacement inconditionnel effacerait donc, à chaque correction de titre, l'image
|
||||
// que la bibliographie avait fournie. Il n'y a rien à « vider » puisqu'il n'y a rien à
|
||||
// saisir, et la lecture retombe de toute façon sur la formule OpenLibrary à ISBN connu.
|
||||
souhait.CoverUrl = Vide(saisie.CoverUrl) ?? souhait.CoverUrl;
|
||||
// ⚠️ Écrite SEULEMENT quand le geste de correction a été utilisé (CoverUrlModifiee) :
|
||||
// « Modifier une envie » enregistre aussi le titre, l'ISBN, etc. sans qu'on ait touché à
|
||||
// la couverture, et cette valeur affichée peut être une DÉDUCTION à la lecture (formule
|
||||
// OpenLibrary à ISBN connu, voir Projeter) plutôt que ce qu'il y a réellement en base.
|
||||
// La matérialiser à chaque enregistrement figerait cette déduction, et une correction
|
||||
// d'ISBN qui suit ne la referait plus jouer. Le drapeau distingue « je n'y ai pas
|
||||
// touché » de « je viens de vider le champ », ce qu'un simple null ne pourrait pas dire.
|
||||
if (saisie.CoverUrlModifiee)
|
||||
{
|
||||
souhait.CoverUrl = Vide(saisie.CoverUrl);
|
||||
}
|
||||
|
||||
// ⚠️ Indispensable : TitreNormalise est la CLÉ D'ŒUVRE, celle qui sert au rapprochement
|
||||
// « déjà au catalogue ». Sans ce recalcul, corriger un titre laisserait le rapprochement
|
||||
|
||||
@@ -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 <img>. *@
|
||||
@if (Corrigeable)
|
||||
{
|
||||
<button type="button" class="couverture-declencheur declencheur-correction"
|
||||
aria-label="@($"Corriger l'adresse de la couverture de {Titre}")"
|
||||
title="Cette image ne se charge pas — cliquez pour corriger son adresse"
|
||||
@onclick="OnCorriger">
|
||||
<img @key="UrlAffichee" class="couverture-image image-cassee"
|
||||
src="@UrlAffichee" alt="Couverture de @Titre" loading="lazy" />
|
||||
</button>
|
||||
}
|
||||
else if (Agrandissable && !_cassee)
|
||||
{
|
||||
@* Un vrai <button> et pas une <div> cliquable : cible tactile, focus clavier,
|
||||
et activation à l'Entrée sans une ligne de code. *@
|
||||
@@ -54,19 +71,35 @@
|
||||
src="@UrlAffichee"
|
||||
alt="Couverture de @Titre"
|
||||
loading="lazy"
|
||||
onerror="this.classList.add('image-cassee'); const b=this.closest('.couverture-declencheur'); if(b){b.disabled=true;}" />
|
||||
@onerror="() => _cassee = true" />
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<img @key="UrlAffichee"
|
||||
class="couverture-image"
|
||||
class="couverture-image @(_cassee ? "image-cassee" : null)"
|
||||
src="@UrlAffichee"
|
||||
alt="Couverture de @Titre"
|
||||
loading="lazy"
|
||||
onerror="this.classList.add('image-cassee')" />
|
||||
@onerror="() => _cassee = true" />
|
||||
}
|
||||
}
|
||||
else if (OnCorriger.HasDelegate)
|
||||
{
|
||||
@*
|
||||
⚠️ Pas de couverture DU TOUT, et pourtant cliquable : c'est le seul écart à la règle
|
||||
« le substitut à initiale n'est jamais cliquable ». Elle valait tant que le clic
|
||||
servait à AGRANDIR — il n'y avait rien à agrandir. Ici il sert à en POSER une, et
|
||||
c'est précisément le livre qui n'en a pas qui en a besoin.
|
||||
|
||||
Ailleurs (cartes du catalogue, séries, envies), aucune correction n'est offerte :
|
||||
le paramètre n'est pas passé, et le substitut y reste un décor inerte.
|
||||
*@
|
||||
<button type="button" class="couverture-declencheur declencheur-correction"
|
||||
aria-label="@($"Ajouter une couverture à {Titre}")"
|
||||
title="Aucune couverture — cliquez pour en indiquer l'adresse"
|
||||
@onclick="OnCorriger"></button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_agrandie)
|
||||
@@ -95,6 +128,22 @@
|
||||
/// <summary>Le clic sur l'image l'affiche en grand. Sans effet s'il n'y a pas de couverture.</summary>
|
||||
[Parameter] public bool Agrandissable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Que faire quand l'image manque ou ne se charge pas — ouvrir le champ de son adresse.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Renseigné, il <b>remplace</b> l'agrandissement sur une image cassée, et rend cliquable
|
||||
/// le substitut à initiale d'un livre <b>sans</b> couverture. Agrandir une image qui ne
|
||||
/// s'affiche pas n'a jamais servi à rien ; en poser une là où l'on constate qu'elle manque
|
||||
/// est exactement ce qu'on veut faire à cet instant.
|
||||
/// </remarks>
|
||||
[Parameter] public EventCallback OnCorriger { get; set; }
|
||||
|
||||
/// <summary>L'image a échoué à se charger : 404 de l'hébergeur, adresse morte, 502 passager.</summary>
|
||||
private bool _cassee;
|
||||
|
||||
private bool Corrigeable => _cassee && OnCorriger.HasDelegate;
|
||||
|
||||
private bool _agrandie;
|
||||
private bool _aFocaliser;
|
||||
private ElementReference _calque;
|
||||
@@ -141,6 +190,9 @@
|
||||
}
|
||||
|
||||
_urlResoluePour = jeton;
|
||||
|
||||
// Nouvelle image : ce qu'on savait de la précédente ne dit plus rien de celle-ci.
|
||||
_cassee = false;
|
||||
await LibererAsync();
|
||||
|
||||
// Le cache d'abord, EN LIGNE COMME HORS LIGNE : une image déjà sur l'appareil s'affiche
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@inject ServiceLivresApi Api
|
||||
|
||||
@*
|
||||
Formulaire d'une fiche livre, partagé par l'ajout (ISBN ou manuel) et l'édition.
|
||||
|
||||
@@ -53,8 +55,14 @@
|
||||
qui le suit rogné — le séparateur était le seul caractère qu'on ne pouvait pas taper.
|
||||
Voir CLAUDE.md.
|
||||
*@
|
||||
@*
|
||||
⚠️ Les thèmes déjà employés sont PROPOSÉS : un thème est un vocabulaire, il ne vaut que
|
||||
réutilisé. « fantasy » et « Fantaisie » saisis séparément ne regroupent rien, et le
|
||||
filtre par thème n'y verrait que deux étiquettes sans rapport.
|
||||
*@
|
||||
<SaisieListe Valeurs="Saisie.Themes" Libelle="Thèmes" Aide=" (un par un : tapez, puis Entrée)"
|
||||
Placeholder="dark fantasy" MessageDoublon="Ce thème est déjà sur ce livre."
|
||||
Suggestions="_themesConnus"
|
||||
OnChangement="StateHasChanged" />
|
||||
|
||||
@*
|
||||
@@ -67,7 +75,7 @@
|
||||
⚠️ Les rôles n'apparaissent qu'à partir de DEUX auteurs, décision inchangée : un roman
|
||||
n'a qu'un signataire dont le rôle ne se pose pas. Le défaut reste « non précisé ».
|
||||
*@
|
||||
<SaisieListe Valeurs="_noms" Libelle="Auteurs" Aide=" (un par un : tapez, puis Entrée)"
|
||||
<SaisieListe Valeurs="Noms" Libelle="Auteurs" Aide=" (un par un : tapez, puis Entrée)"
|
||||
Placeholder="Prénom Nom, ou Nom, Prénom"
|
||||
MessageDoublon="Cet auteur est déjà sur ce livre."
|
||||
OnChangement="StateHasChanged">
|
||||
@@ -205,18 +213,61 @@
|
||||
/// <remarks>
|
||||
/// ⚠️ Sa <b>référence doit rester stable</b> tant que la saisie ne change pas : le composant
|
||||
/// se remet à zéro quand la liste qu'on lui prête change d'identité, et il perdrait donc à
|
||||
/// chaque rendu ce qui est en cours de frappe.
|
||||
/// chaque rendu ce qui est en cours de frappe. D'où la construction paresseuse, invalidée
|
||||
/// par <c>OnParametersSet</c> et par elle seule.
|
||||
/// <para>
|
||||
/// ⚠️ Elle ne peut PAS être construite dans <c>OnParametersSet</c> uniquement : dès qu'un
|
||||
/// composant a un <c>OnInitializedAsync</c> qui attend vraiment (ici la lecture des thèmes
|
||||
/// connus), Blazor rend une première fois <b>avant</b> d'appeler <c>OnParametersSet</c>. Ce
|
||||
/// rendu-là voyait un <c>null</c>, et l'enfant levait une <c>NullReferenceException</c> —
|
||||
/// invisible à l'écran, qui finissait par s'afficher, mais bien présente en console.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private NomsAuteurs _noms = default!;
|
||||
private NomsAuteurs? _noms;
|
||||
|
||||
private NomsAuteurs Noms => _noms ??= new NomsAuteurs(Saisie.Auteurs);
|
||||
|
||||
private EnregistrementLivre? _saisiePrecedente;
|
||||
|
||||
/// <summary>
|
||||
/// Les thèmes déjà employés dans la bibliothèque, proposés sous le champ.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Déduits du <b>catalogue</b>, et non d'un point d'entrée « /api/themes » : la liste des
|
||||
/// livres est déjà lue partout, elle porte les thèmes, et surtout elle a son instantané —
|
||||
/// une nouvelle vue de l'API en exigerait un autre, faute de quoi les propositions
|
||||
/// disparaîtraient hors-ligne. Le catalogue d'un foyer se charge entier, c'est déjà le parti
|
||||
/// pris de la détection de doublons.
|
||||
/// <para>
|
||||
/// Une lecture ratée ne coûte rien : sans propositions, le champ redevient celui d'avant.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private IReadOnlyList<string> _themesConnus = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_themesConnus =
|
||||
[
|
||||
.. (await Api.ListerAsync(new CritereLivres()))
|
||||
.SelectMany(l => l.Themes)
|
||||
.DistinctBy(NormalisationTexte.Normaliser, StringComparer.Ordinal)
|
||||
.OrderBy(NormalisationTexte.Normaliser, StringComparer.Ordinal),
|
||||
];
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_themesConnus = [];
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ReferenceEquals(_saisiePrecedente, Saisie))
|
||||
{
|
||||
_saisiePrecedente = Saisie;
|
||||
_noms = new NomsAuteurs(Saisie.Auteurs);
|
||||
_noms = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
@inject ServiceLivresApi Api
|
||||
|
||||
@*
|
||||
Panneau de modification en lot, ouvert depuis la fiche série une fois des tomes cochés.
|
||||
|
||||
⚠️ Reprend le DESIGN de FormulaireLivre.razor (mêmes composants, même ordre de champs), mais
|
||||
chaque champ est précédé d'une case « Modifier… » : sans elle, la valeur par défaut d'un champ
|
||||
(« NonPrecise », « Physique »…) est déjà une vraie valeur, et l'appliquer à tous les livres
|
||||
sélectionnés écraserait un champ qu'on ne voulait pas toucher (CLAUDE.md, invariant 10).
|
||||
Un champ non coché ne s'affiche pas — c'est ce qui rend visible ce qui va être écrasé.
|
||||
*@
|
||||
<div class="formulaire">
|
||||
<p class="message-avertissement" role="status">
|
||||
Les champs cochés remplacent la valeur existante sur chacun des @NombreLivres livres
|
||||
sélectionnés — pour les auteurs, une liste vide les retire tous.
|
||||
</p>
|
||||
|
||||
<fieldset class="champ">
|
||||
<legend class="champ-libelle">
|
||||
<label class="bascule">
|
||||
<input type="checkbox" @bind="Saisie.ModifierType" />
|
||||
<span>Type</span>
|
||||
</label>
|
||||
</legend>
|
||||
@if (Saisie.ModifierType)
|
||||
{
|
||||
<div class="segments">
|
||||
@foreach (var type in Types)
|
||||
{
|
||||
<button type="button"
|
||||
class="segment @Libelles.ClasseTypeDocument(type) @(Saisie.TypeDocument == type ? "segment-actif" : null)"
|
||||
@onclick="() => Saisie.TypeDocument = type">
|
||||
@Libelles.TypeDocument(type)
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<div class="champ">
|
||||
<label class="bascule">
|
||||
<input type="checkbox" @bind="Saisie.ModifierThemes" />
|
||||
<span>Thèmes</span>
|
||||
</label>
|
||||
@if (Saisie.ModifierThemes)
|
||||
{
|
||||
<SaisieListe Valeurs="Saisie.Themes" Libelle="Thèmes" Aide=" (un par un : tapez, puis Entrée)"
|
||||
Placeholder="dark fantasy" MessageDoublon="Ce thème est déjà dans la liste."
|
||||
Suggestions="_themesConnus"
|
||||
OnChangement="StateHasChanged" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label class="bascule">
|
||||
<input type="checkbox" @bind="Saisie.ModifierAuteurs" />
|
||||
<span>Auteurs</span>
|
||||
</label>
|
||||
@if (Saisie.ModifierAuteurs)
|
||||
{
|
||||
<SaisieListe Valeurs="Noms" Libelle="Auteurs" Aide=" (un par un : tapez, puis Entrée)"
|
||||
Placeholder="Prénom Nom, ou Nom, Prénom"
|
||||
MessageDoublon="Cet auteur est déjà dans la liste."
|
||||
OnChangement="StateHasChanged">
|
||||
<Complement Context="index">
|
||||
@if (Saisie.Auteurs.Count > 1)
|
||||
{
|
||||
<div class="segments segments-role" role="group"
|
||||
aria-label="@($"Rôle de {Saisie.Auteurs[index].Nom}")">
|
||||
@foreach (var role in Roles)
|
||||
{
|
||||
<button type="button"
|
||||
class="segment @(Saisie.Auteurs[index].Role == role ? "segment-actif" : null)"
|
||||
@onclick="() => PoserRole(index, role)">
|
||||
@Libelles.RoleAuteur(role)
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</Complement>
|
||||
</SaisieListe>
|
||||
}
|
||||
</div>
|
||||
|
||||
<fieldset class="champ">
|
||||
<legend class="champ-libelle">
|
||||
<label class="bascule">
|
||||
<input type="checkbox" @bind="Saisie.ModifierFormat" />
|
||||
<span>Format</span>
|
||||
</label>
|
||||
</legend>
|
||||
@if (Saisie.ModifierFormat)
|
||||
{
|
||||
<div class="segments">
|
||||
@foreach (var format in Formats)
|
||||
{
|
||||
<button type="button"
|
||||
class="segment @(Saisie.Format == format ? "segment-actif" : null)"
|
||||
@onclick="() => Saisie.Format = format">
|
||||
@Libelles.Format(format)
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<div class="champ">
|
||||
<label class="bascule">
|
||||
<input type="checkbox" @bind="Saisie.ModifierEditeur" />
|
||||
<span>Éditeur</span>
|
||||
</label>
|
||||
@if (Saisie.ModifierEditeur)
|
||||
{
|
||||
<input class="champ-saisie" type="text" @bind="Saisie.Editeur" @bind:event="oninput"
|
||||
autocomplete="off" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<fieldset class="champ">
|
||||
<legend class="champ-libelle">
|
||||
<label class="bascule">
|
||||
<input type="checkbox" @bind="Saisie.ModifierStatut" />
|
||||
<span>Statut de lecture <span class="champ-aide">(le vôtre)</span></span>
|
||||
</label>
|
||||
</legend>
|
||||
@if (Saisie.ModifierStatut)
|
||||
{
|
||||
<ChoixStatut Valeur="Saisie.Statut" ValeurChanged="s => Saisie.Statut = s" />
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Erreur))
|
||||
{
|
||||
<p class="message-erreur" role="alert">@Erreur</p>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(MessageBlocage))
|
||||
{
|
||||
<p class="message-avertissement" role="status">@MessageBlocage</p>
|
||||
}
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(EnCours || Bloque || Saisie.AucunChamp)"
|
||||
title="@(Saisie.AucunChamp ? "Cochez au moins un champ à modifier." : MessageBlocage)"
|
||||
@onclick="Valider">
|
||||
@(EnCours ? "Enregistrement…" : LibelleValidation)
|
||||
</button>
|
||||
|
||||
@if (OnAnnuler.HasDelegate)
|
||||
{
|
||||
<button type="button" class="bouton bouton-discret" disabled="@EnCours"
|
||||
@onclick="OnAnnuler">
|
||||
Annuler
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private static readonly Format[] Formats = Enum.GetValues<Format>();
|
||||
|
||||
private static readonly TypeDocument[] Types = Enum.GetValues<TypeDocument>();
|
||||
|
||||
private static readonly RoleAuteur[] Roles = Enum.GetValues<RoleAuteur>();
|
||||
|
||||
private void PoserRole(int index, RoleAuteur role) =>
|
||||
Saisie.Auteurs[index] = Saisie.Auteurs[index] with { Role = role };
|
||||
|
||||
/// <summary>Voir la remarque équivalente sur FormulaireLivre.razor : référence stable tant
|
||||
/// que la saisie ne change pas, sans quoi SaisieListe se remet à zéro à chaque frappe.</summary>
|
||||
private NomsAuteurs? _noms;
|
||||
|
||||
private NomsAuteurs Noms => _noms ??= new NomsAuteurs(Saisie.Auteurs);
|
||||
|
||||
private SaisieLot? _saisiePrecedente;
|
||||
|
||||
private IReadOnlyList<string> _themesConnus = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_themesConnus =
|
||||
[
|
||||
.. (await Api.ListerAsync(new CritereLivres()))
|
||||
.SelectMany(l => l.Themes)
|
||||
.DistinctBy(NormalisationTexte.Normaliser, StringComparer.Ordinal)
|
||||
.OrderBy(NormalisationTexte.Normaliser, StringComparer.Ordinal),
|
||||
];
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_themesConnus = [];
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ReferenceEquals(_saisiePrecedente, Saisie))
|
||||
{
|
||||
_saisiePrecedente = Saisie;
|
||||
_noms = null;
|
||||
}
|
||||
}
|
||||
|
||||
[Parameter, EditorRequired] public SaisieLot Saisie { get; set; } = new();
|
||||
|
||||
[Parameter] public int NombreLivres { get; set; }
|
||||
|
||||
[Parameter] public string LibelleValidation { get; set; } = "Appliquer";
|
||||
|
||||
[Parameter] public string? Erreur { get; set; }
|
||||
|
||||
[Parameter] public bool EnCours { get; set; }
|
||||
|
||||
[Parameter] public string? MessageBlocage { get; set; }
|
||||
|
||||
private bool Bloque => !string.IsNullOrEmpty(MessageBlocage);
|
||||
|
||||
[Parameter] public EventCallback OnValider { get; set; }
|
||||
|
||||
[Parameter] public EventCallback OnAnnuler { get; set; }
|
||||
|
||||
private Task Valider() => OnValider.InvokeAsync();
|
||||
}
|
||||
@@ -4,12 +4,17 @@
|
||||
Un bloc de série au catalogue, replié ou déplié — et récursif, parce qu'un cycle est une
|
||||
série de séries : chaque niveau porte sa propre bascule.
|
||||
|
||||
⚠️ Tout est DÉPLIÉ par défaut, et l'on replie ce dont on ne veut pas. C'est la règle déjà
|
||||
tenue par l'arbre des séries : l'inverse obligerait à ouvrir chaque bloc pour retrouver un
|
||||
livre qu'on voyait très bien avant.
|
||||
⚠️ Tout est REPLIÉ par défaut ici (2026-09-10), à l'inverse de l'arbre des séries : le
|
||||
catalogue liste D'ABORD les livres, une série n'y est qu'un détail qu'on ouvre au besoin —
|
||||
alors que l'écran des séries EST la liste des séries, où les masquer toutes viderait l'écran.
|
||||
|
||||
⚠️ Replié, le bloc annonce toujours ce qu'il contient (« 5 tomes ») : sans ce compte, le
|
||||
compteur du haut du catalogue paraîtrait mentir.
|
||||
⚠️ Replié, le bloc annonce toujours ce qu'il contient : sans ce compte, le compteur du haut
|
||||
du catalogue paraîtrait mentir.
|
||||
|
||||
⚠️ Et ce compte distingue trois choses (2026-09-08) : ce qui est AFFICHÉ (filtre compris), ce
|
||||
qu'on POSSÈDE de cette série, et ce qu'elle compte de tomes. Un seul nombre suivait le
|
||||
filtre — « 3 tomes » devenait « 1 tome » sous une recherche, et se lisait « cette série n'a
|
||||
qu'un tome ». Les égalités se replient : sans filtre et série complète, on relit « 3 tomes ».
|
||||
*@
|
||||
<li class="groupe-serie" @key="Entree.Serie!.Id">
|
||||
<div class="groupe-serie-entete">
|
||||
@@ -26,7 +31,7 @@
|
||||
<a class="groupe-serie-titre" href="@($"series/{Entree.Serie!.Id}")">
|
||||
<span class="groupe-serie-nom">@Entree.Serie!.Titre</span>
|
||||
<span class="groupe-serie-compte">
|
||||
@Entree.NombreLivres tome@(Entree.NombreLivres > 1 ? "s" : "")
|
||||
@Libelles.CompteBloc(Entree.NombreLivres, Possedes, Total)
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -38,7 +43,7 @@
|
||||
<ul class="liste-livres">
|
||||
@foreach (var sous in Entree.SousEntrees)
|
||||
{
|
||||
<GroupeCatalogue Entree="sous" Replies="Replies" />
|
||||
<GroupeCatalogue Entree="sous" Ouverts="Ouverts" Totaux="Totaux" />
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
@@ -61,23 +66,41 @@
|
||||
[Parameter, EditorRequired] public EntreeCatalogue Entree { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Blocs REPLIÉS, par identifiant de série — et non dépliés : le défaut est de tout montrer.
|
||||
/// Blocs OUVERTS, par identifiant de série — et non repliés : le défaut est de tout replier.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// État local de l'écran, volontairement non persistant, comme celui de l'arbre des séries :
|
||||
/// c'est un confort de lecture, pas une propriété de la série. Il survit en revanche aux
|
||||
/// rechargements du catalogue (frappe dans la recherche, filtre), le jeu appartenant à
|
||||
/// l'écran et non au bloc.
|
||||
/// État local de l'écran, volontairement non persistant : c'est un confort de lecture, pas
|
||||
/// une propriété de la série. Il survit en revanche aux rechargements du catalogue (frappe
|
||||
/// dans la recherche, filtre), le jeu appartenant à l'écran et non au bloc.
|
||||
/// </remarks>
|
||||
[Parameter, EditorRequired] public ISet<int> Replies { get; set; } = new HashSet<int>();
|
||||
[Parameter, EditorRequired] public ISet<int> Ouverts { get; set; } = new HashSet<int>();
|
||||
|
||||
private bool Deplie => !Replies.Contains(Entree.Serie!.Id);
|
||||
/// <summary>
|
||||
/// Compteurs par série, descendance comprise : ce qu'on possède, et ce que la série recense.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Calculés une fois par l'écran (<c>TotauxSeries</c>), et non ici : le catalogue se relit
|
||||
/// à chaque frappe dans la recherche, et chaque bloc les recalculerait pour tout l'arbre.
|
||||
/// C'est aussi ce qui garantit le <b>même</b> compte que la fiche de la série.
|
||||
/// </remarks>
|
||||
[Parameter] public IReadOnlyDictionary<int, (int Possedes, int Total)>? Totaux { get; set; }
|
||||
|
||||
private (int Possedes, int Total) Compte =>
|
||||
Totaux is not null && Totaux.TryGetValue(Entree.Serie!.Id, out var connu)
|
||||
? connu
|
||||
: (Entree.NombreLivres, Entree.NombreLivres);
|
||||
|
||||
private int Possedes => Compte.Possedes;
|
||||
|
||||
private int Total => Compte.Total;
|
||||
|
||||
private bool Deplie => Ouverts.Contains(Entree.Serie!.Id);
|
||||
|
||||
private void Basculer()
|
||||
{
|
||||
if (!Replies.Add(Entree.Serie!.Id))
|
||||
if (!Ouverts.Add(Entree.Serie!.Id))
|
||||
{
|
||||
Replies.Remove(Entree.Serie!.Id);
|
||||
Ouverts.Remove(Entree.Serie!.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,4 +66,39 @@ public static class Libelles
|
||||
MaBibli.Shared.Entites.TypeDocument.BandeDessinee => "type-bd",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Ce qu'annonce un bloc de série au catalogue : ce qui y est affiché, ce qu'on possède, et
|
||||
/// ce que la série compte de tomes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Le compte d'un bloc changeait avec le filtre</b> : « 3 tomes » devenait « 1 tome »
|
||||
/// dès qu'une recherche écartait les autres, et se lisait alors comme « cette série n'a qu'un
|
||||
/// tome ». Le nombre affiché ne disait pas de quoi il parlait.
|
||||
/// <para>
|
||||
/// Trois nombres seraient du bruit quand ils coïncident : les égalités se replient donc.
|
||||
/// Un fonds complet et non filtré redit simplement « 3 tomes », comme avant.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="affiches">Tomes de ce bloc actuellement à l'écran, filtre appliqué.</param>
|
||||
/// <param name="possedes">Tomes de cette série présents au catalogue, descendance comprise.</param>
|
||||
/// <param name="total">Tomes recensés dans la série, ceux qui manquent compris.</param>
|
||||
public static string CompteBloc(int affiches, int possedes, int total)
|
||||
{
|
||||
var tomes = total > 1 ? "tomes" : "tome";
|
||||
|
||||
if (affiches == possedes && possedes == total)
|
||||
{
|
||||
return $"{total} {tomes}";
|
||||
}
|
||||
|
||||
if (affiches == possedes)
|
||||
{
|
||||
return $"{possedes} sur {total} {tomes}";
|
||||
}
|
||||
|
||||
return possedes == total
|
||||
? $"{affiches} affiché{(affiches > 1 ? "s" : "")} sur {total} {tomes}"
|
||||
: $"{affiches} affiché{(affiches > 1 ? "s" : "")} · {possedes} sur {total} {tomes}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +69,12 @@
|
||||
à <strong>@pret.Emprunteur</strong>
|
||||
depuis le @Jour(pret.DatePret)
|
||||
<span class="pret-duree">(@Duree(pret.JoursEcoules))</span>
|
||||
|
||||
@Croix(pret)
|
||||
</p>
|
||||
|
||||
@Confirmation(pret)
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal" disabled="@_occupe"
|
||||
@onclick="RendreAsync">
|
||||
@@ -127,11 +131,21 @@
|
||||
@foreach (var passe in Historique)
|
||||
{
|
||||
<li @key="passe.Id">
|
||||
<strong>@passe.Emprunteur</strong>
|
||||
<span class="historique-dates">
|
||||
du @Jour(passe.DatePret) au @Jour(passe.DateRetour!.Value)
|
||||
— @Duree(passe.JoursEcoules)
|
||||
</span>
|
||||
@* 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. *@
|
||||
<div class="historique-ligne">
|
||||
<span class="historique-qui">
|
||||
<strong>@passe.Emprunteur</strong>
|
||||
<span class="historique-dates">
|
||||
du @Jour(passe.DatePret) au @Jour(passe.DateRetour!.Value)
|
||||
— @Duree(passe.JoursEcoules)
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@Croix(passe)
|
||||
</div>
|
||||
|
||||
@Confirmation(passe)
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -143,7 +157,44 @@
|
||||
}
|
||||
</section>
|
||||
|
||||
@*
|
||||
⚠️ 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 {
|
||||
/// <summary>La croix d'effacement d'une ligne de prêt.</summary>
|
||||
private RenderFragment Croix(PretDto pret) =>
|
||||
@<button type="button" class="bouton-croix" disabled="@_occupe"
|
||||
title="Effacer ce prêt (saisie erronée)"
|
||||
aria-label="@($"Effacer le prêt à {pret.Emprunteur}")"
|
||||
@onclick="() => _aEffacer = pret.Id">✕</button>;
|
||||
|
||||
/// <summary>La demande de confirmation, sous la ligne visée seulement.</summary>
|
||||
private RenderFragment Confirmation(PretDto pret) => @<text>
|
||||
@if (_aEffacer == pret.Id)
|
||||
{
|
||||
<p class="message-avertissement" role="status">
|
||||
Effacer définitivement ce prêt ? Il disparaîtra de l'historique du livre.
|
||||
</p>
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-danger" disabled="@_occupe"
|
||||
@onclick="() => EffacerAsync(pret)">
|
||||
@(_occupe ? "Suppression…" : "Oui, effacer")
|
||||
</button>
|
||||
<button type="button" class="bouton bouton-discret" disabled="@_occupe"
|
||||
@onclick="() => _aEffacer = null">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</text>;
|
||||
|
||||
[Parameter, EditorRequired] public int LivreId { get; set; }
|
||||
|
||||
/// <summary>Format du livre : un ebook ne se prête pas.</summary>
|
||||
@@ -169,6 +220,9 @@
|
||||
private string? _erreur;
|
||||
private int _livreCharge;
|
||||
|
||||
/// <summary>Prêt dont l'effacement attend confirmation, s'il y en a un.</summary>
|
||||
private int? _aEffacer;
|
||||
|
||||
private PretDto? EnCours => _prets?.FirstOrDefault(p => p.EstEnCours);
|
||||
|
||||
private IReadOnlyList<PretDto> 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");
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<div class="ligne-creation">
|
||||
<input id="@_idChamp" class="champ-saisie" type="text" @bind="_saisie"
|
||||
@bind:event="oninput" @onkeydown="SurTouche"
|
||||
@onfocus="() => _suggestionsOuvertes = true"
|
||||
placeholder="@Placeholder" autocomplete="off" />
|
||||
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
@@ -43,6 +44,27 @@
|
||||
<p class="message-discret" role="status">@_message</p>
|
||||
}
|
||||
|
||||
@*
|
||||
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)
|
||||
{
|
||||
<div class="suggestions">
|
||||
@foreach (var suggestion in Proposees)
|
||||
{
|
||||
<button type="button" class="segment" @key="suggestion"
|
||||
@onclick="() => Choisir(suggestion)">
|
||||
@suggestion
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Valeurs.Count > 0)
|
||||
{
|
||||
<ul class="liste-saisie">
|
||||
@@ -97,6 +119,16 @@
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment<int>? Complement { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Valeurs déjà employées ailleurs, proposées sous le champ. <c>null</c> = aucune proposition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ N'a de sens que pour un <b>vocabulaire</b> — 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.
|
||||
/// </remarks>
|
||||
[Parameter] public IReadOnlyList<string>? Suggestions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Prévient le parent que la liste a changé.
|
||||
/// </summary>
|
||||
@@ -116,6 +148,45 @@
|
||||
|
||||
private string? _message;
|
||||
|
||||
/// <summary>Les propositions sont dépliées depuis le premier focus, jusqu'à ce qu'on choisisse.</summary>
|
||||
private bool _suggestionsOuvertes;
|
||||
|
||||
/// <summary>Nombre de propositions affichées : au-delà, la liste cache le formulaire.</summary>
|
||||
private const int MaximumPropose = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Ce qui reste à proposer : ni ce qui est déjà sur la fiche, ni ce qui ne correspond pas à
|
||||
/// ce qu'on tape.
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> 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();
|
||||
}
|
||||
|
||||
/// <summary>Liste servie au rendu précédent, pour repérer que le parent en a changé.</summary>
|
||||
private IList<string>? _precedente;
|
||||
|
||||
@@ -137,6 +208,7 @@
|
||||
_saisie = null;
|
||||
_indexModifie = null;
|
||||
_message = null;
|
||||
_suggestionsOuvertes = false;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Entites;
|
||||
|
||||
namespace MaBibli.Client.Composants;
|
||||
|
||||
/// <summary>
|
||||
/// Saisie du panneau « Modifier en lot » (fiche série).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Chaque champ porte sa propre case <c>Modifier…</c> : sans elle, la valeur par défaut d'un
|
||||
/// champ (« NonPrecise », « Physique »…) est déjà une vraie valeur, et l'appliquer à tous les
|
||||
/// livres sélectionnés écraserait un champ qu'on ne voulait pas toucher. Seuls les champs cochés
|
||||
/// sont envoyés ; les autres livres restent inchangés sur les champs non cochés (CLAUDE.md,
|
||||
/// invariant 10 : on n'affiche — et n'applique — que ce qui a été choisi).
|
||||
/// </remarks>
|
||||
public sealed class SaisieLot
|
||||
{
|
||||
public bool ModifierType { get; set; }
|
||||
|
||||
public TypeDocument TypeDocument { get; set; }
|
||||
|
||||
public bool ModifierThemes { get; set; }
|
||||
|
||||
public List<string> Themes { get; set; } = [];
|
||||
|
||||
public bool ModifierAuteurs { get; set; }
|
||||
|
||||
public List<AuteurSaisi> Auteurs { get; set; } = [];
|
||||
|
||||
public bool ModifierFormat { get; set; }
|
||||
|
||||
public Format Format { get; set; }
|
||||
|
||||
public bool ModifierEditeur { get; set; }
|
||||
|
||||
public string? Editeur { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Statut de lecture <b>de l'appelant</b>, comme partout ailleurs : appliquer un statut en
|
||||
/// lot pose la lecture de la personne qui agit, jamais celle d'un tiers.
|
||||
/// </summary>
|
||||
public bool ModifierStatut { get; set; }
|
||||
|
||||
public Statut? Statut { get; set; }
|
||||
|
||||
public bool AucunChamp =>
|
||||
!ModifierType && !ModifierThemes && !ModifierAuteurs
|
||||
&& !ModifierFormat && !ModifierEditeur && !ModifierStatut;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@inject NavigationManager Navigation
|
||||
@inject CacheHorsLigne Cache
|
||||
|
||||
@*
|
||||
Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche.
|
||||
@@ -93,6 +94,16 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@*
|
||||
Une rafale non vidée reste consultable ailleurs sur le site — sans quoi y revenir
|
||||
depuis un autre écran demande de refaire tout le chemin d'« Ajouter ». L'entrée
|
||||
disparaît d'elle-même une fois la file effacée (bouton « Nouvelle rafale »).
|
||||
*@
|
||||
@if (_rafale is { Total: > 0 })
|
||||
{
|
||||
<NavLink class="menu-lien menu-lien-rafale" href="ajout/rafale">@LibelleRafale</NavLink>
|
||||
}
|
||||
|
||||
<NavLink class="menu-lien" href="" Match="NavLinkMatch.All">Catalogue</NavLink>
|
||||
<NavLink class="menu-lien" href="auteurs">Auteurs</NavLink>
|
||||
<NavLink class="menu-lien" href="series">Séries</NavLink>
|
||||
@@ -148,6 +159,12 @@
|
||||
|
||||
private ElementReference _menu;
|
||||
|
||||
/// <summary>Rafale non vidée, s'il y en a une. Relue à chaque navigation (lot AB).</summary>
|
||||
private Rafale? _rafale;
|
||||
|
||||
private string LibelleRafale =>
|
||||
_rafale is { Crees: > 0 } r ? $"Compte rendu ({r.Crees})" : "Rafale à reprendre";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Reseau.Change += SurChangementReseau;
|
||||
@@ -158,6 +175,8 @@
|
||||
// entrées empilées au-dessus de la liste des auteurs.
|
||||
Navigation.LocationChanged += SurNavigation;
|
||||
|
||||
_rafale = await Cache.LireRafaleAsync();
|
||||
|
||||
// Écoute des bascules online/offline avant tout appel : un démarrage hors-ligne doit
|
||||
// aller directement au cache, sans attendre l'échec d'une requête.
|
||||
await Reseau.DemarrerAsync();
|
||||
@@ -226,13 +245,19 @@
|
||||
|
||||
private void SurNavigation(object? _, LocationChangedEventArgs __)
|
||||
{
|
||||
if (!_menuOuvert)
|
||||
if (_menuOuvert)
|
||||
{
|
||||
return;
|
||||
_menuOuvert = false;
|
||||
}
|
||||
|
||||
_menuOuvert = false;
|
||||
_ = InvokeAsync(StateHasChanged);
|
||||
// ⚠️ Relu à CHAQUE navigation, pas seulement au quitter de l'écran de rafale : c'est ce
|
||||
// qui fait apparaître l'entrée du menu dès le premier code scanné, et disparaître dès
|
||||
// la file effacée, où qu'on soit rendu entre-temps.
|
||||
_ = InvokeAsync(async () =>
|
||||
{
|
||||
_rafale = await Cache.LireRafaleAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -19,9 +19,27 @@
|
||||
|
||||
<section class="bloc-apropos">
|
||||
<h2 class="titre-section">Manuel</h2>
|
||||
@*
|
||||
Le manuel vit hors de l'application, sur le site de l'auteur : il se corrige et s'illustre
|
||||
sans qu'il faille republier une version. Même règle que les autres liens sortants — un
|
||||
<a> ne se désactivant pas, hors-ligne il devient un bouton inerte qui porte sa raison,
|
||||
plutôt qu'une navigation qui abandonnerait l'utilisateur sur la page d'erreur du navigateur.
|
||||
*@
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<p>
|
||||
<a href="@Manuel" target="_blank" rel="noopener">Comment utiliser MaBibli</a>
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>
|
||||
<button type="button" class="bouton bouton-discret" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Comment utiliser MaBibli</button>
|
||||
</p>
|
||||
}
|
||||
<p class="message-discret">
|
||||
Un manuel d'utilisation est <strong>à venir</strong>. En attendant, chaque écran porte ses
|
||||
explications à l'endroit où elles servent.
|
||||
Chaque écran porte par ailleurs ses explications à l'endroit où elles servent.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -120,6 +138,9 @@
|
||||
/// <summary>Site de l'auteur, ouvert dans une autre fenêtre.</summary>
|
||||
private const string Site = "https://www.limonier.be";
|
||||
|
||||
/// <summary>Manuel d'utilisation, hébergé avec les autres tutoriels du site.</summary>
|
||||
private const string Manuel = "https://limonier.be/tuto/utiliser_mabibli/";
|
||||
|
||||
/// <summary>Dépôt du code — ce que l'AGPL attend qu'on rende accessible.</summary>
|
||||
private const string Depot = "https://git.akbar.nohost.me/mathieu/mabibli";
|
||||
|
||||
|
||||
@@ -72,6 +72,10 @@
|
||||
question. *@
|
||||
<a class="bouton bouton-discret" href="ajout/manuel">Saisir un livre</a>
|
||||
<a class="bouton bouton-discret" href="revues/ajout">Saisir une revue</a>
|
||||
|
||||
@* 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. *@
|
||||
<a class="bouton bouton-discret" href="ajout/rafale">Cataloguer en rafale</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -225,7 +229,29 @@
|
||||
private EnregistrementLivre _saisie = new();
|
||||
private DoublonsLivre? _doublons;
|
||||
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
/// <summary>
|
||||
/// Code transmis par un autre écran — un résidu de rafale à trancher.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ 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 »).
|
||||
/// </remarks>
|
||||
[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;
|
||||
|
||||
/// <summary>
|
||||
/// Donne le focus au champ ISBN dès que l'étape de saisie s'affiche.
|
||||
@@ -237,6 +263,29 @@
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
@@ -38,7 +38,26 @@ else
|
||||
@code {
|
||||
private readonly EnregistrementLivre _saisie = new();
|
||||
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
/// <summary>
|
||||
/// ISBN transmis par un autre écran — un résidu de rafale qu'aucune source ne connaît.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Sans cette reprise, le lien du compte rendu de rafale menait à un formulaire
|
||||
/// <b>vide</b> : 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
@page "/ajout/rafale"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject CacheHorsLigne Cache
|
||||
@inject EtatReseau Reseau
|
||||
@inject NavigationManager Nav
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — cataloguer en rafale</PageTitle>
|
||||
|
||||
<h1>Cataloguer en rafale</h1>
|
||||
|
||||
@*
|
||||
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)
|
||||
{
|
||||
<p class="aide">
|
||||
Les livres créés seront ajoutés à la série
|
||||
<a href="@($"series/{visee.Id}")"><strong>@visee.Titre</strong></a>.
|
||||
</p>
|
||||
}
|
||||
|
||||
@*
|
||||
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)
|
||||
{
|
||||
<p class="message-erreur" role="alert">@erreur</p>
|
||||
}
|
||||
|
||||
@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 ».
|
||||
*@
|
||||
<div class="encart-reprise">
|
||||
@*
|
||||
⚠️ 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)
|
||||
{
|
||||
<p>
|
||||
Une rafale de <strong>@_rafale.Total code@(_rafale.Total > 1 ? "s" : "")</strong>
|
||||
est restée en cours@(_rafale.Traites > 0 ? $", {_rafale.Traites} déjà traité{(_rafale.Traites > 1 ? "s" : "")}" : "").
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>
|
||||
Votre dernière rafale est terminée :
|
||||
<strong>@_rafale.Crees livre@(_rafale.Crees > 1 ? "s" : "") ajouté@(_rafale.Crees > 1 ? "s" : "")</strong>.
|
||||
Son compte rendu, et les liens vers chaque fiche, sont encore là.
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal" @onclick="Reprendre">
|
||||
@(_rafale.ResteATraiter || _rafale.Residus.Count > 0 ? "Reprendre" : "Revoir le compte rendu")
|
||||
</button>
|
||||
<button type="button" class="bouton bouton-danger" @onclick="AbandonnerAsync">
|
||||
@(_rafale.ResteATraiter || _rafale.Residus.Count > 0
|
||||
? "Abandonner cette rafale"
|
||||
: "Commencer une nouvelle rafale")
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_phase == Phase.Collecte)
|
||||
{
|
||||
<p class="aide">
|
||||
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.
|
||||
</p>
|
||||
|
||||
@*
|
||||
⚠️ 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.
|
||||
*@
|
||||
<div class="champ">
|
||||
<label for="codes">Codes scannés</label>
|
||||
<textarea id="codes" class="champ-saisie zone-codes" rows="10"
|
||||
@ref="_zoneCodes"
|
||||
placeholder="9782070612758 9782253004226 …"
|
||||
@bind="_saisie" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
|
||||
<p class="aide" aria-live="polite">
|
||||
@{
|
||||
var codes = SaisieRafale.Decouper(_saisie).Count;
|
||||
}
|
||||
@if (codes == 0)
|
||||
{
|
||||
<span>Aucun code pour l'instant.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span><strong>@codes</strong> code@(codes > 1 ? "s" : "") — les doublons de saisie sont écartés.</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
@*
|
||||
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.
|
||||
*@
|
||||
<label class="case-a-cocher">
|
||||
<input type="checkbox" @bind="_passerLesDoublons" />
|
||||
Passer les livres déjà au catalogue
|
||||
</label>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(!Reseau.EnLigne || SaisieRafale.Decouper(_saisie).Count == 0)"
|
||||
title="@MotifBlocage"
|
||||
@onclick="LancerAsync">
|
||||
Traiter @(SaisieRafale.Decouper(_saisie).Count > 0 ? $"({SaisieRafale.Decouper(_saisie).Count})" : "")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@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.
|
||||
*@
|
||||
<p class="aide">
|
||||
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.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
else if (_phase == Phase.Traitement)
|
||||
{
|
||||
<h2 class="titre-section">Traitement en cours</h2>
|
||||
|
||||
<div class="avancement" role="progressbar"
|
||||
aria-valuenow="@_rafale.Pourcentage" aria-valuemin="0" aria-valuemax="100"
|
||||
aria-label="Avancement du traitement">
|
||||
<div class="avancement-barre" style="width: @(_rafale.Pourcentage)%"></div>
|
||||
</div>
|
||||
|
||||
<p aria-live="polite">
|
||||
<strong>@_rafale.Pourcentage %</strong> — @_rafale.Traites sur @_rafale.Total
|
||||
@if (_codeEnCours is { } encours)
|
||||
{
|
||||
<span class="aide"> · @FormatageIsbn.Afficher(encours)</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton" @onclick="() => _interrompu = true">
|
||||
Interrompre
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="aide">
|
||||
Vous pourrez reprendre plus tard : ce qui est déjà enregistré ne sera pas refait.
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h2 class="titre-section">Compte rendu</h2>
|
||||
|
||||
<ul class="bilan">
|
||||
<li><strong>@_rafale.Crees</strong> livre@(_rafale.Crees > 1 ? "s" : "") ajouté@(_rafale.Crees > 1 ? "s" : "")</li>
|
||||
@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.
|
||||
*@
|
||||
<li><strong>@_rafale.Passes</strong> déjà au catalogue, passé@(_rafale.Passes > 1 ? "s" : "")</li>
|
||||
}
|
||||
@if (_rafale.Residus.Count > 0)
|
||||
{
|
||||
<li><strong>@_rafale.Residus.Count</strong> à regarder ci-dessous</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@*
|
||||
⚠️ 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)
|
||||
{
|
||||
<h2 class="titre-section">Livres ajoutés</h2>
|
||||
|
||||
<ul class="liste-residus">
|
||||
@foreach (var ligne in Ajoutes)
|
||||
{
|
||||
<li class="carte-residu" @key="ligne.Code">
|
||||
<div class="residu-tete">
|
||||
<a class="carte-titre" href="@($"livres/{ligne.LivreId}")">
|
||||
@(string.IsNullOrWhiteSpace(ligne.Titre) ? "Fiche du livre" : ligne.Titre)
|
||||
</a>
|
||||
<span class="code-isbn">@FormatageIsbn.Afficher(ligne.Code)</span>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(ligne.Motif))
|
||||
{
|
||||
<p class="aide">@ligne.Motif</p>
|
||||
}
|
||||
|
||||
<div class="actions-tome">
|
||||
<a class="bouton bouton-compact" href="@($"livres/{ligne.LivreId}/edition")">
|
||||
Compléter la fiche
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (_rafale.ResteATraiter)
|
||||
{
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(!Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="TraiterAsync">
|
||||
Continuer (@(_rafale.Total - _rafale.Traites) restant@(_rafale.Total - _rafale.Traites > 1 ? "s" : ""))
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_rafale.Residus.Count > 0)
|
||||
{
|
||||
<h2 class="titre-section">À reprendre</h2>
|
||||
|
||||
<ul class="liste-residus">
|
||||
@foreach (var ligne in _rafale.Residus)
|
||||
{
|
||||
<li class="carte-residu" @key="ligne.Code">
|
||||
<div class="residu-tete">
|
||||
<span class="code-isbn">@FormatageIsbn.Afficher(ligne.Code)</span>
|
||||
<span class="etiquette @ClasseEtat(ligne.Etat)">@Libelle(ligne.Etat)</span>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(ligne.Titre))
|
||||
{
|
||||
<p class="carte-titre">@ligne.Titre</p>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(ligne.Motif))
|
||||
{
|
||||
<p class="aide">@ligne.Motif</p>
|
||||
}
|
||||
|
||||
<div class="actions-tome">
|
||||
@*
|
||||
Chaque résidu renvoie vers l'écran qui sait le traiter, plutôt que
|
||||
de réimplémenter ici le choix d'édition ou la fiche revue.
|
||||
*@
|
||||
<a class="bouton bouton-compact" href="@LienDeReprise(ligne)">
|
||||
@(ligne.Etat == EtatCodeRafale.Revue ? "Ouvrir la revue" : "Reprendre")
|
||||
</a>
|
||||
|
||||
<button type="button" class="bouton bouton-compact"
|
||||
@onclick="() => RetirerAsync(ligne)">
|
||||
Retirer de la liste
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<p class="aide">
|
||||
Cette liste reste consultable en revenant sur cet écran, tant que vous ne l'avez pas
|
||||
vidée.
|
||||
</p>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
@* Venu d'une saga, on y retourne : c'est là qu'on voit ce qui manque encore. *@
|
||||
@if (SerieVisee is { } retour)
|
||||
{
|
||||
<a class="bouton bouton-principal" href="@($"series/{retour.Id}")">
|
||||
Revenir à @retour.Titre
|
||||
</a>
|
||||
}
|
||||
|
||||
<button type="button" class="bouton bouton-danger" @onclick="AbandonnerAsync">
|
||||
Terminer et vider la liste
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private enum Phase { Collecte, Traitement, Bilan }
|
||||
|
||||
/// <summary>
|
||||
/// Série d'où l'on vient, quand la rafale a été lancée depuis une saga.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Ce paramètre ne sert qu'à <b>amorcer</b> la file : une fois lancée, c'est
|
||||
/// <c>Rafale.SerieId</c> qui commande, sans quoi une reprise ouverte depuis une autre
|
||||
/// adresse rattacherait ailleurs.
|
||||
/// </remarks>
|
||||
[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;
|
||||
|
||||
/// <summary>
|
||||
/// ISBN déjà au catalogue, sous leurs DEUX formes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ 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.
|
||||
/// </remarks>
|
||||
private HashSet<string> _isbnDuCatalogue = new(StringComparer.Ordinal);
|
||||
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
/// <summary>La série à laquelle rattacher : celle de la file en cours, sinon celle de l'adresse.</summary>
|
||||
private SerieDto? SerieVisee => _serie;
|
||||
|
||||
private int? CibleSerie => _rafale.SerieId ?? SerieId;
|
||||
|
||||
/// <summary>
|
||||
/// Nom de la série visée, pour le dire plutôt que de rattacher en silence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Zone de collecte, focalisée à l'ouverture pour qu'une douchette y tape d'emblée.</summary>
|
||||
private ElementReference _zoneCodes;
|
||||
|
||||
private bool _focusAFaire = true;
|
||||
|
||||
/// <summary>Les livres réellement créés par cette rafale, dans l'ordre où ils ont été scannés.</summary>
|
||||
private IReadOnlyList<CodeRafale> Ajoutes =>
|
||||
[.. _rafale.Codes.Where(c => c.Etat == EtatCodeRafale.Cree && c.LivreId is not null)];
|
||||
|
||||
/// <remarks>
|
||||
/// ⚠️ 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.
|
||||
/// </remarks>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Traite la file, un code à la fois.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Un par un, délibérément.</b> 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 <c>catalogue.bnf.fr</c>. Séquentiel, la
|
||||
/// progression se voit et une coupure ne perd que le code en cours.
|
||||
/// <para>
|
||||
/// La file est réécrite <b>après chaque code</b> : c'est ce qui rend la reprise exacte.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Les ISBN déjà possédés, dans les deux formes, pour reconnaître un doublon sans requête.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Les deux formes sont indispensables</b> : 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.
|
||||
/// </remarks>
|
||||
private async Task ChargerIsbnDuCatalogueAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var livres = await Api.ListerAsync(new CritereLivres());
|
||||
_isbnDuCatalogue = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var isbn in livres.Select(l => IsbnUtils.Normaliser(l.Isbn)).OfType<string>())
|
||||
{
|
||||
_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<CodeRafale> 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.",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Range le livre créé dans la série visée, s'il y en a une.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Un rattachement raté ne remet pas la ligne en échec : <b>le livre existe</b>, 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.
|
||||
/// </remarks>
|
||||
private async Task<string?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Où reprendre un résidu — l'écran qui sait déjà traiter ce cas.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ 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.
|
||||
/// </remarks>
|
||||
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",
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@page "/"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@inject NavigationManager Navigation
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — catalogue</PageTitle>
|
||||
@@ -11,14 +12,44 @@
|
||||
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.
|
||||
*@
|
||||
<div class="entete-page">
|
||||
<h1 class="titre-page">Catalogue</h1>
|
||||
|
||||
<button type="button" class="bouton bouton-discret bouton-compact"
|
||||
aria-expanded="@Aria.Etat(_filtresOuverts)" aria-controls="filtres-catalogue"
|
||||
@onclick="() => _filtresOuverts = !_filtresOuverts">
|
||||
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
|
||||
</button>
|
||||
<div class="entete-page-actions">
|
||||
<button type="button" class="bouton bouton-discret bouton-compact"
|
||||
aria-expanded="@Aria.Etat(_filtresOuverts)" aria-controls="filtres-catalogue"
|
||||
@onclick="() => _filtresOuverts = !_filtresOuverts">
|
||||
Filtrer@(FiltresActifs > 0 ? $" ({FiltresActifs})" : "")
|
||||
</button>
|
||||
|
||||
@if (_grouper && Entrees.Any(e => e.EstGroupe))
|
||||
{
|
||||
<button type="button" class="bouton bouton-discret bouton-compact"
|
||||
@onclick="() => { if (AuMoinsUneSerieOuverte) { ToutReplier(); } else { ToutDeplier(); } }">
|
||||
@(AuMoinsUneSerieOuverte ? "Replier les séries" : "Déplier les séries")
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<a class="bouton bouton-principal bouton-compact" href="ajout">Ajouter un ouvrage</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-principal bouton-compact" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Ajouter un ouvrage</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_auteur is not null)
|
||||
@@ -30,11 +61,28 @@
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="barre-recherche">
|
||||
<input class="champ-saisie" type="search" placeholder="Titre ou auteur…"
|
||||
@*
|
||||
La recherche accepte aussi un CODE : douchette, caméra, ou code recopié à la main. C'est le
|
||||
geste « j'ai le livre en main, qu'est-ce que j'en sais déjà ? », l'inverse exact de l'ajout.
|
||||
|
||||
⚠️ Le scan reste offert HORS-LIGNE, contrairement à l'ajout : le décodage se fait dans le
|
||||
navigateur, et la recherche retombe sur l'instantané. Rien là-dedans n'a besoin du réseau.
|
||||
*@
|
||||
<div class="barre-recherche ligne-creation">
|
||||
<input class="champ-saisie" type="search" placeholder="Titre, auteur ou ISBN…"
|
||||
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" />
|
||||
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
@onclick="() => _scanOuvert = !_scanOuvert">
|
||||
@(_scanOuvert ? "Fermer" : "Scanner")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_scanOuvert)
|
||||
{
|
||||
<ScannerCodeBarres OnCodeDetecte="SurCodeScanneAsync" OnAnnuler="() => _scanOuvert = false" />
|
||||
}
|
||||
|
||||
@if (_filtresOuverts)
|
||||
{
|
||||
<div class="filtres" id="filtres-catalogue">
|
||||
@@ -110,6 +158,32 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@*
|
||||
« 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)
|
||||
{
|
||||
<div class="segments" role="group" aria-label="Filtrer par couverture">
|
||||
<button type="button" class="segment @(_sansCouverture is null ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerCouverture(null)">Tous</button>
|
||||
<button type="button" class="segment @(_sansCouverture == false ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerCouverture(false)">Avec couverture</button>
|
||||
<button type="button" class="segment @(_sansCouverture == true ? "segment-actif" : null)"
|
||||
@onclick="() => FiltrerCouverture(true)">Sans couverture</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@*
|
||||
⚠️ 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
|
||||
@@ -162,13 +236,18 @@ else if (_livres is not null)
|
||||
tomes occupait tout l'écran alors qu'on cherchait un livre rangé après lui. Replier ne
|
||||
retire rien de la liste — le compteur du haut ne bouge pas, et le bloc replié dit
|
||||
toujours ce qu'il contient.
|
||||
|
||||
⚠️ Ici, contrairement à l'arbre des séries, tout est REPLIÉ par défaut (2026-09-10) :
|
||||
le catalogue liste des livres, pas des séries — une série n'y est qu'un regroupement
|
||||
qu'on ouvre au besoin. Le bouton qui bascule tout l'arbre est dans l'en-tête, à côté
|
||||
de « Filtrer », pas ici : c'est un réglage d'affichage, pas une action sur la liste.
|
||||
*@
|
||||
<ul class="liste-livres">
|
||||
@foreach (var entree in Entrees)
|
||||
{
|
||||
@if (entree.EstGroupe)
|
||||
{
|
||||
<GroupeCatalogue Entree="entree" Replies="_replies" />
|
||||
<GroupeCatalogue Entree="entree" Ouverts="_ouverts" Totaux="_totauxSeries" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -180,29 +259,6 @@ else if (_livres is not null)
|
||||
</ul>
|
||||
}
|
||||
|
||||
@*
|
||||
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.
|
||||
*@
|
||||
<nav class="actions-flottantes">
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<a class="bouton bouton-principal" href="ajout">Ajouter un ouvrage</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-principal" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Ajouter un ouvrage</button>
|
||||
}
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
/// <summary>Restreint le catalogue à un auteur. Vient de « ?auteur=… », posé par les liens de la liste.</summary>
|
||||
[SupplyParameterFromQuery(Name = "auteur")]
|
||||
@@ -226,15 +282,55 @@ else if (_livres is not null)
|
||||
private bool _grouper = true;
|
||||
|
||||
/// <summary>
|
||||
/// Blocs repliés à la main, par identifiant de série. Tout est déplié par défaut.
|
||||
/// Blocs ouverts à la main, par identifiant de série. Tout est replié par défaut.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Le jeu appartient à l'ÉCRAN, et non aux blocs : le catalogue se relit à chaque frappe
|
||||
/// dans la recherche, et un état porté par les composants se perdrait à chaque caractère.
|
||||
/// Il n'est pas persistant d'une visite à l'autre pour autant — c'est un confort de
|
||||
/// lecture, pas une propriété de la série, même règle que l'arbre des séries.
|
||||
/// lecture, pas une propriété de la série.
|
||||
/// </remarks>
|
||||
private readonly HashSet<int> _replies = [];
|
||||
private readonly HashSet<int> _ouverts = [];
|
||||
|
||||
private bool AuMoinsUneSerieOuverte => _ouverts.Count > 0;
|
||||
|
||||
private void ToutReplier() => _ouverts.Clear();
|
||||
|
||||
private void ToutDeplier()
|
||||
{
|
||||
foreach (var id in IdentifiantsSeries(Entrees))
|
||||
{
|
||||
_ouverts.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<int> IdentifiantsSeries(IEnumerable<EntreeCatalogue> entrees)
|
||||
{
|
||||
foreach (var entree in entrees)
|
||||
{
|
||||
if (!entree.EstGroupe)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return entree.Serie!.Id;
|
||||
|
||||
foreach (var id in IdentifiantsSeries(entree.SousEntrees))
|
||||
{
|
||||
yield return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ce que chaque série possède et recense, descendance comprise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Recalculé <b>seulement</b> quand les séries changent, et non à chaque chargement du
|
||||
/// catalogue : celui-ci se relit à chaque frappe dans la recherche.
|
||||
/// </remarks>
|
||||
private IReadOnlyDictionary<int, (int Possedes, int Total)> _totauxSeries =
|
||||
new Dictionary<int, (int, int)>();
|
||||
|
||||
/// <summary>Le catalogue tel qu'il s'affiche : livres seuls et blocs de série mêlés.</summary>
|
||||
private IReadOnlyList<EntreeCatalogue> Entrees =>
|
||||
@@ -246,6 +342,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 +375,19 @@ else if (_livres is not null)
|
||||
|
||||
private bool QuelqueChoseEstPrete => _fondsAvecPret is not false;
|
||||
|
||||
/// <summary>
|
||||
/// Vrai si le fonds contient à la fois des livres avec et sans couverture — ou qu'on ne le
|
||||
/// sait pas encore.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ 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.
|
||||
/// </remarks>
|
||||
private bool? _fondsCouverturesInegales;
|
||||
|
||||
private bool CouverturesInegales => _fondsCouverturesInegales is not false;
|
||||
|
||||
/// <summary>
|
||||
/// Nombre de filtres repliés actuellement actifs, affiché sur le bouton « Filtrer ».
|
||||
/// </summary>
|
||||
@@ -287,14 +398,15 @@ else if (_livres is not null)
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// La liste se relit à chaque bascule du réseau : au retour, pour reprendre les données du
|
||||
@@ -315,10 +427,12 @@ else if (_livres is not null)
|
||||
try
|
||||
{
|
||||
_series = await Api.ListerSeriesAsync();
|
||||
_totauxSeries = TotauxSeries.Calculer(_series);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_series = null;
|
||||
_totauxSeries = new Dictionary<int, (int, int)>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +465,48 @@ else if (_livres is not null)
|
||||
await ChargerAsync();
|
||||
}
|
||||
|
||||
/// <summary>Le scanner est ouvert au-dessus de la liste, pour retrouver un livre en main.</summary>
|
||||
private bool _scanOuvert;
|
||||
|
||||
/// <summary>
|
||||
/// Un code lu remplit la recherche, exactement comme une douchette l'aurait tapé.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private async Task SurCodeScanneAsync(CodeScanne lecture)
|
||||
{
|
||||
_scanOuvert = false;
|
||||
_recherche = lecture.Code;
|
||||
await ChargerAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre la fiche quand le code désigne <b>un seul</b> livre du catalogue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// ⚠️ Deux conditions, et les deux comptent. <b>Un seul résultat</b>, sinon on choisirait à
|
||||
/// la place de l'utilisateur — deux exemplaires du même ISBN sont légitimes. Et un
|
||||
/// <b>code complet et valide</b> : sur un fragment en cours de frappe, le résultat unique
|
||||
/// est un hasard, et l'écran sauterait sous les doigts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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 +531,12 @@ else if (_livres is not null)
|
||||
return ChargerAsync();
|
||||
}
|
||||
|
||||
private Task FiltrerCouverture(bool? sansCouverture)
|
||||
{
|
||||
_sansCouverture = sansCouverture;
|
||||
return ChargerAsync();
|
||||
}
|
||||
|
||||
/// <summary>Vrai quand la liste demandée est le fonds entier, sans restriction d'aucune sorte.</summary>
|
||||
private static bool EstSansCritere(CritereLivres criteres) =>
|
||||
string.IsNullOrWhiteSpace(criteres.Recherche)
|
||||
@@ -382,6 +544,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 +566,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)
|
||||
|
||||
@@ -41,6 +41,16 @@ else if (_saisie is null)
|
||||
else
|
||||
{
|
||||
<div class="formulaire">
|
||||
@* Même ligne que dans le formulaire d'un ouvrage (FormulaireLivre) : un aperçu quand il y
|
||||
a quelque chose à montrer, puis un champ d'adresse ordinaire, au même titre que les
|
||||
autres. *@
|
||||
@if (!string.IsNullOrEmpty(_saisie.CoverUrl) || !string.IsNullOrWhiteSpace(_saisie.Titre))
|
||||
{
|
||||
<div class="formulaire-apercu">
|
||||
<Couverture Url="@_saisie.CoverUrl" Titre="@_saisie.Titre" Classe="couverture-moyenne" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Titre <span class="obligatoire" aria-hidden="true">*</span></span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Titre" @bind:event="oninput"
|
||||
@@ -73,6 +83,12 @@ else
|
||||
@bind="_saisie.Isbn" @bind:event="oninput" placeholder="978…" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">URL de couverture</span>
|
||||
<input class="champ-saisie" type="url" @bind="_saisie.CoverUrl" @bind:event="oninput"
|
||||
placeholder="https://…" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Note</span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput"
|
||||
@@ -104,6 +120,13 @@ else
|
||||
private bool _enCours;
|
||||
private string? _erreur;
|
||||
|
||||
/// <summary>
|
||||
/// La couverture telle que chargée — éventuellement déduite de l'ISBN (voir <c>Projeter</c>
|
||||
/// côté serveur), pas forcément ce qu'il y a réellement en base. Sert uniquement à détecter,
|
||||
/// à l'enregistrement, si l'utilisateur a touché au champ.
|
||||
/// </summary>
|
||||
private string? _couvertureChargee;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Reseau.Change += SurChangementReseau;
|
||||
@@ -135,9 +158,8 @@ else
|
||||
return;
|
||||
}
|
||||
|
||||
// ⚠️ La couverture n'est PAS reprise ici : elle n'a pas de champ de saisie, et le
|
||||
// service la conserve donc telle qu'elle est en base. La valeur portée par le DTO peut
|
||||
// en outre être déduite de l'ISBN — la figer serait écrire en base ce qui se calcule.
|
||||
_couvertureChargee = souhait.CoverUrl;
|
||||
|
||||
_saisie = new EnregistrementSouhait
|
||||
{
|
||||
Titre = souhait.Titre,
|
||||
@@ -145,6 +167,7 @@ else
|
||||
Editeur = souhait.Editeur,
|
||||
Annee = souhait.Annee,
|
||||
Isbn = souhait.Isbn,
|
||||
CoverUrl = souhait.CoverUrl,
|
||||
Note = souhait.Note,
|
||||
};
|
||||
}
|
||||
@@ -156,6 +179,16 @@ else
|
||||
return;
|
||||
}
|
||||
|
||||
// ⚠️ Envoyée seulement si elle a changé depuis le chargement, et non à chaque
|
||||
// enregistrement : la valeur affichée peut être une DÉDUCTION à la lecture (formule
|
||||
// OpenLibrary à ISBN connu, voir `Projeter`) plutôt que ce qu'il y a réellement en base.
|
||||
// La renvoyer telle quelle à chaque correction de titre la figerait en base, et une
|
||||
// correction d'ISBN qui suit ne la referait plus jouer.
|
||||
_saisie.CoverUrlModifiee = !string.Equals(
|
||||
_saisie.CoverUrl?.Trim() ?? string.Empty,
|
||||
_couvertureChargee?.Trim() ?? string.Empty,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
|
||||
|
||||
@@ -91,8 +91,17 @@ else
|
||||
}
|
||||
|
||||
<div class="fiche-entete">
|
||||
@*
|
||||
⚠️ 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.
|
||||
*@
|
||||
<Couverture Url="@_livre.CoverUrl" Titre="@_livre.Titre"
|
||||
Classe="couverture-grande" Agrandissable="true" />
|
||||
Classe="couverture-grande" Agrandissable="true"
|
||||
OnCorriger="OuvrirCorrectionCouverture" />
|
||||
|
||||
<div class="fiche-infos">
|
||||
<h2 class="fiche-titre">@_livre.Titre</h2>
|
||||
@@ -181,6 +190,32 @@ else
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_correctionCouverture)
|
||||
{
|
||||
<div class="barre-recherche ligne-creation">
|
||||
<input class="champ-saisie" type="url" @ref="_champUrlCouverture"
|
||||
@bind="_urlCouverture" @bind:event="oninput"
|
||||
@onkeydown="SurToucheCouverture"
|
||||
placeholder="https://…" aria-label="Adresse de la couverture"
|
||||
autocomplete="off" />
|
||||
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enregistrement || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="EnregistrerCouvertureAsync">
|
||||
@(_enregistrement ? "Enregistrement…" : "Enregistrer")
|
||||
</button>
|
||||
|
||||
<button type="button" class="bouton bouton-discret" disabled="@_enregistrement"
|
||||
@onclick="() => _correctionCouverture = false">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="message-discret">
|
||||
Collez l'adresse d'une image, puis Entrée. Vider le champ retire la couverture.
|
||||
</p>
|
||||
}
|
||||
|
||||
@* 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. *@
|
||||
<section class="bloc-statut">
|
||||
@@ -368,6 +403,83 @@ else
|
||||
/// </summary>
|
||||
private async Task RechargerAsync() => _livre = await Api.ObtenirAsync(Id) ?? _livre;
|
||||
|
||||
/// <summary>Champ d'adresse de la couverture, ouvert depuis l'image cassée elle-même.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enregistre la seule adresse de couverture, sans passer par l'écran d'édition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
@@ -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 ».
|
||||
*@
|
||||
<Couverture Url="@numero.CoverUrl" Titre="@($"N° {numero.Numero}")"
|
||||
Classe="couverture-petite" Agrandissable="true" />
|
||||
Classe="couverture-petite" Agrandissable="true"
|
||||
OnCorriger="() => OuvrirCorrectionCouverture(numero)" />
|
||||
|
||||
<div class="carte-corps">
|
||||
<p class="carte-titre">N° @numero.Numero</p>
|
||||
|
||||
@if (_correctionCouverture == numero.Id)
|
||||
{
|
||||
<div class="barre-recherche ligne-creation">
|
||||
<input class="champ-saisie" type="url" @ref="_champUrlCouverture"
|
||||
@bind="_urlCouverture" @bind:event="oninput"
|
||||
@onkeydown="@(e => SurToucheCouverture(e, numero))"
|
||||
placeholder="https://…"
|
||||
aria-label="@($"Adresse de la couverture du numéro {numero.Numero}")"
|
||||
autocomplete="off" />
|
||||
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="() => EnregistrerCouvertureAsync(numero)">
|
||||
@(_enCours ? "Enregistrement…" : "Enregistrer")
|
||||
</button>
|
||||
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
disabled="@_enCours"
|
||||
@onclick="() => _correctionCouverture = null">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="message-discret">
|
||||
Collez l'adresse d'une image, puis Entrée. Vider le champ
|
||||
retire la couverture.
|
||||
</p>
|
||||
}
|
||||
|
||||
<p class="carte-details">
|
||||
@if (numero.DateParution is { } parution)
|
||||
{
|
||||
@@ -300,6 +338,15 @@ else
|
||||
/// <summary>Numéro dont le retrait attend confirmation — <c>null</c> = aucun.</summary>
|
||||
private int? _confirmeRetrait;
|
||||
|
||||
/// <summary>Numéro dont l'adresse de couverture est ouverte — <c>null</c> = aucun.</summary>
|
||||
private int? _correctionCouverture;
|
||||
|
||||
private string _urlCouverture = string.Empty;
|
||||
|
||||
private ElementReference _champUrlCouverture;
|
||||
|
||||
private bool _focusCouverture;
|
||||
|
||||
/// <summary>
|
||||
/// É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;
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre l'adresse de la couverture d'un numéro, depuis l'image elle-même.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Le focus est demandé <b>une seule fois</b>, au prochain rendu : le reprendre à chaque
|
||||
/// rendu l'arracherait pendant qu'on colle ou qu'on tape.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// N'enregistre que l'adresse de la couverture, sans passer par l'écran d'édition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Le point d'entrée REMPLACE le numéro entier</b>, 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 <c>OuvrirEdition</c>, et
|
||||
/// c'est le genre d'oubli qu'aucun message d'erreur ne signalerait.
|
||||
/// </remarks>
|
||||
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();
|
||||
|
||||
+735
-115
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
/// <summary>Range la rafale en cours. Appelée à chaque scan : perdre la file serait cuisant.</summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Relit la rafale interrompue, s'il y en a une.</summary>
|
||||
public async Task<Rafale?> LireRafaleAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var module = await ModuleAsync();
|
||||
var brut = await module.InvokeAsync<string?>("rafaleLire");
|
||||
return brut is null ? null : JsonSerializer.Deserialize<Rafale>(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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>État déclaré par le navigateur. Voir la nuance dans <c>cache-hors-ligne.js</c>.</summary>
|
||||
public async Task<bool> EnLigneAsync()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Même ordre que le serveur : titre normalisé, puis identifiant pour rendre le tri
|
||||
/// déterministe.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// La comparaison est <b>ordinale</b> parce que c'est ce que fait SQLite sur une colonne sans
|
||||
/// collation — un tri « intelligent » ici ferait diverger les deux vues.
|
||||
/// </remarks>
|
||||
private static IReadOnlyList<LivreDto> Ordonner(IEnumerable<LivreDto> livres) =>
|
||||
livres
|
||||
.OrderBy(l => NormalisationTexte.Normaliser(l.Titre), StringComparer.Ordinal)
|
||||
.ThenBy(l => l.Id)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<List<LivreDto>>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Efface un prêt saisi par erreur.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ À ne pas confondre avec <see cref="RendreAsync"/>, qui <b>conserve</b> la ligne en lui
|
||||
/// posant une date de retour. Celle-ci défait une saisie fautive, et rien d'autre.
|
||||
/// </remarks>
|
||||
public Task<string?> SupprimerPretAsync(int pretId, CancellationToken ct = default) =>
|
||||
SupprimerAsync($"api/prets/{pretId}", ct);
|
||||
|
||||
/// <summary>Clôt un prêt. Le prêt n'est pas supprimé : il devient l'historique du livre.</summary>
|
||||
public async Task<ResultatApi<PretDto>> 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);
|
||||
|
||||
/// <summary>Rattache un livre possédé à une place, ou l'en détache avec <c>null</c>.</summary>
|
||||
/// <summary>
|
||||
/// Corrige le titre et le numéro d'une place, sans toucher au livre rattaché.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Distinct de <see cref="RattacherLivreSerieAsync"/> : un point d'entrée, un geste.
|
||||
/// </remarks>
|
||||
public async Task<ResultatApi<SerieDto>> ModifierElementSerieAsync(
|
||||
int elementId, AjoutElementSerie saisie, CancellationToken ct = default) =>
|
||||
await EcrireAsync<SerieDto>(
|
||||
() => http.PutAsJsonAsync($"api/series/elements/{elementId}", saisie, Json, ct), ct);
|
||||
|
||||
public async Task<ResultatApi<SerieDto>> RattacherLivreSerieAsync(
|
||||
int elementId, int? livreId, CancellationToken ct = default) =>
|
||||
await EcrireAsync<SerieDto>(
|
||||
|
||||
@@ -164,6 +164,11 @@ body {
|
||||
|
||||
.entete-fiche-actions {
|
||||
flex: 0 0 auto;
|
||||
/* Sans elle, un flex-basis « auto » prend la largeur de TOUS les boutons sur une seule
|
||||
ligne (leur taille de contenu), et le flex-wrap ci-dessous ne peut jamais s'en servir :
|
||||
la ligne déborde au lieu de se replier. Quatre boutons (dont « Modifier en lot ») y
|
||||
tiennent tout juste à 1280px, mais débordent à 375px sans ce plafond. */
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
@@ -251,7 +256,15 @@ body {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* ⚠️ Écrit pour des <button> À 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 +276,11 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.segment:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.segment-actif {
|
||||
background: var(--mb-accent);
|
||||
border-color: var(--mb-accent);
|
||||
@@ -447,7 +465,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 +652,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 +756,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 +1236,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 +1290,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 {
|
||||
@@ -1572,6 +1669,20 @@ body {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/*
|
||||
Le compte rendu de la dernière rafale (lot AB) : une entrée transitoire, présente
|
||||
seulement tant qu'une file non vidée existe. Le même jaune que la pastille hors-ligne
|
||||
la distingue des six destinations permanentes — c'est une chose à finir, pas un lieu.
|
||||
*/
|
||||
.menu-lien-rafale {
|
||||
border-left-color: #f5d76e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-lien-rafale.active {
|
||||
border-left-color: #f5d76e;
|
||||
}
|
||||
|
||||
/*
|
||||
À partir de 40rem la rangée tient sans se comprimer, et la bascule n'a plus
|
||||
de raison d'être. Le point de rupture est répété dans `MainLayout.razor.css`
|
||||
@@ -1613,6 +1724,10 @@ body {
|
||||
border-bottom-color: #f5d76e;
|
||||
}
|
||||
|
||||
.menu-lien-rafale {
|
||||
border-bottom-color: #f5d76e;
|
||||
}
|
||||
|
||||
/* En rangée, un filet au-dessus ne séparerait rien : le trait passe à GAUCHE, et la marge
|
||||
supérieure disparaît sous peine de désaligner l'entrée du reste de la rangée. */
|
||||
.menu-lien-annexe {
|
||||
@@ -1737,3 +1852,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;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,12 @@ const MAGASIN = 'instantanes';
|
||||
// valeur = le Blob de l'image. Sert uniquement à la consultation hors-ligne — en ligne,
|
||||
// l'<img> 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);
|
||||
}
|
||||
|
||||
@@ -62,4 +62,26 @@ public record CritereLivres
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool? Prete { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> = seulement les livres sans couverture, <c>false</c> = seulement ceux qui en
|
||||
/// ont une, <c>null</c> = tous.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Répond à « quels livres me reste-t-il à illustrer ? ». Comme <see cref="Prete"/>, le
|
||||
/// critère est <b>commun au foyer</b> : une couverture décrit l'ouvrage, pas la relation
|
||||
/// d'un lecteur à lui.
|
||||
/// <para>
|
||||
/// ⚠️ <b>« Sans couverture » veut dire « sans URL enregistrée », et rien d'autre.</b> Ce
|
||||
/// critère ne dit pas si l'image se charge : le savoir demanderait d'interroger chaque URL,
|
||||
/// ce que <c>CLAUDE.md</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Une URL <b>vide</b> compte comme absente : les deux implémentations du filtre doivent
|
||||
/// s'accorder là-dessus, un test les y confronte.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool? SansCouverture { get; init; }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
namespace MaBibli.Shared.Catalogue;
|
||||
|
||||
/// <summary>
|
||||
/// Où en est un code scanné dans une rafale.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>C'est l'existence de ces états qui rend la reprise possible</b>, et la reprise est ce
|
||||
/// qui distingue une rafale d'une simple liste de codes. Sans <see cref="Cree"/>, 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 <b>par accident, pas par construction</b>.
|
||||
/// </remarks>
|
||||
public enum EtatCodeRafale
|
||||
{
|
||||
/// <summary>Scanné, pas encore interrogé.</summary>
|
||||
ATraiter = 0,
|
||||
|
||||
/// <summary>Livre enregistré. Ne jamais le refaire à la reprise.</summary>
|
||||
Cree,
|
||||
|
||||
/// <summary>Déjà au catalogue, écarté par la case à cocher.</summary>
|
||||
Passe,
|
||||
|
||||
/// <summary>Plusieurs notices : l'utilisateur doit choisir l'édition.</summary>
|
||||
AChoisir,
|
||||
|
||||
/// <summary>Aucune source ne connaît ce code. À saisir à la main.</summary>
|
||||
Introuvable,
|
||||
|
||||
/// <summary>Code de périodique (préfixe 977) : mis de côté, traité à la fin.</summary>
|
||||
Revue,
|
||||
|
||||
/// <summary>La source n'a pas répondu. ⚠️ À distinguer d'<see cref="Introuvable"/>.</summary>
|
||||
Echec,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Un code de la file, et ce qu'on en sait.
|
||||
/// </summary>
|
||||
public record CodeRafale
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
|
||||
public EtatCodeRafale Etat { get; init; } = EtatCodeRafale.ATraiter;
|
||||
|
||||
/// <summary>Titre trouvé, ou saisi. Sert à reconnaître la ligne dans le compte rendu.</summary>
|
||||
public string? Titre { get; init; }
|
||||
|
||||
/// <summary>Renseigné quand l'état est <see cref="EtatCodeRafale.Cree"/>.</summary>
|
||||
public int? LivreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ce qui s'est passé, écrit pour un humain.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ 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.
|
||||
/// </remarks>
|
||||
public string? Motif { get; init; }
|
||||
|
||||
/// <summary>Reste-t-il quelque chose à faire sur cette ligne ?</summary>
|
||||
public bool Termine => Etat is EtatCodeRafale.Cree or EtatCodeRafale.Passe;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Une rafale de catalogage : les codes scannés, et où en est chacun.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Persistée dans IndexedDB, magasin <c>rafale</c></b> — 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.
|
||||
/// <para>
|
||||
/// ⚠️ <b>Ce ne sont PAS des écritures en attente</b>, et la nuance est ce qui la rend acceptable
|
||||
/// dans un projet qui refuse toute file de synchronisation : ce sont des <b>codes à
|
||||
/// interroger</b>. 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public record Rafale
|
||||
{
|
||||
public List<CodeRafale> Codes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Passer les livres déjà au catalogue plutôt que de demander à chaque fois.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Coché par défaut : quand on catalogue cinquante livres, on ne veut pas être arrêté
|
||||
/// cinquante fois. ⚠️ Mais le compte rendu doit <b>dire combien ont été passés</b> — 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.
|
||||
/// </remarks>
|
||||
public bool PasserLesDoublons { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Série à laquelle rattacher chaque livre créé, quand la rafale a été lancée depuis une saga.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Elle vit <b>dans la file</b> 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
|
||||
/// <summary>Ce qui attend encore une décision de l'utilisateur.</summary>
|
||||
public IReadOnlyList<CodeRafale> 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);
|
||||
|
||||
/// <summary>Pourcentage traité, pour l'indicateur d'avancement.</summary>
|
||||
public int Pourcentage => Total == 0 ? 0 : Traites * 100 / Total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Découpe une saisie en codes, dans l'ordre, sans doublon.
|
||||
/// </summary>
|
||||
public static class SaisieRafale
|
||||
{
|
||||
/// <summary>
|
||||
/// Un code par ligne — c'est ce que produit une douchette, qui envoie <c>Entrée</c> après
|
||||
/// chaque lecture. Les espaces et tirets sont tolérés, la casse aussi.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Les doublons de la saisie sont écartés ici</b>, 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 <i>catalogue</i>,
|
||||
/// lui, est une tout autre question, traitée par <c>PasserLesDoublons</c>.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<string> Decouper(string? saisie)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(saisie))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var vus = new HashSet<string>(StringComparer.Ordinal);
|
||||
var codes = new List<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,9 @@ public record ElementSerieDto
|
||||
/// <summary>Titre du tome : celui du livre quand il est rattaché, sinon celui qui a été saisi.</summary>
|
||||
public required string Titre { get; init; }
|
||||
|
||||
/// <summary>Le numéro imprimé sur le livre. <c>null</c> = inconnu.</summary>
|
||||
public string? Numero { get; init; }
|
||||
|
||||
public int? LivreId { get; init; }
|
||||
|
||||
public bool Possede => LivreId is not null;
|
||||
@@ -76,6 +79,9 @@ public record EnregistrementSerie
|
||||
/// </remarks>
|
||||
public record AjoutElementSerie
|
||||
{
|
||||
/// <summary>Le numéro imprimé sur le livre — facultatif.</summary>
|
||||
public string? Numero { get; set; }
|
||||
|
||||
/// <summary>Livre du catalogue à placer ici. <c>null</c> = un tome qu'on ne possède pas.</summary>
|
||||
public int? LivreId { get; set; }
|
||||
|
||||
|
||||
@@ -65,6 +65,18 @@ public record EnregistrementSouhait
|
||||
|
||||
public string? CoverUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Vrai seulement si l'utilisateur vient d'utiliser le geste de correction de la couverture.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Sans ce signal, <see cref="CoverUrl"/> à <c>null</c> serait ambigu entre « je n'y ai
|
||||
/// pas touché » (garder ce qu'il y a en base, y compris rien du tout, et laisser jouer la
|
||||
/// formule OpenLibrary à ISBN connu) et « je viens de vider le champ » (retirer la
|
||||
/// couverture pour de bon). Modifier le titre ou l'ISBN d'une envie ne doit jamais
|
||||
/// matérialiser en base la couverture déduite qu'elle ne fait qu'afficher à la lecture.
|
||||
/// </remarks>
|
||||
public bool CoverUrlModifiee { get; set; }
|
||||
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -105,5 +105,34 @@ public class ElementSerie
|
||||
/// </summary>
|
||||
public string Titre { get; set; } = string.Empty;
|
||||
|
||||
public void RecalculerFormes() => Titre = Titre.Trim();
|
||||
/// <summary>
|
||||
/// Le numéro imprimé sur le livre — « 7 », « Hors-série », « 3.5 ». <c>null</c> = inconnu.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>À ne pas confondre avec <see cref="Position"/>.</b> La position est le rang dans
|
||||
/// l'ordre de <b>lecture</b> ; le numéro est l'étiquette <b>éditoriale</b>. Les deux
|
||||
/// coïncident tant qu'on possède la série depuis le début, et divergent <b>par nature</b>
|
||||
/// pour une préquelle : dans <i>La Légende de Drizzt</i>, le premier livre à lire porte
|
||||
/// « 4 » sur sa couverture.
|
||||
/// <para>
|
||||
/// ⚠️ C'est aussi pourquoi le numéro ne pouvait pas se loger dans <c>Position</c> :
|
||||
/// <c>PUT /api/series/{id}/ordre</c> renumérote toutes les positions, donc un numéro rangé
|
||||
/// là serait détruit au premier réordonnancement par flèches.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Une chaîne, jamais un entier</b> — même choix que <c>NumeroRevue.Numero</c> : les
|
||||
/// sagas produisent des « hors-série », des « 3.5 » et des « intégrale 1-3 » qu'un
|
||||
/// numérique refuserait.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
namespace MaBibli.Shared.Isbn;
|
||||
|
||||
/// <summary>
|
||||
/// Reconnaît qu'un terme de recherche est un <b>code</b> et non des mots, et donne les formes
|
||||
/// sous lesquelles le chercher dans le catalogue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Les deux formes d'ISBN sont indispensables</b>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Le seuil de huit chiffres n'est pas décoratif.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class RechercheIsbn
|
||||
{
|
||||
/// <summary>En deçà, un terme numérique est un titre bien plus probablement qu'un code.</summary>
|
||||
private const int ChiffresMinimum = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Les formes à confronter à <c>Livre.Isbn</c>, ou <c>null</c> si le terme n'est pas un code.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Un terme n'est un code que s'il ne contient <b>rien d'autre</b> que des chiffres, la
|
||||
/// ponctuation d'un ISBN (tirets, espaces, points) et l'éventuel <c>X</c> final : « Zola 1984 »
|
||||
/// reste une recherche de mots.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<string>? 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];
|
||||
}
|
||||
|
||||
/// <summary>Vrai si le terme est un ISBN complet et valide — donc s'il désigne UNE édition.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
namespace MaBibli.Shared.Textes;
|
||||
|
||||
/// <summary>
|
||||
/// Compare des libellés qui contiennent des nombres, en lisant les nombres comme des nombres.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Le tri ordinaire range les tomes 1, 10, 11, 2, 9</b> — parce que le caractère <c>1</c>
|
||||
/// précède le <c>2</c>. 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.
|
||||
/// <para>
|
||||
/// ⚠️ <b>Aucune collation culturelle</b>, comme partout ailleurs dans le projet
|
||||
/// (<c>GroupementCatalogue</c>, <c>FiltreLivresLocal</c>) : la comparaison des blocs de texte
|
||||
/// est <b>ordinale</b>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ordre retenu, tranché avec l'utilisateur le 2026-08-22 :
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>le libellé <b>vide</b> — ce dont on ignore le numéro ;</item>
|
||||
/// <item>ce qui <b>ne commence pas par un chiffre</b> (« Hors-série », « Intégrale 1-3 »),
|
||||
/// entre eux de a à z ;</item>
|
||||
/// <item>ce qui <b>commence par un chiffre</b>, de 0 à 9 en tri naturel.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// ⚠️ Cet ordre <b>diverge</b> de celui des numéros de revue, où ce qui n'a pas de date
|
||||
/// <i>ferme</i> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class TriNaturel
|
||||
{
|
||||
/// <summary>Comparateur prêt à l'emploi pour <c>OrderBy</c>.</summary>
|
||||
public static IComparer<string?> Comparateur { get; } = new ComparateurNaturel();
|
||||
|
||||
/// <summary>
|
||||
/// Compare deux libellés. <c>null</c> et la chaîne vide sont équivalents et passent en tête.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parcourt les deux libellés en parallèle, bloc de chiffres contre bloc de chiffres,
|
||||
/// bloc de texte contre bloc de texte.
|
||||
/// </summary>
|
||||
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<string?>
|
||||
{
|
||||
public int Compare(string? x, string? y) => Comparer(x, y);
|
||||
}
|
||||
}
|
||||
@@ -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" }));
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using MaBibli.Client.Composants;
|
||||
|
||||
namespace MaBibli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Le compte annoncé par un bloc de série au catalogue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using MaBibli.Shared.Catalogue;
|
||||
|
||||
namespace MaBibli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// La file de catalogage en rafale : découpe de la saisie, et comptes du compte rendu.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using MaBibli.Shared.Isbn;
|
||||
|
||||
namespace MaBibli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Reconnaissance d'un code dans le champ de recherche du catalogue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// L'enjeu n'est pas de trouver un ISBN — c'est de <b>ne pas</b> 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.
|
||||
/// </remarks>
|
||||
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));
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using MaBibli.Shared.Textes;
|
||||
|
||||
namespace MaBibli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Le tri des numéros de tome — celui qui range « 2 » avant « 10 ».
|
||||
/// </summary>
|
||||
public class TriNaturelTests
|
||||
{
|
||||
private static List<string?> 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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|
||||
+2
-2
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user