6 Commits
Author SHA1 Message Date
mathieu e2254e700d Ajout lot dans catalogue 2026-09-10 15:29:53 +02:00
mathieu 47d371fa3a On replie par defaut les série dans le catalogue.
Un bouton permet de déplier/replier toutes les séries d'un coup.
2026-09-10 01:39:17 +02:00
mathieu 61d4fb1678 Ajout d'un moyen de modifier par lot les ouvrages dans les séries. 2026-09-10 01:08:57 +02:00
mathieu c5b4e117ba Ajout de l'url des envies directement dans la modification des ouvrages. 2026-09-09 21:47:03 +02:00
mathieuandClaude Sonnet 5 d76fb10ee0 Un liste d'ouvrage rafale est accessible depuis le menu
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 19:16:33 +02:00
mathieu 42dec0dbd1 - Catalogage rapide (douchette, code-barres)
- Cataloguer en rafale — nouvel écran /ajout/rafale : on scanne une pile de livres à la suite dans une zone de texte, chaque code est traité (BnF puis OpenLibrary), les doublons connus sont passés automatiquement. La collecte marche même hors-ligne. Le compte rendu liste maintenant les livres créés, en lien vers leur fiche, et reste consultable en revenant sur l'écran même après une rafale entièrement réussie. Le catalogue reconnaît un ISBN dans sa barre de recherche (13 ou 10 chiffres, avec ou sans tirets) : scanner un livre en main ouvre directement sa fiche s'il n'y en a qu'un. Un bouton « Scanner » l'alimente, actif hors-ligne.
- Ajouter un tome à une série accepte aussi un ISBN dans le champ manuel : le catalogue est cherché d'abord (rattachement direct si un seul exemplaire), sinon la BnF prend le relais.
Séries et sagas
- Numéro de tome distinct de la position de lecture : on peut indiquer « c'est le tome 7 » même si on ne possède pas les six premiers ; l'ordre de lecture reste un réglage séparé (utile pour les préquelles).
- Tri par numéro en plus du tri par ordre de lecture quand des tomes en portent un.
- Panneau « Ajouter » regroupé et repliable sur la fiche d'une série (manuellement / en rafale / depuis le catalogue / depuis les envies), au lieu de quatre formulaires ouverts en permanence.
- Filtre catalogue « sans couverture » pour repérer les livres à illustrer.
Le catalogue groupe les tomes d'une même série sous un bloc repliable, avec un décompte plus clair (affichés / possédés / total).
- Corrections directes sur la fiche
- Effacer un prêt saisi par erreur (bouton ✕ sur chaque ligne, avec confirmation), sans passer par « rendre ».
- Corriger une couverture manquante ou cassée en cliquant dessus : le champ d'adresse s'ouvre focalisé, Entrée enregistre. Étendu aux numéros de revue dans la dernière modification.
- Les thèmes déjà utilisés dans la bibliothèque sont proposés à la frappe.
- La recherche d'un livre à rattacher montre des suggestions dès le focus, sans attendre de taper.

- Visuel : un rendu manquant après une écriture asynchrone dans le formulaire de livre, une bascule de rôle cassée, des débordements à 320 px, et le style d'un bouton-lien qui restait souligné.
2026-09-09 00:05:26 +02:00
49 changed files with 4994 additions and 221 deletions
@@ -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");
+2
View File
@@ -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;
+14
View File
@@ -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) =>
+15
View File
@@ -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
+56 -4
View File
@@ -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();
}
+71 -19
View File
@@ -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 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,8 @@
<ul class="liste-livres">
@foreach (var sous in Entree.SousEntrees)
{
<GroupeCatalogue Entree="sous" Replies="Replies" />
<GroupeCatalogue Entree="sous" Ouverts="Ouverts" Totaux="Totaux"
LotActif="LotActif" SelectionLot="SelectionLot" OnSelectionner="OnSelectionner" />
}
</ul>
}
@@ -48,9 +54,24 @@
<ul class="liste-livres">
@foreach (var livre in Entree.Livres)
{
<li @key="livre.Id">
<CarteLivre Livre="livre" />
</li>
@if (LotActif)
{
<li class="carte-oeuvre" @key="livre.Id">
<label class="oeuvre-case">
<input type="checkbox"
checked="@(SelectionLot?.Contains(livre.Id) ?? false)"
@onchange="e => OnSelectionner.InvokeAsync((livre.Id, e.Value is true))" />
<span class="lecture-ecran">Sélectionner « @livre.Titre »</span>
</label>
<CarteLivre Livre="livre" />
</li>
}
else
{
<li @key="livre.Id">
<CarteLivre Livre="livre" />
</li>
}
}
</ul>
}
@@ -61,23 +82,54 @@
[Parameter, EditorRequired] public EntreeCatalogue Entree { get; set; } = default!;
/// <summary>
/// Blocs REPLIÉS, par identifiant de série — et non 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; }
/// <summary>Modification en lot du catalogue : voir la remarque équivalente sur Catalogue.razor.</summary>
[Parameter] public bool LotActif { get; set; }
[Parameter] public ISet<int>? SelectionLot { get; set; }
/// <summary>
/// ⚠️ EventCallback, et non un Action brut : coché depuis un tome niché dans ce bloc, l'écran
/// du catalogue (le VRAI propriétaire de la sélection) ne serait sinon jamais prévenu du
/// changement, et sa barre de sélection resterait figée sur l'état d'avant — le second piège
/// Blazor de CLAUDE.md.
/// </summary>
[Parameter] public EventCallback<(int LivreId, bool Coche)> OnSelectionner { 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);
}
}
}
+35
View File
@@ -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}";
}
}
+82 -5
View File
@@ -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>
+49
View File
@@ -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;
}
+29 -4
View File
@@ -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>
+50 -1
View File
@@ -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;
+20 -1
View File
@@ -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);
+741
View File
@@ -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&#10;9782253004226&#10;…"
@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",
};
}
+393 -36
View File
@@ -1,6 +1,7 @@
@page "/"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@inject NavigationManager Navigation
@implements IDisposable
<PageTitle>MaBibli — catalogue</PageTitle>
@@ -11,14 +12,58 @@
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>
}
@*
« Modifier en lot » n'apparaît qu'à partir de DEUX livres — même seuil que sur la fiche
série. Reste possible hors-ligne : l'écriture, elle, est bloquée par livre comme
partout ailleurs (⚠️ MotifBlocage).
*@
@if (Reseau.EnLigne && _livres is { Count: > 1 })
{
<button type="button" class="bouton bouton-discret bouton-compact"
aria-expanded="@Aria.Etat(_lotOuvert)" aria-controls="lot-catalogue"
@onclick="BasculerLot">
Modifier en lot
</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 +75,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 +172,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
@@ -152,6 +240,47 @@ else if (_livres is not null)
{
<p class="compteur">@_livres.Count livre@(_livres.Count > 1 ? "s" : "")</p>
@*
Modification en lot : même patron que la fiche série (barre collante, formulaire affiché
sous elle) — sauf que la sélection porte ici sur le catalogue ENTIER, groupé ou non, d'où
le bouton « Tout sélectionner » qui coche d'un coup tous les livres actuellement chargés
(donc déjà filtrés : cocher les 400 livres du fonds derrière un filtre à trois résultats
n'aurait aucun sens).
*@
@if (_lotOuvert)
{
<div id="lot-catalogue">
@if (_selectionLot.Count > 0)
{
<div class="barre-selection" role="group" aria-label="Actions sur la sélection">
<span class="selection-compte">
@_selectionLot.Count sélectionné@(_selectionLot.Count > 1 ? "s" : "")
</span>
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@_enCoursLot" @onclick="ToutSelectionnerLot">
Tout sélectionner (@_livres!.Count)
</button>
<button type="button" class="bouton bouton-compact bouton-discret"
disabled="@_enCoursLot" @onclick="() => _selectionLot.Clear()">
Tout décocher
</button>
</div>
<FormulaireLotLivres Saisie="_saisieLot" NombreLivres="_selectionLot.Count"
Erreur="@_erreurLot" EnCours="_enCoursLot" MessageBlocage="@MotifBlocageLot"
OnValider="EnregistrerLotAsync" OnAnnuler="AnnulerLot" />
}
else
{
<p class="message-discret">
Cochez les livres à modifier ensemble, ou
<button type="button" class="bouton bouton-discret bouton-compact"
@onclick="ToutSelectionnerLot">tout sélectionner (@_livres!.Count)</button>.
</p>
}
</div>
}
@*
Les tomes d'une série tiennent sous son nom, à la place qu'occuperait le premier d'entre
eux : l'ordre général du catalogue ne bouge pas, on ne perd donc pas un livre qu'on
@@ -162,13 +291,32 @@ 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"
LotActif="_lotOuvert" SelectionLot="_selectionLot"
OnSelectionner="args => SelectionnerLot(args.LivreId, args.Coche)" />
}
else if (_lotOuvert)
{
<li class="carte-oeuvre" @key="entree.Livres[0].Id">
<label class="oeuvre-case">
<input type="checkbox"
checked="@_selectionLot.Contains(entree.Livres[0].Id)"
@onchange="e => SelectionnerLot(entree.Livres[0].Id, e.Value is true)" />
<span class="lecture-ecran">Sélectionner « @entree.Livres[0].Titre »</span>
</label>
<CarteLivre Livre="entree.Livres[0]" />
</li>
}
else
{
@@ -180,29 +328,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 +351,55 @@ else if (_livres is not null)
private bool _grouper = true;
/// <summary>
/// Blocs repliés à la main, par identifiant de série. Tout est 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,10 +411,24 @@ 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;
/// <summary>Le panneau de modification en lot est déplié. Même patron que la fiche série.</summary>
private bool _lotOuvert;
/// <summary>Identifiants des livres cochés pour la modification en lot.</summary>
private readonly HashSet<int> _selectionLot = [];
private SaisieLot _saisieLot = new();
private bool _enCoursLot;
private string? _erreurLot;
private string? MotifBlocageLot => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
/// <summary>
/// Formats réellement présents dans le fonds, ou <c>null</c> tant qu'on ne le sait pas.
/// </summary>
@@ -277,6 +456,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 +479,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 +508,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 +546,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 +612,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 +625,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 +647,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)
@@ -434,6 +687,110 @@ else if (_livres is not null)
}
}
/// <summary>
/// Ouvre ou ferme le panneau de modification en lot. Le refermer vide aussi la sélection et
/// la saisie en cours — même règle que sur la fiche série : un panneau qui se rouvrirait sur
/// l'état d'avant laisserait croire à une saisie encore active.
/// </summary>
private void BasculerLot()
{
_lotOuvert = !_lotOuvert;
_erreurLot = null;
_selectionLot.Clear();
_saisieLot = new SaisieLot();
}
private void SelectionnerLot(int livreId, bool selectionne)
{
if (selectionne)
{
_selectionLot.Add(livreId);
}
else
{
_selectionLot.Remove(livreId);
}
}
/// <summary>
/// Coche d'un coup tous les livres actuellement CHARGÉS — donc déjà filtrés. Un groupe replié
/// n'y échappe pas : la sélection porte sur <c>_livres</c>, pas sur ce que l'écran affiche.
/// </summary>
private void ToutSelectionnerLot()
{
foreach (var livre in _livres ?? [])
{
_selectionLot.Add(livre.Id);
}
}
private void AnnulerLot()
{
_selectionLot.Clear();
_saisieLot = new SaisieLot();
_erreurLot = null;
}
/// <summary>
/// Applique la saisie en lot à chacun des livres cochés, un appel à la fois — même patron que
/// la fiche série : pas de point d'entrée dédié, la boucle réutilise
/// <see cref="ServiceLivresApi.ModifierAsync"/>, en ne remplaçant que les champs cochés et en
/// reprenant tels quels ceux qui ne le sont pas.
/// </summary>
private async Task EnregistrerLotAsync()
{
_enCoursLot = true;
_erreurLot = null;
var erreurs = new List<string>();
foreach (var livreId in _selectionLot)
{
var livre = (_livres ?? []).FirstOrDefault(l => l.Id == livreId);
if (livre is null)
{
continue;
}
var saisie = new EnregistrementLivre
{
Isbn = livre.Isbn,
Titre = livre.Titre,
Auteurs = _saisieLot.ModifierAuteurs
? _saisieLot.Auteurs
: livre.Auteurs.Select(a => new AuteurSaisi(a.Nom, a.Role)).ToList(),
Themes = _saisieLot.ModifierThemes ? _saisieLot.Themes : [.. livre.Themes],
Editeur = _saisieLot.ModifierEditeur ? _saisieLot.Editeur : livre.Editeur,
Format = _saisieLot.ModifierFormat ? _saisieLot.Format : livre.Format,
TypeDocument = _saisieLot.ModifierType ? _saisieLot.TypeDocument : livre.TypeDocument,
Statut = _saisieLot.ModifierStatut ? _saisieLot.Statut : livre.Statut,
NombrePages = livre.NombrePages,
CoverUrl = livre.CoverUrl,
UrlNotice = livre.UrlNotice,
};
var resultat = await Api.ModifierAsync(livre.Id, saisie);
if (!resultat.EstOk)
{
erreurs.Add(resultat.Erreur!);
}
}
_enCoursLot = false;
if (erreurs.Count > 0)
{
_erreurLot = string.Join(" ", erreurs.Distinct());
await ChargerAsync();
return;
}
_lotOuvert = false;
_selectionLot.Clear();
_saisieLot = new SaisieLot();
await ChargerAsync();
}
public void Dispose()
{
Reseau.Change -= SurChangementReseau;
+36 -3
View File
@@ -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;
+113 -1
View File
@@ -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;
+138 -4
View File
@@ -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();
File diff suppressed because it is too large Load Diff
+52
View File
@@ -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()
{
+31 -5
View File
@@ -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>(
+221 -1
View File
@@ -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 il commence et 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' 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;
}
+35 -1
View File
@@ -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);
}
+22
View File
@@ -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; }
}
+24
View File
@@ -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)
{
+164
View File
@@ -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;
}
}
+6
View File
@@ -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; }
+12
View File
@@ -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; }
}
+30 -1
View File
@@ -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();
}
}
+83
View File
@@ -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;
}
}
+121
View File
@@ -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);
}
}
+55 -5
View File
@@ -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" }));
+37
View File
@@ -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));
}
}
+133
View File
@@ -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);
}
}
}
+70
View File
@@ -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));
}
+3
View File
@@ -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")]
+32
View File
@@ -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()
{
+92
View File
@@ -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)));
}
}
}
}
+1 -2
View File
@@ -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
View File
@@ -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`).