Compte les pages d'un livre, et fais des envies une liste à deux moitiés
Lot U — le nombre de pages `Livre.NombrePages` est nullable, sans valeur par défaut : « 0 page » se lirait comme une donnée là où l'on veut dire « on ne sait pas ». Même raison que pour `TypeDocument.NonPrecise` — un défaut qui ne prétend rien n'a rien à reprendre, d'où une migration réduite à un `AddColumn`. Le service refuse un zéro plutôt que de l'écrire ; effacer le champ reste la façon de revenir à « inconnu ». Le préremplissage vient de `dc:format`, que rien ne lisait jusqu'ici. ⚠️ Ce champ n'est pas un nombre mais une phrase décrivant le support, et les notices déjà enregistrées sous Fixtures/ le montrent : « 1 vol. (113 p.) : ill., couv. ill. en coul. ; 18 cm », « 503 p. : couv. ill. ; 17 cm ». Les règles sont donc étroites — un nombre suivi de « p. » ou de « page(s) », rien d'autre — et tout le reste rend `null`. L'erreur n'est pas symétrique : un champ vide se remplit à la main en trois secondes, un chiffre faux s'enregistre sans que personne ne le voie. « 1 vol. » ne vaut pas 1, « 30 cm » ne vaut pas 30, et « (p. 45-90) », qui est une pagination de contribution, ne vaut rien. La valeur reste proposée dans un champ modifiable, et rien n'est déduit pour un ebook. Lot X — éditer une envie, et souhaiter une revue `PUT /api/souhaits/{id}` recalcule la clé d'œuvre et l'auteur normalisé : sans ce recalcul, le rapprochement « déjà au catalogue » continuerait de se faire sur l'ancienne forme, et le signalement mentirait sans le dire. Le filtre sur l'appelant fait partie de la clé de recherche, pas d'une vérification ultérieure — l'envie d'un autre est introuvable (404), jamais refusée (403). ⚠️ Une édition peut heurter l'unicité (utilisateur, œuvre, auteur), ce qu'un ajout ne peut pas : renommer une envie en une autre déjà présente répond par un message lisible, jamais par « UNIQUE constraint failed ». 400 et non 409, contrairement au doublon du catalogue : là-bas posséder deux exemplaires est légitime et l'appel se reconfirme, ici l'index l'interdit et il n'y a rien à confirmer. Le rang n'est pas touché — l'ordre a son propre point d'entrée. ⚠️ La couverture n'est écrite que si la charge utile en porte une. Aucun écran n'offre de champ « URL de couverture » pour une envie (décision actée), donc un remplacement inconditionnel l'aurait effacée à la première faute de frappe corrigée. `RevueSouhaitee` est une table sœur, et non des colonnes de plus sur `LivreSouhaite` : un numéro n'a pas d'auteur et se distingue par son numéro, deux choses que la clé d'unicité des envies de livres ne sait pas exprimer sans devenir fausse pour tout le monde. `NumeroNormalise` est NOT NULL avec un défaut vide — SQLite tient deux NULL pour distincts, et « Médor, sans numéro » s'ajouterait autant de fois qu'on cliquerait. L'ISSN est canonisé avec son tiret, seul code du projet rangé ainsi. Le coût de la table sœur est payé partout où il devait l'être : affichage, `.txt`, `.csv` et instantané hors-ligne `souhaits-revues`. ⚠️ Les revues forment une SECTION à part plutôt que des lignes entrelacées : chaque table numérote son rang indépendamment, et mélanger deux suites sans rapport produirait un ordre que personne n'a choisi. Le `.txt`, groupé par auteur, ne pouvait de toute façon pas les accueillir — elles n'en ont pas, et « Auteur non précisé » désigne des livres dont l'auteur est inconnu. Le CSV gagne une colonne « Type » : sans elle, un tri par titre rendrait revues et livres indiscernables, et la colonne des codes mêlerait ISBN et ISSN en silence. `ServiceRenormalisation` connaît la nouvelle table, avec la règle de collision déjà en place. ⚠️ L'ISSN y est canonisé à part : `Renormaliser` n'applique rien quand la clé ne bouge pas, un ISSN mal formé sur une ligne au titre inchangé y échapperait. `RevueSouhaitee` ne porte PAS de `CoverUrl` : rien à ajouter au garde de `GET /api/couvertures`. Vérifié en exécution : ISSN « 24666718 » rangé « 2466-6718 », édition de l'envie d'un autre en 404, et les deux exports portant bien les deux moitiés. 602 tests au vert (552 au départ), aucun avertissement de compilation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,13 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
|
||||
/// </summary>
|
||||
public DbSet<LivreSouhaite> LivresSouhaites => Set<LivreSouhaite>();
|
||||
|
||||
/// <summary>
|
||||
/// Envies de revues et de numéros, <b>personnelles</b>. Table sœur de
|
||||
/// <see cref="LivresSouhaites"/> : un numéro n'a pas d'auteur, et se distingue par son
|
||||
/// numéro — deux choses que la clé d'unicité des envies de livres ne sait pas dire.
|
||||
/// </summary>
|
||||
public DbSet<RevueSouhaitee> RevuesSouhaitees => Set<RevueSouhaitee>();
|
||||
|
||||
public DbSet<BibliographieMasquee> BibliographiesMasquees => Set<BibliographieMasquee>();
|
||||
|
||||
/// <summary>
|
||||
@@ -174,6 +181,28 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
|
||||
.IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RevueSouhaitee>(envie =>
|
||||
{
|
||||
envie.Property(e => e.Utilisateur).IsRequired();
|
||||
envie.Property(e => e.Titre).IsRequired();
|
||||
envie.Property(e => e.TitreNormalise).IsRequired();
|
||||
|
||||
// Jamais nullable, exactement pour la raison qui l'impose à AuteurNormalise : SQLite
|
||||
// tient deux NULL pour distincts, et « Médor, pas de numéro » s'ajouterait autant de
|
||||
// fois qu'on cliquerait.
|
||||
envie.Property(e => e.NumeroNormalise).IsRequired().HasDefaultValue(string.Empty);
|
||||
|
||||
envie.HasIndex(e => e.Utilisateur, "IX_RevuesSouhaitees_Utilisateur");
|
||||
|
||||
// Le numéro fait partie de la clé : souhaiter le 43 ET le 44 de la même revue est le
|
||||
// cas normal, alors que noter deux fois le même numéro est une fausse manœuvre.
|
||||
// L'utilisateur ouvre l'index : deux personnes peuvent vouloir la même parution.
|
||||
envie.HasIndex(
|
||||
e => new { e.Utilisateur, e.TitreNormalise, e.NumeroNormalise },
|
||||
"IX_RevuesSouhaitees_Utilisateur_Revue")
|
||||
.IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<BibliographieMasquee>(masquee =>
|
||||
{
|
||||
masquee.Property(m => m.Utilisateur).IsRequired();
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
// <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("20260821114427_NombreDePages")]
|
||||
partial class NombreDePages
|
||||
{
|
||||
/// <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<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.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,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MaBibli.Api.Data.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Ajoute le nombre de pages, <b>nullable</b>, sans rien reprendre de l'existant.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Aucune valeur par défaut, et surtout pas <c>0</c> : aucun livre déjà catalogué ne
|
||||
/// porte cette information, et un zéro écrit partout se lirait comme « ce livre n'a pas de
|
||||
/// pages » au lieu de « on ne sait pas ». C'est la même raison qui a permis à
|
||||
/// <c>TypeDeDocument</c> de se réduire à un <c>AddColumn</c> — un défaut qui ne prétend
|
||||
/// rien n'a rien à reprendre, contrairement à <c>RangDesEnvies</c>.
|
||||
/// </remarks>
|
||||
public partial class NombreDePages : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "NombrePages",
|
||||
table: "Livres",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NombrePages",
|
||||
table: "Livres");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
// <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("20260821115007_EnviesDeRevues")]
|
||||
partial class EnviesDeRevues
|
||||
{
|
||||
/// <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<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,66 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MaBibli.Api.Data.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Crée la table sœur des envies : les revues et numéros souhaités.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rien à reprendre de l'existant — la notion n'existait pas — donc une simple création de
|
||||
/// table, contrairement à <c>RangDesEnvies</c> qui devait reconduire un ordre déjà affiché.
|
||||
/// <para>
|
||||
/// ⚠️ <c>NumeroNormalise</c> est <b>NOT NULL avec un défaut vide</b>, et ce n'est pas une
|
||||
/// coquetterie : SQLite tient deux <c>NULL</c> pour distincts, et l'unicité ci-dessous
|
||||
/// laisserait alors ajouter autant de fois « Médor, sans numéro » qu'on voudrait. Même
|
||||
/// piège que <c>LivresSouhaites.AuteurNormalise</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public partial class EnviesDeRevues : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RevuesSouhaitees",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Utilisateur = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Titre = table.Column<string>(type: "TEXT", nullable: false),
|
||||
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Numero = table.Column<string>(type: "TEXT", nullable: true),
|
||||
NumeroNormalise = table.Column<string>(type: "TEXT", nullable: false, defaultValue: ""),
|
||||
Issn = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Note = table.Column<string>(type: "TEXT", nullable: true),
|
||||
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
Rang = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RevuesSouhaitees", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RevuesSouhaitees_Utilisateur",
|
||||
table: "RevuesSouhaitees",
|
||||
column: "Utilisateur");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RevuesSouhaitees_Utilisateur_Revue",
|
||||
table: "RevuesSouhaitees",
|
||||
columns: new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "RevuesSouhaitees");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,9 @@ namespace MaBibli.Api.Data.Migrations
|
||||
b.Property<string>("Isbn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("NombrePages")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Titre")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
@@ -398,6 +401,55 @@ namespace MaBibli.Api.Data.Migrations
|
||||
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")
|
||||
|
||||
@@ -66,6 +66,35 @@ public sealed class ServiceRenormalisation(MaBibliDbContext db, ILogger<ServiceR
|
||||
s => s.RecalculerFormes(),
|
||||
s => $"l'envie « {s.Titre} »");
|
||||
|
||||
// ⚠️ La table sœur des envies suit exactement la même règle. L'oublier ne se verrait
|
||||
// pas tout de suite : une revue souhaitée resterait introuvable par sa propre clé le
|
||||
// jour où la normalisation changerait — c'est ce qui était arrivé aux envies avec les
|
||||
// ligatures.
|
||||
var revuesSouhaitees = await db.RevuesSouhaitees.OrderBy(e => e.Id).ToListAsync(ct);
|
||||
corriges += Renormaliser(
|
||||
revuesSouhaitees,
|
||||
e => Cle(e.Utilisateur, e.TitreNormalise, e.NumeroNormalise),
|
||||
e => Cle(
|
||||
e.Utilisateur,
|
||||
NormalisationTexte.Normaliser(e.Titre),
|
||||
e.Numero is null ? string.Empty : NormalisationTexte.Normaliser(e.Numero)),
|
||||
e => e.RecalculerFormes(),
|
||||
e => $"l'envie de revue « {e.Titre} »");
|
||||
|
||||
// ⚠️ Renormaliser() n'applique rien quand la CLÉ ne bouge pas : un ISSN mal formé sur
|
||||
// une ligne au titre inchangé y échapperait. Il se canonise donc à part — et sans le
|
||||
// moindre jeu de clés, cette colonne ne portant aucune unicité, contrairement à celle
|
||||
// de Revue.
|
||||
foreach (var envie in revuesSouhaitees)
|
||||
{
|
||||
var voulu = FormatageIssn.Canonique(envie.Issn);
|
||||
if (voulu != envie.Issn)
|
||||
{
|
||||
envie.Issn = voulu;
|
||||
corriges++;
|
||||
}
|
||||
}
|
||||
|
||||
var series = await db.Series.OrderBy(s => s.Id).ToListAsync(ct);
|
||||
corriges += Renormaliser(
|
||||
series,
|
||||
|
||||
@@ -74,6 +74,37 @@ public static class SouhaitsEndpoints
|
||||
.Produces<SouhaitDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
groupe.MapPut("/{id:int}", async (
|
||||
int id,
|
||||
EnregistrementSouhait saisie,
|
||||
IServiceSouhaits service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var resultat = await service.ModifierAsync(
|
||||
id, saisie, utilisateurs.Obtenir().Identifiant, ct);
|
||||
|
||||
if (resultat.NExistePas)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return resultat.Erreur is not null
|
||||
? Results.BadRequest(new { erreur = resultat.Erreur })
|
||||
: Results.Ok(resultat.Souhait);
|
||||
})
|
||||
.WithName("ModifierSouhait")
|
||||
.WithSummary("Corrige une envie de l'appelant.")
|
||||
.WithDescription(
|
||||
"404 si l'envie n'existe pas OU appartient à quelqu'un d'autre : les deux cas "
|
||||
+ "sont volontairement indiscernables. 400 — et non 409 — quand la correction "
|
||||
+ "ferait doublon avec une autre envie : l'unicité l'interdit, il n'y a rien à "
|
||||
+ "confirmer, contrairement au doublon du catalogue. Le rang n'est pas touché : "
|
||||
+ "l'ordre a son propre point d'entrée.")
|
||||
.Produces<SouhaitDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapDelete("/{id:int}", async (
|
||||
int id,
|
||||
IServiceSouhaits service,
|
||||
@@ -93,20 +124,108 @@ public static class SouhaitsEndpoints
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Envies de revues et de numéros.
|
||||
//
|
||||
// Table sœur, donc points d'entrée sœurs : mêmes règles de portée, même 404 sur ce qui
|
||||
// appartient à un autre. Le préfixe littéral « revues » ne peut pas être confondu avec
|
||||
// « /{id:int} », qui exige un entier.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
groupe.MapGet("/revues", async (
|
||||
IServiceRevuesSouhaitees service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
Results.Ok(await service.ListerAsync(utilisateurs.Obtenir().Identifiant, ct)))
|
||||
.WithName("ListerRevuesSouhaitees")
|
||||
.WithSummary("Les revues et numéros souhaités par l'appelant.")
|
||||
.WithDescription(
|
||||
"Section distincte de la liste d'envies : les rangs des deux tables sont "
|
||||
+ "numérotés indépendamment, les entrelacer produirait un ordre que personne "
|
||||
+ "n'a choisi.")
|
||||
.Produces<IReadOnlyList<RevueSouhaiteeDto>>();
|
||||
|
||||
groupe.MapPost("/revues", async (
|
||||
EnregistrementRevueSouhaitee saisie,
|
||||
IServiceRevuesSouhaitees service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var resultat = await service.AjouterAsync(
|
||||
saisie, utilisateurs.Obtenir().Identifiant, ct);
|
||||
|
||||
return resultat.Erreur is not null
|
||||
? Results.BadRequest(new { erreur = resultat.Erreur })
|
||||
: Results.Created($"/api/souhaits/revues/{resultat.Envie!.Id}", resultat.Envie);
|
||||
})
|
||||
.WithName("AjouterRevueSouhaitee")
|
||||
.WithSummary("Ajoute une revue — ou un numéro précis — à la liste d'envies.")
|
||||
.WithDescription(
|
||||
"Le numéro est facultatif : on souhaite parfois un numéro vu en kiosque, "
|
||||
+ "parfois le titre entier. L'ISSN est canonisé avec son tiret.")
|
||||
.Produces<RevueSouhaiteeDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
groupe.MapPut("/revues/{id:int}", async (
|
||||
int id,
|
||||
EnregistrementRevueSouhaitee saisie,
|
||||
IServiceRevuesSouhaitees service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var resultat = await service.ModifierAsync(
|
||||
id, saisie, utilisateurs.Obtenir().Identifiant, ct);
|
||||
|
||||
if (resultat.NExistePas)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return resultat.Erreur is not null
|
||||
? Results.BadRequest(new { erreur = resultat.Erreur })
|
||||
: Results.Ok(resultat.Envie);
|
||||
})
|
||||
.WithName("ModifierRevueSouhaitee")
|
||||
.WithSummary("Corrige une envie de revue de l'appelant.")
|
||||
.Produces<RevueSouhaiteeDto>()
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
groupe.MapDelete("/revues/{id:int}", async (
|
||||
int id,
|
||||
IServiceRevuesSouhaitees service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var supprime = await service.SupprimerAsync(
|
||||
id, utilisateurs.Obtenir().Identifiant, ct);
|
||||
|
||||
return supprime ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.WithName("SupprimerRevueSouhaitee")
|
||||
.WithSummary("Retire une revue de la liste d'envies de l'appelant.")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Deux formats parce que les deux usages décrits dans IDEES.md diffèrent : le .txt
|
||||
// s'emporte en librairie et se lit tel quel, le .csv s'ouvre dans un tableur pour se
|
||||
// répartir des achats. Ce sont deux fichiers texte : aucune dépendance ni pour les
|
||||
// produire, ni pour les lire.
|
||||
groupe.MapGet("/export.txt", (
|
||||
IServiceSouhaits service, IFournisseurUtilisateur utilisateurs, CancellationToken ct) =>
|
||||
ExporterAsync(FormatExportSouhaits.Texte, service, utilisateurs, ct))
|
||||
IServiceSouhaits service,
|
||||
IServiceRevuesSouhaitees revues,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
ExporterAsync(FormatExportSouhaits.Texte, service, revues, utilisateurs, ct))
|
||||
.WithName("ExporterSouhaitsTexte")
|
||||
.WithSummary("Liste d'envies en texte lisible, groupée par auteur.")
|
||||
.ExcludeFromDescription();
|
||||
|
||||
groupe.MapGet("/export.csv", (
|
||||
IServiceSouhaits service, IFournisseurUtilisateur utilisateurs, CancellationToken ct) =>
|
||||
ExporterAsync(FormatExportSouhaits.Csv, service, utilisateurs, ct))
|
||||
IServiceSouhaits service,
|
||||
IServiceRevuesSouhaitees revues,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
ExporterAsync(FormatExportSouhaits.Csv, service, revues, utilisateurs, ct))
|
||||
.WithName("ExporterSouhaitsCsv")
|
||||
.WithSummary("Liste d'envies en CSV, ouvrable dans un tableur.")
|
||||
.ExcludeFromDescription();
|
||||
@@ -131,15 +250,20 @@ public static class SouhaitsEndpoints
|
||||
private static async Task<IResult> ExporterAsync(
|
||||
FormatExportSouhaits format,
|
||||
IServiceSouhaits service,
|
||||
IServiceRevuesSouhaitees revues,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var utilisateur = utilisateurs.Obtenir();
|
||||
var souhaits = await service.ListerAsync(utilisateur.Identifiant, ct);
|
||||
|
||||
// ⚠️ Les deux tables partent ensemble, sans quoi les envies de revues n'existeraient
|
||||
// pas dans le fichier qu'on emporte — c'est-à-dire là où elles servent.
|
||||
var revuesSouhaitees = await revues.ListerAsync(utilisateur.Identifiant, ct);
|
||||
var maintenant = DateTime.Now;
|
||||
|
||||
var contenu = ExportSouhaits.Produire(
|
||||
format, souhaits, utilisateur.Affichage, maintenant);
|
||||
format, souhaits, revuesSouhaitees, utilisateur.Affichage, maintenant);
|
||||
|
||||
// Le BOM n'est mis QUE sur le CSV, où il est indispensable : sans lui, Excel lit le
|
||||
// fichier en codage hérité et affiche « Émile Zola ». Le .txt s'en passe — il est fait
|
||||
|
||||
@@ -21,6 +21,7 @@ builder.Services.AddScoped<IServiceAuteurs, ServiceAuteurs>();
|
||||
builder.Services.AddScoped<IServiceCatalogue, ServiceCatalogue>();
|
||||
builder.Services.AddScoped<IServicePrets, ServicePrets>();
|
||||
builder.Services.AddScoped<IServiceSouhaits, ServiceSouhaits>();
|
||||
builder.Services.AddScoped<IServiceRevuesSouhaitees, ServiceRevuesSouhaitees>();
|
||||
builder.Services.AddScoped<IServiceBibliographie, ServiceBibliographie>();
|
||||
builder.Services.AddScoped<IServiceSeries, ServiceSeries>();
|
||||
builder.Services.AddScoped<IServiceRevues, ServiceRevues>();
|
||||
|
||||
@@ -134,6 +134,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
Editeur = Vide(saisie.Editeur),
|
||||
Format = saisie.Format,
|
||||
TypeDocument = saisie.TypeDocument,
|
||||
NombrePages = saisie.NombrePages,
|
||||
CoverUrl = Vide(saisie.CoverUrl),
|
||||
UrlNotice = Vide(saisie.UrlNotice),
|
||||
DateAjout = DateTime.UtcNow,
|
||||
@@ -178,6 +179,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
livre.Editeur = Vide(saisie.Editeur);
|
||||
livre.Format = saisie.Format;
|
||||
livre.TypeDocument = saisie.TypeDocument;
|
||||
livre.NombrePages = saisie.NombrePages;
|
||||
livre.CoverUrl = Vide(saisie.CoverUrl);
|
||||
livre.UrlNotice = Vide(saisie.UrlNotice);
|
||||
livre.RecalculerFormes();
|
||||
@@ -516,6 +518,15 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
isbn = normalise;
|
||||
}
|
||||
|
||||
// ⚠️ On refuse « 0 page » plutôt que de l'enregistrer : la colonne est nullable
|
||||
// précisément pour que l'absence ait sa propre valeur, et un zéro stocké se lirait
|
||||
// comme une donnée. Laisser le champ vide est la façon de dire « je ne sais pas ».
|
||||
if (saisie.NombrePages is { } pages && pages <= 0)
|
||||
{
|
||||
erreur = "Le nombre de pages doit être positif. Laissez le champ vide si vous ne l'avez pas.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -540,6 +551,7 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur
|
||||
Editeur = livre.Editeur,
|
||||
Format = livre.Format,
|
||||
TypeDocument = livre.TypeDocument,
|
||||
NombrePages = livre.NombrePages,
|
||||
Statut = statut,
|
||||
CoverUrl = livre.CoverUrl,
|
||||
UrlNotice = livre.UrlNotice,
|
||||
|
||||
@@ -71,6 +71,13 @@ public static class BnfSruParser
|
||||
Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")),
|
||||
Annee = Premier(data, "date"),
|
||||
Langue = Premier(data, "language"),
|
||||
|
||||
// ⚠️ Plusieurs dc:format cohabitent sur une notice (« 1 vol. (349 p.) », puis
|
||||
// « 30 cm ») : on prend le PREMIER qui donne une pagination, et non le premier
|
||||
// tout court, qui pourrait n'être qu'une dimension.
|
||||
NombrePages = Tous(data, "format")
|
||||
.Select(NettoyageIsbd.NombreDePages)
|
||||
.FirstOrDefault(pages => pages is not null),
|
||||
CoverUrl = urlCouverture,
|
||||
Source = SourceMetadonnees.Bnf,
|
||||
IsbnInterroge = isbnInterroge,
|
||||
|
||||
@@ -23,6 +23,13 @@ public static partial class NettoyageIsbd
|
||||
[GeneratedRegex(@"\s*\([^)]*\)\s*$")]
|
||||
private static partial Regex ParenthesesFinales();
|
||||
|
||||
/// <summary>
|
||||
/// Un nombre collé à une mention de pages. Le nombre doit <b>précéder</b> « p. » : dans
|
||||
/// « p. 45-90 », les chiffres décrivent un intervalle dans un volume, pas son épaisseur.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"(?<![\d\]])(\d{1,6})\s*(?:p\.|pages?\b)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex Pagination();
|
||||
|
||||
/// <summary>
|
||||
/// Titre : couper à la première mention de responsabilité (« / »).
|
||||
/// </summary>
|
||||
@@ -166,6 +173,51 @@ public static partial class NettoyageIsbd
|
||||
return s.Length == 0 ? null : s;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nombre de pages lu dans la description matérielle (<c>dc:format</c>), ou <c>null</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Ce champ n'est pas un nombre, c'est une phrase</b> : la BnF y décrit le support
|
||||
/// (« 1 vol. (113 p.) : ill., couv. ill. en coul. ; 18 cm », « 503 p. : couv. ill. ; 17 cm »,
|
||||
/// mesuré sur les notices enregistrées sous <c>Fixtures/</c>). Les règles restent
|
||||
/// volontairement <b>étroites</b> : on ne reconnaît qu'un nombre suivi de « p. » ou de
|
||||
/// « page(s) », et <b>tout le reste rend <c>null</c></b> — le catalogue décrit aussi des
|
||||
/// disques compacts et des dimensions en centimètres.
|
||||
/// <para>
|
||||
/// ⚠️ Le sens de l'erreur n'est pas symétrique : un champ vide se remplit à la main en trois
|
||||
/// secondes, un chiffre faux s'enregistre sans que personne ne le remarque. C'est pourquoi
|
||||
/// « 1 vol. » ne rend pas 1, « 30 cm » ne rend pas 30, et « (p. 45-90) » — une pagination de
|
||||
/// contribution, pas une épaisseur — ne rend rien du tout.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// La valeur reste de toute façon <b>proposée</b> dans un champ modifiable, jamais imposée.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static int? NombreDePages(string? brut)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(brut))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trouve = Pagination().Match(brut);
|
||||
if (!trouve.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!int.TryParse(
|
||||
trouve.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var pages))
|
||||
{
|
||||
// Un nombre à rallonge ne tient pas dans un int : ce n'est pas une pagination.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bornes de bon sens. Zéro page n'est pas une donnée, et au-delà de dix mille on a lu
|
||||
// autre chose que l'épaisseur d'un livre.
|
||||
return pages is > 0 and <= 10_000 ? pages : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Titre d'un <b>périodique</b> : couper la mention de responsabilité puis retirer le
|
||||
/// qualificatif entre parenthèses.
|
||||
|
||||
@@ -48,12 +48,29 @@ public static class ExportSouhaits
|
||||
public static string TypeMime(FormatExportSouhaits format) =>
|
||||
format == FormatExportSouhaits.Csv ? "text/csv; charset=utf-8" : "text/plain; charset=utf-8";
|
||||
|
||||
/// <summary>
|
||||
/// Produit le fichier, <b>les deux tables réunies</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Les revues souhaitées viennent d'une table sœur et doivent traverser l'export : c'est
|
||||
/// le fichier qu'on emporte, donc précisément l'endroit où les oublier ne se verrait pas.
|
||||
/// <para>
|
||||
/// Elles forment une <b>section à part</b> plutôt que d'être entrelacées : les deux tables
|
||||
/// numérotent leur rang indépendamment, et mélanger deux suites sans rapport produirait un
|
||||
/// ordre que personne n'a choisi. Une revue n'a d'ailleurs pas d'auteur, donc rien à faire
|
||||
/// dans un fichier groupé par auteur.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static string Produire(
|
||||
FormatExportSouhaits format, IReadOnlyList<SouhaitDto> souhaits, string? proprietaire, DateTime maintenant) =>
|
||||
FormatExportSouhaits format,
|
||||
IReadOnlyList<SouhaitDto> souhaits,
|
||||
IReadOnlyList<RevueSouhaiteeDto> revues,
|
||||
string? proprietaire,
|
||||
DateTime maintenant) =>
|
||||
format switch
|
||||
{
|
||||
FormatExportSouhaits.Csv => Csv(souhaits),
|
||||
_ => Texte(souhaits, proprietaire, maintenant),
|
||||
FormatExportSouhaits.Csv => Csv(souhaits, revues),
|
||||
_ => Texte(souhaits, revues, proprietaire, maintenant),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -63,7 +80,11 @@ public static class ExportSouhaits
|
||||
/// Le groupement par auteur n'est pas cosmétique : c'est ainsi qu'une librairie est rangée.
|
||||
/// Une liste à plat obligerait à parcourir tout le fichier pour chaque rayon.
|
||||
/// </remarks>
|
||||
public static string Texte(IReadOnlyList<SouhaitDto> souhaits, string? proprietaire, DateTime maintenant)
|
||||
public static string Texte(
|
||||
IReadOnlyList<SouhaitDto> souhaits,
|
||||
IReadOnlyList<RevueSouhaiteeDto> revues,
|
||||
string? proprietaire,
|
||||
DateTime maintenant)
|
||||
{
|
||||
var sortie = new StringBuilder();
|
||||
|
||||
@@ -76,14 +97,26 @@ public static class ExportSouhaits
|
||||
sortie.Append(" — ").Append(maintenant.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture)).Append('\n');
|
||||
sortie.Append(new string('=', 40)).Append('\n');
|
||||
|
||||
if (souhaits.Count == 0)
|
||||
if (souhaits.Count == 0 && revues.Count == 0)
|
||||
{
|
||||
sortie.Append("\n(aucun livre dans la liste)\n");
|
||||
sortie.Append("\n(aucune envie dans la liste)\n");
|
||||
return sortie.ToString();
|
||||
}
|
||||
|
||||
if (souhaits.Count > 0)
|
||||
{
|
||||
sortie.Append('\n').Append(souhaits.Count).Append(souhaits.Count > 1 ? " livres" : " livre").Append('\n');
|
||||
Livres(sortie, souhaits);
|
||||
}
|
||||
|
||||
Revues(sortie, revues);
|
||||
|
||||
return sortie.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Les livres souhaités, groupés par auteur.</summary>
|
||||
private static void Livres(StringBuilder sortie, IReadOnlyList<SouhaitDto> souhaits)
|
||||
{
|
||||
// ⚠️ Le groupement par auteur DÉTRUIT l'ordre d'envie : c'est le prix, assumé, d'une
|
||||
// liste rangée comme une librairie. Le rang est donc réinscrit devant chaque titre,
|
||||
// faute de quoi le fichier emporté ne dit plus par quoi commencer — précisément
|
||||
@@ -125,19 +158,65 @@ public static class ExportSouhaits
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sortie.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Tableau ouvrable dans un tableur, une envie par ligne.</summary>
|
||||
public static string Csv(IReadOnlyList<SouhaitDto> souhaits)
|
||||
/// <summary>
|
||||
/// Les revues souhaitées, en <b>section à part</b>, à la fin.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Elles ne peuvent pas rejoindre les groupes ci-dessus : une revue n'a pas d'auteur, et
|
||||
/// « Auteur non précisé » réunirait des magazines et des livres dont l'auteur est simplement
|
||||
/// inconnu. Au kiosque comme en librairie, ce ne sont pas les mêmes rayons.
|
||||
/// </remarks>
|
||||
private static void Revues(StringBuilder sortie, IReadOnlyList<RevueSouhaiteeDto> revues)
|
||||
{
|
||||
if (revues.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sortie.Append('\n').Append(new string('-', 40)).Append('\n');
|
||||
sortie.Append(revues.Count).Append(revues.Count > 1 ? " revues" : " revue").Append('\n');
|
||||
|
||||
// La numérotation repart de 1 : le rang d'une revue est celui de sa propre section, pas
|
||||
// une place dans un classement commun qui n'existe pas.
|
||||
var rang = 0;
|
||||
|
||||
foreach (var revue in revues)
|
||||
{
|
||||
sortie.Append(" - [").Append(++rang).Append("] ").Append(revue.TitreComplet);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(revue.Issn))
|
||||
{
|
||||
sortie.Append(" (ISSN ").Append(revue.Issn).Append(')');
|
||||
}
|
||||
|
||||
sortie.Append('\n');
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(revue.Note))
|
||||
{
|
||||
sortie.Append(" ").Append(revue.Note.Trim()).Append('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tableau ouvrable dans un tableur, une envie par ligne, <b>les deux tables réunies</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Une colonne « Type » ouvre le tableau à côté du rang : sans elle, une revue et un
|
||||
/// livre seraient indiscernables une fois le fichier trié par titre, et la colonne
|
||||
/// « ISBN / ISSN » deviendrait illisible — deux codes de nature différente y cohabitent.
|
||||
/// Les revues suivent les livres, chaque table gardant sa propre numérotation.
|
||||
/// </remarks>
|
||||
public static string Csv(IReadOnlyList<SouhaitDto> souhaits, IReadOnlyList<RevueSouhaiteeDto> revues)
|
||||
{
|
||||
var sortie = new StringBuilder();
|
||||
|
||||
// Le rang ouvre le tableau : c'est la colonne sur laquelle on trie dans un tableur, et
|
||||
// celle qui répond à « par quoi commence-t-on ? » quand on se répartit des achats.
|
||||
// Le CSV suit déjà cet ordre, mais un ordre implicite se perd au premier tri par titre.
|
||||
Ligne(sortie, "Rang", "Titre", "Auteur", "Éditeur", "Année", "ISBN", "Note", "Ajouté le");
|
||||
Ligne(sortie, "Rang", "Type", "Titre", "Auteur", "Éditeur", "Année", "ISBN / ISSN", "Note", "Ajouté le");
|
||||
|
||||
var rangs = Rangs(souhaits);
|
||||
|
||||
@@ -146,6 +225,7 @@ public static class ExportSouhaits
|
||||
Ligne(
|
||||
sortie,
|
||||
rangs[souhait.Id].ToString(CultureInfo.InvariantCulture),
|
||||
"Livre",
|
||||
souhait.Titre,
|
||||
souhait.Auteur,
|
||||
souhait.Editeur,
|
||||
@@ -155,6 +235,25 @@ public static class ExportSouhaits
|
||||
souhait.DateAjout.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
var rangRevue = 0;
|
||||
|
||||
foreach (var revue in revues)
|
||||
{
|
||||
// Ni auteur, ni éditeur, ni année : une revue n'en a pas au sens de cette liste, et
|
||||
// inventer des colonnes remplies au hasard serait pire que des cases vides.
|
||||
Ligne(
|
||||
sortie,
|
||||
(++rangRevue).ToString(CultureInfo.InvariantCulture),
|
||||
"Revue",
|
||||
revue.TitreComplet,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
revue.Issn,
|
||||
revue.Note,
|
||||
revue.DateAjout.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return sortie.ToString();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
using MaBibli.Api.Data;
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Entites;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MaBibli.Api.Services.Souhaits;
|
||||
|
||||
/// <summary>Issue d'une écriture d'envie de revue : la ligne, un message, ou rien du tout.</summary>
|
||||
public readonly record struct ResultatRevueSouhaitee(
|
||||
RevueSouhaiteeDto? Envie, string? Erreur, bool NExistePas = false)
|
||||
{
|
||||
public static readonly ResultatRevueSouhaitee Introuvable = new(null, null, true);
|
||||
|
||||
public static ResultatRevueSouhaitee Ok(RevueSouhaiteeDto envie) => new(envie, null);
|
||||
|
||||
public static ResultatRevueSouhaitee Invalide(string message) => new(null, message);
|
||||
}
|
||||
|
||||
public interface IServiceRevuesSouhaitees
|
||||
{
|
||||
Task<IReadOnlyList<RevueSouhaiteeDto>> ListerAsync(
|
||||
string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatRevueSouhaitee> AjouterAsync(
|
||||
EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
Task<ResultatRevueSouhaitee> ModifierAsync(
|
||||
int id, EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Les envies de revues et de numéros, <b>personnelles</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mêmes règles que <see cref="ServiceSouhaits"/>, sans exception : toute méthode filtre sur
|
||||
/// l'appelant, aucun point d'entrée n'accepte de nom d'utilisateur, et l'envie d'un autre est
|
||||
/// simplement introuvable.
|
||||
/// <para>
|
||||
/// ⚠️ Il n'y a <b>pas</b> de rapprochement avec les revues possédées, contrairement aux envies
|
||||
/// de livres. Ce n'est pas un oubli : « je possède la revue mais pas ce numéro » et « je possède
|
||||
/// ce numéro » demandent deux réponses différentes, et un signalement approximatif sur une liste
|
||||
/// d'envies coûte plus qu'il ne rapporte.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ServiceRevuesSouhaitees(MaBibliDbContext db) : IServiceRevuesSouhaitees
|
||||
{
|
||||
public async Task<IReadOnlyList<RevueSouhaiteeDto>> ListerAsync(
|
||||
string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
if (utilisateur is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var envies = await db.RevuesSouhaitees
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Utilisateur == utilisateur)
|
||||
.OrderBy(e => e.Rang)
|
||||
.ThenBy(e => e.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return envies.Select(Projeter).ToList();
|
||||
}
|
||||
|
||||
public async Task<ResultatRevueSouhaitee> AjouterAsync(
|
||||
EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
if (utilisateur is null)
|
||||
{
|
||||
return ResultatRevueSouhaitee.Invalide(
|
||||
"Impossible d'ajouter une envie sans savoir à qui elle appartient.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(saisie.Titre))
|
||||
{
|
||||
return ResultatRevueSouhaitee.Invalide("Le titre de la revue est obligatoire.");
|
||||
}
|
||||
|
||||
var envie = new RevueSouhaitee
|
||||
{
|
||||
Utilisateur = utilisateur,
|
||||
Titre = saisie.Titre,
|
||||
Numero = saisie.Numero,
|
||||
Issn = saisie.Issn,
|
||||
Note = Vide(saisie.Note),
|
||||
DateAjout = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
// ⚠️ Canonise l'ISSN au passage : « 24666718 » devient « 2466-6718 », la seule forme
|
||||
// rangée en base et la seule que la BnF interroge.
|
||||
envie.RecalculerFormes();
|
||||
|
||||
if (await ExisteDejaAsync(envie, null, ct))
|
||||
{
|
||||
return ResultatRevueSouhaitee.Invalide(Deja(envie));
|
||||
}
|
||||
|
||||
// En fin de section, comme une envie de livre : on vient de la noter, on n'a pas dit
|
||||
// qu'on la voulait plus que les autres.
|
||||
var dernierRang = await db.RevuesSouhaitees
|
||||
.Where(e => e.Utilisateur == utilisateur)
|
||||
.Select(e => (int?)e.Rang)
|
||||
.MaxAsync(ct);
|
||||
|
||||
envie.Rang = (dernierRang ?? -1) + 1;
|
||||
|
||||
db.RevuesSouhaitees.Add(envie);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return ResultatRevueSouhaitee.Ok(Projeter(envie));
|
||||
}
|
||||
|
||||
public async Task<ResultatRevueSouhaitee> ModifierAsync(
|
||||
int id, EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
if (utilisateur is null)
|
||||
{
|
||||
return ResultatRevueSouhaitee.Introuvable;
|
||||
}
|
||||
|
||||
// Le filtre fait partie de la clé de recherche : l'envie d'un autre n'est pas refusée,
|
||||
// elle n'est pas trouvée.
|
||||
var envie = await db.RevuesSouhaitees
|
||||
.FirstOrDefaultAsync(e => e.Id == id && e.Utilisateur == utilisateur, ct);
|
||||
|
||||
if (envie is null)
|
||||
{
|
||||
return ResultatRevueSouhaitee.Introuvable;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(saisie.Titre))
|
||||
{
|
||||
return ResultatRevueSouhaitee.Invalide("Le titre de la revue est obligatoire.");
|
||||
}
|
||||
|
||||
envie.Titre = saisie.Titre;
|
||||
envie.Numero = saisie.Numero;
|
||||
envie.Issn = saisie.Issn;
|
||||
envie.Note = Vide(saisie.Note);
|
||||
envie.RecalculerFormes();
|
||||
|
||||
if (await ExisteDejaAsync(envie, envie.Id, ct))
|
||||
{
|
||||
return ResultatRevueSouhaitee.Invalide(Deja(envie));
|
||||
}
|
||||
|
||||
// Rang inchangé : corriger un numéro ne dit rien de l'ordre voulu.
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return ResultatRevueSouhaitee.Ok(Projeter(envie));
|
||||
}
|
||||
|
||||
public async Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
if (utilisateur is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var envie = await db.RevuesSouhaitees
|
||||
.FirstOrDefaultAsync(e => e.Id == id && e.Utilisateur == utilisateur, ct);
|
||||
|
||||
if (envie is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.RevuesSouhaitees.Remove(envie);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Le doublon est refusé <b>avec un message</b>, jamais laissé à l'index unique.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ L'entité est déjà rattachée au contexte lors d'une modification : d'où l'exclusion
|
||||
/// explicite de sa propre ligne, sans quoi toute édition se dirait doublon d'elle-même.
|
||||
/// </remarks>
|
||||
private Task<bool> ExisteDejaAsync(RevueSouhaitee envie, int? saufId, CancellationToken ct) =>
|
||||
db.RevuesSouhaitees.AnyAsync(
|
||||
e => e.Utilisateur == envie.Utilisateur
|
||||
&& e.TitreNormalise == envie.TitreNormalise
|
||||
&& e.NumeroNormalise == envie.NumeroNormalise
|
||||
&& (saufId == null || e.Id != saufId),
|
||||
ct);
|
||||
|
||||
private static string Deja(RevueSouhaitee envie) =>
|
||||
envie.Numero is null
|
||||
? $"« {envie.Titre} » est déjà dans votre liste d'envies."
|
||||
: $"« {envie.Titre} n° {envie.Numero} » est déjà dans votre liste d'envies.";
|
||||
|
||||
private static string? Vide(string? valeur) =>
|
||||
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
|
||||
|
||||
/// <summary>Projection vers le DTO, <b>sans</b> le champ <c>Utilisateur</c>.</summary>
|
||||
private static RevueSouhaiteeDto Projeter(RevueSouhaitee envie) => new()
|
||||
{
|
||||
Id = envie.Id,
|
||||
Titre = envie.Titre,
|
||||
Numero = envie.Numero,
|
||||
Issn = envie.Issn,
|
||||
Note = envie.Note,
|
||||
DateAjout = envie.DateAjout,
|
||||
};
|
||||
}
|
||||
@@ -7,9 +7,16 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MaBibli.Api.Services.Souhaits;
|
||||
|
||||
/// <summary>Issue d'un ajout d'envie : soit la ligne créée, soit un message pour l'utilisateur.</summary>
|
||||
public readonly record struct ResultatSouhait(SouhaitDto? Souhait, string? Erreur)
|
||||
/// <summary>Issue d'une écriture d'envie : la ligne, un message, ou rien du tout.</summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ « Introuvable » est un <b>troisième état</b>, et pas un message d'erreur de plus : c'est
|
||||
/// la seule issue qui doive produire un 404. L'édition de l'envie de quelqu'un d'autre passe par
|
||||
/// là — l'inexistence et l'appartenance à autrui sont volontairement indiscernables.
|
||||
/// </remarks>
|
||||
public readonly record struct ResultatSouhait(SouhaitDto? Souhait, string? Erreur, bool NExistePas = false)
|
||||
{
|
||||
public static readonly ResultatSouhait Introuvable = new(null, null, true);
|
||||
|
||||
public static ResultatSouhait Ok(SouhaitDto souhait) => new(souhait, null);
|
||||
|
||||
public static ResultatSouhait Invalide(string message) => new(null, message);
|
||||
@@ -22,6 +29,16 @@ public interface IServiceSouhaits
|
||||
Task<ResultatSouhait> AjouterAsync(
|
||||
EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Corrige une envie existante de l'appelant.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <c>Rang</c> n'y est pas touché : l'ordre a son propre point d'entrée, et corriger un
|
||||
/// titre n'est pas dire qu'on le veut davantage.
|
||||
/// </remarks>
|
||||
Task<ResultatSouhait> ModifierAsync(
|
||||
int id, EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
@@ -153,6 +170,91 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits
|
||||
return ResultatSouhait.Ok(Projeter(souhait, Correspondant(possede, souhait.Id)));
|
||||
}
|
||||
|
||||
public async Task<ResultatSouhait> ModifierAsync(
|
||||
int id, EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
if (utilisateur is null)
|
||||
{
|
||||
return ResultatSouhait.Introuvable;
|
||||
}
|
||||
|
||||
// Le filtre sur l'utilisateur fait partie de la CLÉ, comme pour la suppression : on ne
|
||||
// vérifie pas l'appartenance après coup, on ne trouve tout simplement pas l'envie d'un
|
||||
// autre. C'est ce qui rend le 404 exact plutôt que poli.
|
||||
var souhait = await db.LivresSouhaites
|
||||
.FirstOrDefaultAsync(s => s.Id == id && s.Utilisateur == utilisateur, ct);
|
||||
|
||||
if (souhait is null)
|
||||
{
|
||||
return ResultatSouhait.Introuvable;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(saisie.Titre))
|
||||
{
|
||||
return ResultatSouhait.Invalide("Le titre est obligatoire.");
|
||||
}
|
||||
|
||||
string? isbn = null;
|
||||
if (!string.IsNullOrWhiteSpace(saisie.Isbn))
|
||||
{
|
||||
var normalise = IsbnUtils.Normaliser(saisie.Isbn);
|
||||
if (!IsbnUtils.EstValide(normalise))
|
||||
{
|
||||
return ResultatSouhait.Invalide(
|
||||
$"« {saisie.Isbn} » n'est pas un ISBN valide. Laissez le champ vide si vous ne l'avez pas.");
|
||||
}
|
||||
|
||||
isbn = normalise;
|
||||
}
|
||||
|
||||
souhait.Titre = saisie.Titre;
|
||||
souhait.Auteur = saisie.Auteur;
|
||||
souhait.Editeur = Vide(saisie.Editeur);
|
||||
souhait.Annee = Vide(saisie.Annee);
|
||||
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;
|
||||
|
||||
// ⚠️ 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
|
||||
// se faire sur l'ancienne forme — et le signalement mentirait sans que rien ne le dise.
|
||||
souhait.RecalculerFormes();
|
||||
|
||||
// ⚠️ Une édition peut heurter l'unicité (Utilisateur, TitreNormalise, AuteurNormalise),
|
||||
// contrairement à un ajout que l'utilisateur vient de composer : renommer une envie en
|
||||
// une autre qu'on a déjà est une fausse manœuvre plausible. On répond par un message
|
||||
// plutôt que de laisser remonter « UNIQUE constraint failed », qui ne veut rien dire.
|
||||
//
|
||||
// 400 et non 409, contrairement au doublon du CATALOGUE : là-bas posséder deux
|
||||
// exemplaires est légitime et l'appel se reconfirme, ici l'index l'interdit et il n'y a
|
||||
// rien à confirmer. C'est une saisie à corriger, pas un choix à trancher.
|
||||
var deja = await db.LivresSouhaites.AnyAsync(
|
||||
s => s.Id != souhait.Id
|
||||
&& s.Utilisateur == utilisateur
|
||||
&& s.TitreNormalise == souhait.TitreNormalise
|
||||
&& s.AuteurNormalise == souhait.AuteurNormalise,
|
||||
ct);
|
||||
|
||||
if (deja)
|
||||
{
|
||||
return ResultatSouhait.Invalide(
|
||||
$"« {souhait.Titre} » est déjà dans votre liste d'envies.");
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var possede = await CorrespondancesAsync([souhait], ct);
|
||||
|
||||
return ResultatSouhait.Ok(Projeter(souhait, Correspondant(possede, souhait.Id)));
|
||||
}
|
||||
|
||||
public async Task<bool> SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default)
|
||||
{
|
||||
if (utilisateur is null)
|
||||
|
||||
@@ -131,6 +131,20 @@
|
||||
autocomplete="off" />
|
||||
</label>
|
||||
|
||||
@*
|
||||
⚠️ Le champ reste VIDE quand on ne sait pas : « 0 » se lirait comme une donnée. C'est
|
||||
aussi pourquoi la valeur proposée par la BnF arrive ici et non en base directement —
|
||||
une pagination lue dans une phrase (« 1 vol. (349 p.) ») se corrige d'un geste.
|
||||
*@
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">
|
||||
Nombre de pages <span class="champ-aide">(facultatif)</span>
|
||||
</span>
|
||||
<input class="champ-saisie" type="number" inputmode="numeric" min="1" step="1"
|
||||
@bind="Saisie.NombrePages" @bind:event="oninput"
|
||||
placeholder="349" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">ISBN <span class="champ-aide">(facultatif)</span></span>
|
||||
<input class="champ-saisie" type="text" inputmode="numeric" @bind="Saisie.Isbn" @bind:event="oninput"
|
||||
|
||||
@@ -445,6 +445,10 @@
|
||||
Titre = candidat.Titre,
|
||||
Auteur = candidat.Auteur,
|
||||
Editeur = candidat.Editeur,
|
||||
|
||||
// Proposé, jamais imposé : la notice donne une phrase (« 1 vol. (349 p.) »), le
|
||||
// champ reste modifiable et se vide d'un geste si elle a mal été lue.
|
||||
NombrePages = candidat.NombrePages,
|
||||
CoverUrl = candidat.CoverUrl,
|
||||
UrlNotice = candidat.UrlNotice,
|
||||
Format = Format.Physique,
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
@onclick="() => Basculer(Mode.Isbn)">Par ISBN</button>
|
||||
<button type="button" class="segment @(_mode == Mode.Manuel ? "segment-actif" : null)"
|
||||
@onclick="() => Basculer(Mode.Manuel)">À la main</button>
|
||||
<button type="button" class="segment @(_mode == Mode.Revue ? "segment-actif" : null)"
|
||||
@onclick="() => Basculer(Mode.Revue)">Une revue</button>
|
||||
</div>
|
||||
|
||||
@if (_mode == Mode.Titre)
|
||||
@@ -198,12 +200,57 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_mode == Mode.Revue)
|
||||
{
|
||||
@*
|
||||
⚠️ Aucune recherche ici, et ce n'est pas un manque : l'ISSN désigne un TITRE de
|
||||
périodique, jamais une parution. Interroger la BnF ne dirait donc rien du numéro, qui est
|
||||
justement ce qu'on souhaite. On note ce qu'on a vu en kiosque.
|
||||
*@
|
||||
<p class="message-discret">
|
||||
Une revue entière, ou un numéro précis. Le numéro peut rester vide : « je voudrais
|
||||
m'abonner à Médor » est une envie comme une autre.
|
||||
</p>
|
||||
|
||||
<div class="champ">
|
||||
<label class="champ-libelle" for="titre-revue">Titre de la revue <span class="obligatoire">*</span></label>
|
||||
<input id="titre-revue" class="champ-saisie" type="text" @ref="_premierChamp"
|
||||
@bind="_saisieRevue.Titre" @bind:event="oninput" placeholder="Médor" />
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label class="champ-libelle" for="numero-revue">Numéro</label>
|
||||
<input id="numero-revue" class="champ-saisie" type="text"
|
||||
@bind="_saisieRevue.Numero" @bind:event="oninput" placeholder="43, hors-série 7…" />
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label class="champ-libelle" for="issn-revue">ISSN</label>
|
||||
<input id="issn-revue" class="champ-saisie" type="text"
|
||||
@bind="_saisieRevue.Issn" @bind:event="oninput" placeholder="2466-6718" />
|
||||
</div>
|
||||
|
||||
<div class="champ">
|
||||
<label class="champ-libelle" for="note-revue">Note</label>
|
||||
<input id="note-revue" class="champ-saisie" type="text"
|
||||
@bind="_saisieRevue.Note" @bind:event="oninput" placeholder="« vu chez le dentiste »…" />
|
||||
</div>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_ajout || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisieRevue.Titre))"
|
||||
title="@MotifBlocage" @onclick="AjouterRevueAsync">
|
||||
Ajouter à ma liste
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<nav class="actions-flottantes">
|
||||
<a class="bouton bouton-discret" href="souhaits">Retour à ma liste</a>
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
private enum Mode { Titre, Isbn, Manuel }
|
||||
private enum Mode { Titre, Isbn, Manuel, Revue }
|
||||
|
||||
private Mode _mode = Mode.Titre;
|
||||
private ElementReference _premierChamp;
|
||||
@@ -214,6 +261,7 @@
|
||||
private string _isbn = string.Empty;
|
||||
|
||||
private EnregistrementSouhait _saisie = new();
|
||||
private EnregistrementRevueSouhaitee _saisieRevue = new();
|
||||
private IReadOnlyList<CandidatLivre> _candidats = [];
|
||||
private IReadOnlyList<string> _avertissements = [];
|
||||
private TriResultats _tri = TriResultats.Date;
|
||||
@@ -454,4 +502,22 @@
|
||||
// Retour à la liste : l'envie vient de s'y poser en dernier, et c'est là qu'on la classe.
|
||||
Navigation.NavigateTo("souhaits");
|
||||
}
|
||||
|
||||
private async Task AjouterRevueAsync()
|
||||
{
|
||||
_ajout = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.AjouterRevueSouhaiteeAsync(_saisieRevue);
|
||||
|
||||
_ajout = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
Navigation.NavigateTo("souhaits");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
@page "/souhaits/{Id:int}/edition"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@inject NavigationManager Navigation
|
||||
@implements IDisposable
|
||||
|
||||
@*
|
||||
Écran dédié plutôt qu'un formulaire déplié dans la liste : convention du projet depuis la
|
||||
fiche livre — le mode est dans l'ADRESSE, jamais dans un booléen interne. C'est ce qui rend
|
||||
le bouton « retour » du navigateur une sortie d'édition naturelle, et ce qui empêche de
|
||||
modifier une envie en faisant défiler sa liste.
|
||||
|
||||
⚠️ Cette page ne partage aucun paramètre avec une autre route : contrairement à la fiche
|
||||
livre ou à la fiche série, elle n'a pas besoin de s'abonner à LocationChanged. L'abonnement
|
||||
ci-dessous ne sert qu'à suivre l'état du réseau.
|
||||
*@
|
||||
|
||||
<PageTitle>MaBibli — modifier une envie</PageTitle>
|
||||
|
||||
<h1 class="titre-page">Modifier une envie</h1>
|
||||
|
||||
@if (_erreur is not null)
|
||||
{
|
||||
<p class="message-erreur" role="alert">@_erreur</p>
|
||||
}
|
||||
|
||||
@if (_introuvable)
|
||||
{
|
||||
@* Hors-ligne, « supprimée » et « pas encore enregistrée ici » sont indiscernables : on ne
|
||||
tranche pas à la place de l'utilisateur. Même règle que pour une fiche livre absente. *@
|
||||
<p class="message-discret">
|
||||
@(Reseau.EnLigne
|
||||
? "Cette envie n'existe plus dans votre liste."
|
||||
: "Cette envie n'est pas dans les données enregistrées sur cet appareil.")
|
||||
</p>
|
||||
}
|
||||
else if (_saisie is null)
|
||||
{
|
||||
<Patience Message="Ouverture de l'envie…" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="formulaire">
|
||||
<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"
|
||||
autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Auteur</span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Auteur" @bind:event="oninput"
|
||||
autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Éditeur</span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Editeur" @bind:event="oninput"
|
||||
autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Année</span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Annee" @bind:event="oninput"
|
||||
placeholder="2012" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
@* Le champ de saisie garde la valeur nue : découper à la frappe se battrait avec le
|
||||
curseur. Les tirets sont une affaire d'affichage, pas de saisie. *@
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">ISBN <span class="champ-aide">(facultatif)</span></span>
|
||||
<input class="champ-saisie" type="text" inputmode="numeric"
|
||||
@bind="_saisie.Isbn" @bind:event="oninput" placeholder="978…" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Note</span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput"
|
||||
placeholder="« demandé à Noël », « en poche seulement »…" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
@if (!Reseau.EnLigne)
|
||||
{
|
||||
<p class="message-avertissement" role="status">@EtatReseau.MotifHorsLigne</p>
|
||||
}
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Titre))"
|
||||
title="@MotifBlocage" @onclick="EnregistrerAsync">
|
||||
@(_enCours ? "Enregistrement…" : "Enregistrer")
|
||||
</button>
|
||||
|
||||
<a class="bouton bouton-discret" href="souhaits">Annuler</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
private EnregistrementSouhait? _saisie;
|
||||
private bool _introuvable;
|
||||
private bool _enCours;
|
||||
private string? _erreur;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Reseau.Change += SurChangementReseau;
|
||||
await ChargerAsync();
|
||||
}
|
||||
|
||||
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose() => Reseau.Change -= SurChangementReseau;
|
||||
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
/// <summary>
|
||||
/// Reprend l'envie depuis la <b>liste</b>, et non depuis un point d'entrée dédié.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// L'API n'expose pas <c>GET /api/souhaits/{id}</c>, et il n'y a pas lieu de l'ajouter : la
|
||||
/// liste entière est déjà chargée, déjà mise en instantané, et c'est ce qui rend cet écran
|
||||
/// consultable hors-ligne — même si l'enregistrement, lui, y est refusé.
|
||||
/// </remarks>
|
||||
private async Task ChargerAsync()
|
||||
{
|
||||
var souhaits = await Api.ListerSouhaitsAsync();
|
||||
var souhait = souhaits.FirstOrDefault(s => s.Id == Id);
|
||||
|
||||
if (souhait is null)
|
||||
{
|
||||
_introuvable = true;
|
||||
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.
|
||||
_saisie = new EnregistrementSouhait
|
||||
{
|
||||
Titre = souhait.Titre,
|
||||
Auteur = souhait.Auteur,
|
||||
Editeur = souhait.Editeur,
|
||||
Annee = souhait.Annee,
|
||||
Isbn = souhait.Isbn,
|
||||
Note = souhait.Note,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task EnregistrerAsync()
|
||||
{
|
||||
if (_saisie is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.ModifierSouhaitAsync(Id, _saisie);
|
||||
|
||||
_enCours = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
// Le message vient de l'API : doublon d'une autre envie, ISBN invalide, ou hors
|
||||
// ligne. Aucun code HTTP brut n'arrive jusqu'ici.
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
Navigation.NavigateTo("souhaits");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
@page "/souhaits/revues/{Id:int}/edition"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@inject NavigationManager Navigation
|
||||
@implements IDisposable
|
||||
|
||||
@*
|
||||
Le même geste que pour une envie de livre, sur la table sœur : écran dédié, mode dans
|
||||
l'adresse. Les deux formulaires ne se fondent pas en un seul — une revue n'a ni auteur, ni
|
||||
éditeur, ni ISBN, et un formulaire à moitié grisé se lit plus mal que deux formulaires courts.
|
||||
*@
|
||||
|
||||
<PageTitle>MaBibli — modifier une envie de revue</PageTitle>
|
||||
|
||||
<h1 class="titre-page">Modifier une envie de revue</h1>
|
||||
|
||||
@if (_erreur is not null)
|
||||
{
|
||||
<p class="message-erreur" role="alert">@_erreur</p>
|
||||
}
|
||||
|
||||
@if (_introuvable)
|
||||
{
|
||||
<p class="message-discret">
|
||||
@(Reseau.EnLigne
|
||||
? "Cette envie n'existe plus dans votre liste."
|
||||
: "Cette envie n'est pas dans les données enregistrées sur cet appareil.")
|
||||
</p>
|
||||
}
|
||||
else if (_saisie is null)
|
||||
{
|
||||
<Patience Message="Ouverture de l'envie…" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="formulaire">
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Titre de la revue <span class="obligatoire" aria-hidden="true">*</span></span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Titre" @bind:event="oninput"
|
||||
placeholder="Médor" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">
|
||||
Numéro <span class="champ-aide">(vide = la revue entière)</span>
|
||||
</span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Numero" @bind:event="oninput"
|
||||
placeholder="43, hors-série 7…" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
@* La saisie garde ce qui est tapé : le tiret est posé à l'enregistrement, jamais à la
|
||||
frappe — sinon il se battrait avec le curseur. *@
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">ISSN <span class="champ-aide">(facultatif)</span></span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Issn" @bind:event="oninput"
|
||||
placeholder="2466-6718" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<label class="champ">
|
||||
<span class="champ-libelle">Note</span>
|
||||
<input class="champ-saisie" type="text" @bind="_saisie.Note" @bind:event="oninput"
|
||||
placeholder="« vu chez le dentiste »…" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
@if (!Reseau.EnLigne)
|
||||
{
|
||||
<p class="message-avertissement" role="status">@EtatReseau.MotifHorsLigne</p>
|
||||
}
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Titre))"
|
||||
title="@MotifBlocage" @onclick="EnregistrerAsync">
|
||||
@(_enCours ? "Enregistrement…" : "Enregistrer")
|
||||
</button>
|
||||
|
||||
<a class="bouton bouton-discret" href="souhaits">Annuler</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
private EnregistrementRevueSouhaitee? _saisie;
|
||||
private bool _introuvable;
|
||||
private bool _enCours;
|
||||
private string? _erreur;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
Reseau.Change += SurChangementReseau;
|
||||
await ChargerAsync();
|
||||
}
|
||||
|
||||
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose() => Reseau.Change -= SurChangementReseau;
|
||||
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
private async Task ChargerAsync()
|
||||
{
|
||||
var envies = await Api.ListerRevuesSouhaiteesAsync();
|
||||
var envie = envies.FirstOrDefault(e => e.Id == Id);
|
||||
|
||||
if (envie is null)
|
||||
{
|
||||
_introuvable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_saisie = new EnregistrementRevueSouhaitee
|
||||
{
|
||||
Titre = envie.Titre,
|
||||
Numero = envie.Numero,
|
||||
Issn = envie.Issn,
|
||||
Note = envie.Note,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task EnregistrerAsync()
|
||||
{
|
||||
if (_saisie is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
|
||||
var resultat = await Api.ModifierRevueSouhaiteeAsync(Id, _saisie);
|
||||
|
||||
_enCours = false;
|
||||
|
||||
if (!resultat.EstOk)
|
||||
{
|
||||
_erreur = resultat.Erreur;
|
||||
return;
|
||||
}
|
||||
|
||||
Navigation.NavigateTo("souhaits");
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,16 @@ else
|
||||
</div>
|
||||
}
|
||||
|
||||
@*
|
||||
Même règle que le format et le type de document : la ligne ne s'affiche que
|
||||
lorsqu'elle apprend quelque chose. « Nombre de pages : inconnu » sur presque
|
||||
chaque fiche n'apprendrait rien et allongerait toutes les autres.
|
||||
*@
|
||||
@if (_livre.NombrePages is { } pages)
|
||||
{
|
||||
<div><dt>Pages</dt><dd>@pages</dd></div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_livre.UrlNotice))
|
||||
{
|
||||
<div><dt>Source</dt><dd><a href="@_livre.UrlNotice" target="_blank" rel="noopener noreferrer">Voir la notice source</a></dd></div>
|
||||
@@ -345,6 +355,7 @@ else
|
||||
Editeur = livre.Editeur,
|
||||
Format = livre.Format,
|
||||
TypeDocument = livre.TypeDocument,
|
||||
NombrePages = livre.NombrePages,
|
||||
Themes = livre.Themes.ToList(),
|
||||
Statut = livre.Statut,
|
||||
CoverUrl = livre.CoverUrl,
|
||||
|
||||
@@ -876,6 +876,7 @@ else
|
||||
Titre = candidat.Titre,
|
||||
Auteurs = candidat.Auteurs.Select(a => new AuteurSaisi(a)).ToList(),
|
||||
Editeur = candidat.Editeur,
|
||||
NombrePages = candidat.NombrePages,
|
||||
CoverUrl = candidat.CoverUrl,
|
||||
UrlNotice = candidat.UrlNotice,
|
||||
};
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
@* Le rappel n'est pas décoratif : la bibliothèque est commune, cette liste ne l'est pas.
|
||||
Sans ce mot, on pourrait croire que le foyer entier voit ce qu'on note ici. *@
|
||||
<p class="message-discret">
|
||||
Les livres que vous aimeriez acquérir. <strong>Cette liste est la vôtre</strong> :
|
||||
Les livres et les revues que vous aimeriez acquérir. <strong>Cette liste est la vôtre</strong> :
|
||||
les autres membres du foyer ont la leur, et ne voient pas celle-ci.
|
||||
Ces livres ne sont pas dans le catalogue et ne comptent pas dans la bibliothèque.
|
||||
Rien de ce qui est ici n'est dans le catalogue, ni ne compte dans la bibliothèque.
|
||||
</p>
|
||||
|
||||
@if (_erreur is not null)
|
||||
@@ -28,7 +28,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_souhaits.Count == 0)
|
||||
@if (_souhaits.Count == 0 && _revues.Count == 0)
|
||||
{
|
||||
@* Hors-ligne, « vide » et « jamais enregistrée » sont indiscernables : une liste jamais
|
||||
synchronisée rendrait exactement le même résultat qu'une liste réellement vide. On dit
|
||||
@@ -54,7 +54,7 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="compteur">@_souhaits.Count livre@(_souhaits.Count > 1 ? "s" : "") souhaité@(_souhaits.Count > 1 ? "s" : "")</p>
|
||||
<p class="compteur">@Compteur()</p>
|
||||
|
||||
@* Un seul bloc d'export, en haut de liste : c'est l'action qu'on vient chercher quand on
|
||||
part en librairie, elle ne doit pas être au bout d'un défilement. *@
|
||||
@@ -107,6 +107,9 @@ else
|
||||
HTML5 ne fonctionne pas au doigt sur mobile. Sans elles, réordonner n'existerait
|
||||
que sur PC — c'est-à-dire pas là où l'on consulte sa liste en librairie.
|
||||
*@
|
||||
@* Une liste vide n'est pas rendue : on peut n'avoir que des revues en envie. *@
|
||||
@if (_souhaits.Count > 0)
|
||||
{
|
||||
<ul class="liste-souhaits">
|
||||
@foreach (var (souhait, index) in _souhaits.Select((s, i) => (s, i)))
|
||||
{
|
||||
@@ -184,19 +187,83 @@ else
|
||||
}
|
||||
</div>
|
||||
|
||||
@*
|
||||
« Éditer » AU-DESSUS de « Retirer » : les deux gestes se ressemblent, et
|
||||
celui qui répare doit se présenter avant celui qui détruit.
|
||||
⚠️ Un lien, et non un bouton désactivé hors-ligne : la NAVIGATION reste
|
||||
toujours possible, c'est l'écriture qui se refuse — et l'écran d'édition
|
||||
le dit lui-même, avec son motif.
|
||||
*@
|
||||
@if (!ModeOrdre)
|
||||
{
|
||||
<div class="souhait-actions">
|
||||
<a class="bouton bouton-compact" href="@($"souhaits/{souhait.Id}/edition")"
|
||||
aria-label="@($"Éditer « {souhait.Titre} »")">
|
||||
Éditer
|
||||
</a>
|
||||
|
||||
<button type="button" class="bouton bouton-compact bouton-danger"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="() => SupprimerAsync(souhait)"
|
||||
aria-label="@($"Retirer « {souhait.Titre} » de la liste d'envies")">
|
||||
Retirer
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
@*
|
||||
⚠️ Une SECTION à part, et non des lignes mêlées aux livres : les deux tables numérotent
|
||||
leur rang indépendamment (voir RevueSouhaitee.Rang), donc un classement commun afficherait
|
||||
un ordre que personne n'a choisi. C'est le coût assumé de la table sœur — mais la liste
|
||||
les porte bien toutes les deux, y compris hors-ligne, ce qui est le seul point qui compte
|
||||
au kiosque.
|
||||
*@
|
||||
@if (_revues.Count > 0 && !ModeOrdre)
|
||||
{
|
||||
<h2 class="titre-section">Revues et magazines</h2>
|
||||
|
||||
<ul class="liste-souhaits">
|
||||
@foreach (var revue in _revues)
|
||||
{
|
||||
<li class="carte-souhait" @key="revue">
|
||||
<div class="carte-corps">
|
||||
<p class="carte-titre">@revue.TitreComplet</p>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(revue.Issn))
|
||||
{
|
||||
<p class="carte-details">
|
||||
<span class="etiquette code-isbn">ISSN @revue.Issn</span>
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(revue.Note))
|
||||
{
|
||||
<p class="souhait-note">@revue.Note</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="souhait-actions">
|
||||
<a class="bouton bouton-compact" href="@($"souhaits/revues/{revue.Id}/edition")"
|
||||
aria-label="@($"Éditer « {revue.TitreComplet} »")">
|
||||
Éditer
|
||||
</a>
|
||||
|
||||
<button type="button" class="bouton bouton-compact bouton-danger"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="() => SupprimerRevueAsync(revue)"
|
||||
aria-label="@($"Retirer « {revue.TitreComplet} » de la liste d'envies")">
|
||||
Retirer
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
@* L'ajout est passé dans son propre écran : la page ne montre plus que la liste, comme la
|
||||
@@ -223,6 +290,7 @@ else
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<SouhaitDto>? _souhaits;
|
||||
private IReadOnlyList<RevueSouhaiteeDto> _revues = [];
|
||||
private bool _enCours;
|
||||
private int? _glisse;
|
||||
private string? _erreur;
|
||||
@@ -276,6 +344,10 @@ else
|
||||
{
|
||||
_erreur = null;
|
||||
_souhaits = await Api.ListerSouhaitsAsync();
|
||||
|
||||
// Les deux moitiés se chargent ensemble : une liste d'envies amputée de ses revues
|
||||
// serait fausse sans le dire.
|
||||
_revues = await Api.ListerRevuesSouhaiteesAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
@@ -354,6 +426,36 @@ else
|
||||
|
||||
private void TerminerGlisse() => _glisse = null;
|
||||
|
||||
/// <summary>« 3 livres et 1 revue », sans jamais nommer une section vide.</summary>
|
||||
private string Compteur()
|
||||
{
|
||||
var morceaux = new List<string>();
|
||||
|
||||
if (_souhaits is { Count: > 0 })
|
||||
{
|
||||
morceaux.Add($"{_souhaits.Count} livre{(_souhaits.Count > 1 ? "s" : "")}");
|
||||
}
|
||||
|
||||
if (_revues.Count > 0)
|
||||
{
|
||||
morceaux.Add($"{_revues.Count} revue{(_revues.Count > 1 ? "s" : "")}");
|
||||
}
|
||||
|
||||
return string.Join(" et ", morceaux) + " en envie";
|
||||
}
|
||||
|
||||
private async Task SupprimerRevueAsync(RevueSouhaiteeDto revue)
|
||||
{
|
||||
_enCours = true;
|
||||
_erreur = await Api.SupprimerRevueSouhaiteeAsync(revue.Id);
|
||||
_enCours = false;
|
||||
|
||||
if (_erreur is null)
|
||||
{
|
||||
await ChargerAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SupprimerAsync(SouhaitDto souhait)
|
||||
{
|
||||
_enCours = true;
|
||||
|
||||
@@ -35,6 +35,17 @@ public static class ClesCache
|
||||
/// </remarks>
|
||||
public const string Souhaits = "souhaits";
|
||||
|
||||
/// <summary>
|
||||
/// Les revues et numéros souhaités : la <b>seconde moitié</b> de la liste d'envies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Un instantané à part parce que l'API en fait une vue à part (table sœur), pas parce
|
||||
/// que l'écran en ferait deux : la liste d'envies est le seul écran qu'on emporte
|
||||
/// délibérément là où le réseau manque, et une moitié absente en librairie ne servirait à
|
||||
/// rien. Personnel, comme <see cref="Souhaits"/> — jamais paramétré par utilisateur.
|
||||
/// </remarks>
|
||||
public const string SouhaitsRevues = "souhaits-revues";
|
||||
|
||||
/// <summary>
|
||||
/// Toutes les séries, à plat, avec leurs tomes.
|
||||
/// </summary>
|
||||
|
||||
@@ -49,7 +49,11 @@ public static class RemonteeRoutes
|
||||
("/series/{id}", "/series"),
|
||||
("/series", Racine),
|
||||
|
||||
// Envies : l'ordre et l'ajout sont deux écrans de la même liste.
|
||||
// Envies : l'ordre, l'ajout et les deux écrans d'édition sont des écrans de la même
|
||||
// liste. ⚠️ « /souhaits/revues/{id}/edition » ne peut pas se confondre avec
|
||||
// « /souhaits/{id}/edition » : les modèles n'ont pas le même nombre de segments.
|
||||
("/souhaits/revues/{id}/edition", "/souhaits"),
|
||||
("/souhaits/{id}/edition", "/souhaits"),
|
||||
("/souhaits/ordre", "/souhaits"),
|
||||
("/souhaits/ajout", "/souhaits"),
|
||||
("/souhaits", Racine),
|
||||
|
||||
@@ -49,6 +49,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
|
||||
await ListerAuteursAsync(ct);
|
||||
await ListerPretsEnCoursAsync(ct);
|
||||
await ListerSouhaitsAsync(ct);
|
||||
await ListerRevuesSouhaiteesAsync(ct);
|
||||
await ListerSeriesAsync(ct);
|
||||
await ListerRevuesAsync(ct);
|
||||
await ObtenirUtilisateurAsync(ct);
|
||||
@@ -561,6 +562,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
|
||||
await ListerPretsEnCoursAsync();
|
||||
await ListerAuteursAsync();
|
||||
await ListerSouhaitsAsync();
|
||||
await ListerRevuesSouhaiteesAsync();
|
||||
await ListerSeriesAsync();
|
||||
await ListerRevuesAsync();
|
||||
}
|
||||
@@ -657,6 +659,84 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
|
||||
return instantane?.Donnees ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Corrige une envie. Passe par <see cref="EcrireAsync{T}"/> : la réponse porte l'envie
|
||||
/// modifiée, donc il y a bien quelque chose à désérialiser — contrairement au 204 de la
|
||||
/// suppression.
|
||||
/// </summary>
|
||||
public Task<ResultatApi<SouhaitDto>> ModifierSouhaitAsync(
|
||||
int id, EnregistrementSouhait saisie, CancellationToken ct = default) =>
|
||||
EcrireAsync<SouhaitDto>(
|
||||
() => http.PutAsJsonAsync($"api/souhaits/{id}", saisie, Json, ct), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Les revues et numéros souhaités <b>de l'utilisateur courant</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Second instantané de la liste d'envies, et il n'est pas facultatif : c'est l'écran qu'on
|
||||
/// emporte en librairie ou au kiosque, donc là où le réseau manque.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<RevueSouhaiteeDto>> ListerRevuesSouhaiteesAsync(
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var (ok, envies) = await EssayerAsync(
|
||||
() => http.GetFromJsonAsync<List<RevueSouhaiteeDto>>("api/souhaits/revues", Json, ct));
|
||||
|
||||
if (ok)
|
||||
{
|
||||
await MemoriserAsync(ClesCache.SouhaitsRevues, envies ?? []);
|
||||
return envies ?? [];
|
||||
}
|
||||
|
||||
var instantane = await LireCacheAsync<List<RevueSouhaiteeDto>>(ClesCache.SouhaitsRevues);
|
||||
return instantane?.Donnees ?? [];
|
||||
}
|
||||
|
||||
public Task<ResultatApi<RevueSouhaiteeDto>> AjouterRevueSouhaiteeAsync(
|
||||
EnregistrementRevueSouhaitee saisie, CancellationToken ct = default) =>
|
||||
EcrireAsync<RevueSouhaiteeDto>(
|
||||
() => http.PostAsJsonAsync("api/souhaits/revues", saisie, Json, ct), ct);
|
||||
|
||||
public Task<ResultatApi<RevueSouhaiteeDto>> ModifierRevueSouhaiteeAsync(
|
||||
int id, EnregistrementRevueSouhaitee saisie, CancellationToken ct = default) =>
|
||||
EcrireAsync<RevueSouhaiteeDto>(
|
||||
() => http.PutAsJsonAsync($"api/souhaits/revues/{id}", saisie, Json, ct), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Retire une revue souhaitée. <c>null</c> si c'est fait, sinon le motif à afficher.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Comme la suppression d'une envie de livre, elle répond <c>204 No Content</c> : la
|
||||
/// passer par <see cref="EcrireAsync{T}"/> lèverait une exception sur un corps vide, là où
|
||||
/// tout s'est bien passé.
|
||||
/// </remarks>
|
||||
public async Task<string?> SupprimerRevueSouhaiteeAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
if (!reseau.EnLigne)
|
||||
{
|
||||
return EtatReseau.MotifHorsLigne;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var reponse = await http.DeleteAsync($"api/souhaits/revues/{id}", ct);
|
||||
reseau.SignalerSuccesReseau();
|
||||
|
||||
if (!reponse.IsSuccessStatusCode)
|
||||
{
|
||||
return await MessageErreurAsync(reponse, ct);
|
||||
}
|
||||
|
||||
_ = RafraichirEnFondAsync();
|
||||
return null;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
reseau.SignalerEchecReseau();
|
||||
return EtatReseau.MotifHorsLigne;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultatApi<SouhaitDto>> AjouterSouhaitAsync(
|
||||
EnregistrementSouhait saisie, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -984,6 +984,17 @@ body {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* Les deux actions d'une envie, empilées : « Éditer » au-dessus de « Retirer ».
|
||||
Elles ne s'étalent pas en largeur — sur un téléphone, la colonne d'actions doit rester
|
||||
étroite pour laisser le titre respirer. */
|
||||
.souhait-actions {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.souhait-note {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--mb-texte-doux);
|
||||
|
||||
@@ -50,6 +50,17 @@ public record CandidatLivre
|
||||
/// <summary>Code langue tel que fourni par la source (« fre », « eng »…).</summary>
|
||||
public string? Langue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Nombre de pages lu dans la description matérielle de la notice, quand elle en donne un.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b>Une proposition, jamais une certitude</b> : la BnF n'expose pas un nombre de pages
|
||||
/// mais une phrase (<c>dc:format</c> : « 1 vol. (349 p.) », « 30 cm », « 2 disques
|
||||
/// compacts »). Tout ce que le nettoyage ne comprend pas reste <c>null</c> — mieux vaut un
|
||||
/// champ vide qu'un chiffre inventé.
|
||||
/// </remarks>
|
||||
public int? NombrePages { get; init; }
|
||||
|
||||
/// <summary>URL de la couverture chez OpenLibrary, toujours renseignée (voir remarques du service).</summary>
|
||||
public string? CoverUrl { get; init; }
|
||||
|
||||
|
||||
@@ -47,6 +47,15 @@ public record EnregistrementLivre
|
||||
/// </summary>
|
||||
public Statut? Statut { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Nombre de pages. <c>null</c> = non renseigné, et surtout pas <c>0</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Peut arriver prérempli depuis <c>dc:format</c> de la BnF ; c'est alors une
|
||||
/// <b>proposition</b>, que la saisie corrige ou efface.
|
||||
/// </remarks>
|
||||
public int? NombrePages { get; set; }
|
||||
|
||||
public string? CoverUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -58,6 +58,10 @@ public record LivreDto
|
||||
/// </remarks>
|
||||
public Statut? Statut { get; init; }
|
||||
|
||||
/// <summary>Nombre de pages, ou <c>null</c> s'il n'a jamais été renseigné.</summary>
|
||||
/// <remarks>⚠️ Jamais <c>0</c> pour dire « inconnu » : l'interface ne l'affiche que renseigné.</remarks>
|
||||
public int? NombrePages { get; init; }
|
||||
|
||||
public string? CoverUrl { get; init; }
|
||||
|
||||
public string? UrlNotice { get; init; }
|
||||
|
||||
@@ -68,6 +68,55 @@ public record EnregistrementSouhait
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Une envie de revue — ou de numéro précis — telle qu'exposée par l'API.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Comme <see cref="SouhaitDto"/>, <b>aucun champ « utilisateur »</b> : l'API ne rend que les
|
||||
/// envies de l'appelant.
|
||||
/// <para>
|
||||
/// Il n'y a volontairement <b>ni « Possede » ni « RevueId »</b>, contrairement aux envies de
|
||||
/// livres : le rapprochement avec les revues possédées supposerait de décider ce que vaut
|
||||
/// « j'ai la revue mais pas ce numéro », et un signalement faux sur une liste d'envies est
|
||||
/// exactement ce qu'on cherche à éviter. Rien n'empêchera de l'ajouter le jour où le besoin
|
||||
/// sera constaté.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public record RevueSouhaiteeDto
|
||||
{
|
||||
public required int Id { get; init; }
|
||||
|
||||
public required string Titre { get; init; }
|
||||
|
||||
/// <summary><c>null</c> = la revue entière, pas un numéro particulier.</summary>
|
||||
public string? Numero { get; init; }
|
||||
|
||||
/// <summary>ISSN sous sa forme à tiret, la seule rangée en base.</summary>
|
||||
public string? Issn { get; init; }
|
||||
|
||||
public string? Note { get; init; }
|
||||
|
||||
public required DateTime DateAjout { get; init; }
|
||||
|
||||
/// <summary>Titre suivi de son numéro, tel qu'on l'épelle à un kiosquier.</summary>
|
||||
[JsonIgnore]
|
||||
public string TitreComplet =>
|
||||
string.IsNullOrWhiteSpace(Numero) ? Titre : $"{Titre} n° {Numero}";
|
||||
}
|
||||
|
||||
/// <summary>Charge utile d'ajout ou de modification d'une envie de revue.</summary>
|
||||
/// <remarks>Sans identité : le propriétaire est déterminé par le serveur.</remarks>
|
||||
public record EnregistrementRevueSouhaitee
|
||||
{
|
||||
public string Titre { get; set; } = string.Empty;
|
||||
|
||||
public string? Numero { get; set; }
|
||||
|
||||
public string? Issn { get; set; }
|
||||
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Charge utile du masquage personnel d'une œuvre bibliographique.</summary>
|
||||
public record MasquageBibliographie
|
||||
{
|
||||
|
||||
@@ -37,6 +37,21 @@ public class Livre
|
||||
/// </remarks>
|
||||
public TypeDocument TypeDocument { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Nombre de pages, <b>facultatif</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <c>null</c> et non <c>0</c> : aucun livre déjà catalogué ne porte cette information,
|
||||
/// et « 0 page » se lirait comme une donnée alors que c'est une absence. Même famille de
|
||||
/// piège que <see cref="TypeDocument.NonPrecise"/>, dont le défaut ne prétend rien.
|
||||
/// <para>
|
||||
/// Il peut être proposé par la BnF (<c>dc:format</c> : « 1 vol. (349 p.) »), mais il reste
|
||||
/// <b>modifiable</b> et n'est jamais imposé. Rien n'est déduit pour un ebook, dont la
|
||||
/// pagination dépend de la police.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public int? NombrePages { get; set; }
|
||||
|
||||
/// <summary>URL de la couverture (OpenLibrary), pas de fichier stocké localement.</summary>
|
||||
public string? CoverUrl { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using MaBibli.Shared.Isbn;
|
||||
using MaBibli.Shared.Textes;
|
||||
|
||||
namespace MaBibli.Shared.Entites;
|
||||
|
||||
/// <summary>
|
||||
/// Une revue — ou un numéro précis — que l'on aimerait acquérir. <b>Personnelle</b>, comme
|
||||
/// <see cref="LivreSouhaite"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Pourquoi une table sœur plutôt que des colonnes de plus sur <see cref="LivreSouhaite"/> ?</b>
|
||||
/// Une envie de livre est identifiée par un couple (œuvre, auteur) : c'est ce que porte son index
|
||||
/// unique, et c'est ce qui permet de la rapprocher du catalogue. Un numéro de revue n'a
|
||||
/// <b>pas d'auteur</b> et se distingue par son <i>numéro</i> — deux notions que la clé de
|
||||
/// <c>LivreSouhaite</c> ne sait pas exprimer sans devenir fausse pour tout le monde. Y loger les
|
||||
/// revues aurait obligé à écrire « et qui n'est pas une revue » à chaque rapprochement, à chaque
|
||||
/// export et à chaque compteur : c'est exactement le raisonnement qui a séparé
|
||||
/// <see cref="Revue"/> de <see cref="Livre"/>.
|
||||
/// <para>
|
||||
/// ⚠️ <b>Le coût est assumé et il est réel</b> : deux tables se fusionnent à l'affichage, dans
|
||||
/// les deux exports et dans l'instantané hors-ligne. C'est là que le sujet se rate s'il est
|
||||
/// bâclé — une envie de revue invisible en librairie ne sert à rien.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Utilisateur"/> est une <b>frontière</b>, pas une trace : aucun point d'entrée
|
||||
/// n'accepte de nom d'utilisateur, et l'envie d'un autre répond 404, jamais 403.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class RevueSouhaitee
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>YNH_USER</c> du propriétaire de l'envie. <b>Frontière</b> : toute lecture filtre dessus.
|
||||
/// </summary>
|
||||
public string Utilisateur { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Titre de la revue (« Médor »), sans son numéro.</summary>
|
||||
public string Titre { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Titre mis à plat. Sert au dédoublonnage.</summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <see cref="NormalisationTexte"/> et non <c>CleOeuvre</c> : le titre d'une revue n'a ni
|
||||
/// mention de responsabilité ni sous-titre à couper, et « Médor : les yeux ouverts » est une
|
||||
/// accroche qu'on ne tape pas. C'est la même règle que <see cref="Revue.TitreNormalise"/>.
|
||||
/// </remarks>
|
||||
public string TitreNormalise { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Numéro souhaité (« 43 », « hors-série 7 »), ou <c>null</c> pour la revue entière.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Facultatif, et c'est la moitié du besoin : on souhaite parfois un numéro précis vu en
|
||||
/// kiosque, parfois l'abonnement à un titre qu'on ne possède pas encore.
|
||||
/// </remarks>
|
||||
public string? Numero { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Numéro mis à plat. <b>Jamais <c>null</c></b> — chaîne vide pour « la revue entière ».
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Même piège que <see cref="LivreSouhaite.AuteurNormalise"/> : SQLite tient deux
|
||||
/// <c>NULL</c> pour distincts, et l'unicité laisserait alors passer autant de fois « Médor,
|
||||
/// pas de numéro » qu'on voudrait.
|
||||
/// </remarks>
|
||||
public string NumeroNormalise { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// ISSN, facultatif, <b>canonisé avec son tiret</b> (« 2466-6718 »).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C'est la forme rangée en base pour tout le projet — seule exception à « la valeur stockée
|
||||
/// reste nue », l'ISSN étant produit à tiret par le code-barres et interrogé à tiret par la
|
||||
/// BnF. Sans canonisation, un ISSN tapé « 24666718 » ne se rapprocherait de rien.
|
||||
/// </remarks>
|
||||
public string? Issn { get; set; }
|
||||
|
||||
/// <summary>Note libre : « vu chez le dentiste », « demander au kiosque ».</summary>
|
||||
public string? Note { get; set; }
|
||||
|
||||
public DateTime DateAjout { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rang dans la section « revues » de la liste, du plus désiré au moins désiré.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Cette numérotation est <b>indépendante</b> de celle de <see cref="LivreSouhaite.Rang"/> :
|
||||
/// chaque table repart de zéro par utilisateur. C'est précisément pourquoi la liste affiche
|
||||
/// deux sections plutôt qu'un seul classement entrelacé — deux suites de rangs sans rapport
|
||||
/// mélangées produiraient un ordre que personne n'a choisi.
|
||||
/// </remarks>
|
||||
public int Rang { get; set; }
|
||||
|
||||
public void RecalculerFormes()
|
||||
{
|
||||
Titre = Titre.Trim();
|
||||
TitreNormalise = NormalisationTexte.Normaliser(Titre);
|
||||
|
||||
Numero = string.IsNullOrWhiteSpace(Numero) ? null : Numero.Trim();
|
||||
NumeroNormalise = Numero is null ? string.Empty : NormalisationTexte.Normaliser(Numero);
|
||||
|
||||
// Canonique() rend la saisie élaguée quand ce n'est pas un ISSN : un code mal recopié
|
||||
// est conservé tel quel plutôt que jeté — le voir mal formé vaut mieux que le voir
|
||||
// disparaître.
|
||||
Issn = FormatageIssn.Canonique(Issn);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,27 @@ public class BnfSruParserTests
|
||||
Assert.Equal($"https://catalogue.bnf.fr/{c.IdentifiantSource}", c.UrlNotice);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Le nombre de pages est lu dans <c>dc:format</c>, qui décrit le support en toutes lettres.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ Il est <b>proposé</b>, jamais imposé : la valeur traverse le candidat jusqu'à un champ
|
||||
/// modifiable. Les deux formes du catalogue sont représentées ici — « 1 vol. (113 p.) … »
|
||||
/// et « 503 p. … » —, et les dimensions qui les suivent (« ; 18 cm ») ne doivent surtout pas
|
||||
/// être prises pour une pagination.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void Le_nombre_de_pages_est_lu_dans_la_description_materielle()
|
||||
{
|
||||
var petitPrince = BnfSruParser.Parser(
|
||||
Fixture.Lire("bnf-9782070612758.xml"), "9782070612758", null);
|
||||
Assert.Equal(113, Assert.Single(petitPrince).NombrePages);
|
||||
|
||||
var germinal = BnfSruParser.Parser(
|
||||
Fixture.Lire("bnf-2253004227.xml"), "2253004227", null);
|
||||
Assert.Equal([503, 605, 538], germinal.Select(c => c.NombrePages));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Germinal_renvoie_les_trois_reeditions_distinctes()
|
||||
{
|
||||
|
||||
@@ -35,6 +35,7 @@ public class ExportSouhaitsTests
|
||||
Souhait(2, "Germinal", "Émile Zola"),
|
||||
Souhait(3, "Les fourmis", "Bernard Werber"),
|
||||
],
|
||||
[],
|
||||
"Mathieu",
|
||||
Jour);
|
||||
|
||||
@@ -60,6 +61,7 @@ public class ExportSouhaitsTests
|
||||
{
|
||||
var texte = ExportSouhaits.Texte(
|
||||
[Souhait(1, "Titre orphelin"), Souhait(2, "Germinal", "Émile Zola")],
|
||||
[],
|
||||
null,
|
||||
Jour);
|
||||
|
||||
@@ -72,6 +74,7 @@ public class ExportSouhaitsTests
|
||||
{
|
||||
var texte = ExportSouhaits.Texte(
|
||||
[Souhait(1, "Germinal", "Émile Zola", "Le Livre de poche", "1885", "2253004227", "en poche seulement")],
|
||||
[],
|
||||
null,
|
||||
Jour);
|
||||
|
||||
@@ -90,7 +93,7 @@ public class ExportSouhaitsTests
|
||||
public void Le_csv_decoupe_lIsbn_pour_quExcel_ne_le_prenne_pas_pour_un_nombre()
|
||||
{
|
||||
var csv = ExportSouhaits.Csv(
|
||||
[Souhait(1, "Germinal", "Émile Zola", "Le Livre de poche", "1885", "9782253004226")]);
|
||||
[Souhait(1, "Germinal", "Émile Zola", "Le Livre de poche", "1885", "9782253004226")], []);
|
||||
|
||||
Assert.Contains("978-2-253-00422-6", csv);
|
||||
Assert.DoesNotContain("9782253004226", csv);
|
||||
@@ -99,20 +102,20 @@ public class ExportSouhaitsTests
|
||||
[Fact]
|
||||
public void Le_texte_le_dit_quand_la_liste_est_vide()
|
||||
{
|
||||
var texte = ExportSouhaits.Texte([], "Mathieu", Jour);
|
||||
var texte = ExportSouhaits.Texte([], [], "Mathieu", Jour);
|
||||
|
||||
Assert.Contains("(aucun livre dans la liste)", texte);
|
||||
Assert.Contains("(aucune envie dans la liste)", texte);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Le_csv_porte_un_en_tete_et_une_ligne_par_envie()
|
||||
{
|
||||
var csv = ExportSouhaits.Csv([Souhait(1, "Germinal", "Émile Zola", "Le Livre de poche", "1885")]);
|
||||
var csv = ExportSouhaits.Csv([Souhait(1, "Germinal", "Émile Zola", "Le Livre de poche", "1885")], []);
|
||||
|
||||
var lignes = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
Assert.Equal("Rang;Titre;Auteur;Éditeur;Année;ISBN;Note;Ajouté le", lignes[0]);
|
||||
Assert.Equal("1;Germinal;Émile Zola;Le Livre de poche;1885;;;2026-08-18", lignes[1]);
|
||||
Assert.Equal("Rang;Type;Titre;Auteur;Éditeur;Année;ISBN / ISSN;Note;Ajouté le", lignes[0]);
|
||||
Assert.Equal("1;Livre;Germinal;Émile Zola;Le Livre de poche;1885;;;2026-08-18", lignes[1]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -123,15 +126,15 @@ public class ExportSouhaitsTests
|
||||
public void Le_csv_echappe_le_separateur_et_les_guillemets()
|
||||
{
|
||||
var csv = ExportSouhaits.Csv(
|
||||
[Souhait(1, "Titre; piégé", note: "il a dit \"oui\"")]);
|
||||
[Souhait(1, "Titre; piégé", note: "il a dit \"oui\"")], []);
|
||||
|
||||
var ligne = csv.Split("\r\n")[1];
|
||||
|
||||
Assert.StartsWith("1;\"Titre; piégé\";", ligne);
|
||||
Assert.StartsWith("1;Livre;\"Titre; piégé\";", ligne);
|
||||
Assert.Contains("\"il a dit \"\"oui\"\"\"", ligne);
|
||||
|
||||
// Huit colonnes une fois l'échappement respecté, pas neuf.
|
||||
Assert.Equal(8, ColonnesCsv(ligne).Count);
|
||||
// Neuf colonnes une fois l'échappement respecté, pas dix.
|
||||
Assert.Equal(9, ColonnesCsv(ligne).Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -148,20 +151,20 @@ public class ExportSouhaitsTests
|
||||
Souhait(3, "Abécédaire", "Émile Zola"),
|
||||
];
|
||||
|
||||
var texte = ExportSouhaits.Texte(liste, null, Jour);
|
||||
var texte = ExportSouhaits.Texte(liste, [], null, Jour);
|
||||
Assert.Contains(" - [1] Zorro", texte);
|
||||
Assert.Contains(" - [2] Abécédaire", texte);
|
||||
|
||||
// Le CSV, lui, garde l'ordre : le rang y est croissant ligne après ligne.
|
||||
var lignes = ExportSouhaits.Csv(liste).Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.StartsWith("1;Zorro;", lignes[1]);
|
||||
Assert.StartsWith("2;Abécédaire;", lignes[2]);
|
||||
var lignes = ExportSouhaits.Csv(liste, []).Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.StartsWith("1;Livre;Zorro;", lignes[1]);
|
||||
Assert.StartsWith("2;Livre;Abécédaire;", lignes[2]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Le_csv_echappe_les_sauts_de_ligne()
|
||||
{
|
||||
var csv = ExportSouhaits.Csv([Souhait(1, "Titre", note: "ligne 1\nligne 2")]);
|
||||
var csv = ExportSouhaits.Csv([Souhait(1, "Titre", note: "ligne 1\nligne 2")], []);
|
||||
|
||||
Assert.Contains("\"ligne 1\nligne 2\"", csv);
|
||||
}
|
||||
@@ -173,7 +176,7 @@ public class ExportSouhaitsTests
|
||||
public void Le_bom_precede_le_csv()
|
||||
{
|
||||
var octets = ExportSouhaits.Bom
|
||||
.Concat(Encoding.UTF8.GetBytes(ExportSouhaits.Csv([Souhait(1, "Émile")])))
|
||||
.Concat(Encoding.UTF8.GetBytes(ExportSouhaits.Csv([Souhait(1, "Émile")], [])))
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal([0xEF, 0xBB, 0xBF], octets[..3]);
|
||||
@@ -188,6 +191,78 @@ public class ExportSouhaitsTests
|
||||
Assert.Equal(mime, ExportSouhaits.TypeMime(format));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// La table sœur : les revues souhaitées doivent traverser les DEUX exports.
|
||||
// C'est là que le sujet se rate — un fichier emporté sans ses revues ne le dit pas.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static RevueSouhaiteeDto Revue(
|
||||
int id, string titre, string? numero = null, string? issn = null, string? note = null) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
Titre = titre,
|
||||
Numero = numero,
|
||||
Issn = issn,
|
||||
Note = note,
|
||||
DateAjout = Jour,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Le_texte_porte_les_revues_dans_une_section_apres_les_livres()
|
||||
{
|
||||
var texte = ExportSouhaits.Texte(
|
||||
[Souhait(1, "Germinal", "Émile Zola")],
|
||||
[Revue(1, "Médor", "43", "2466-6718"), Revue(2, "XXI")],
|
||||
"Mathieu",
|
||||
Jour);
|
||||
|
||||
Assert.Contains("1 livre", texte);
|
||||
Assert.Contains("2 revues", texte);
|
||||
|
||||
// ⚠️ Section à part, APRÈS les livres : une revue n'a pas d'auteur, elle n'a donc rien
|
||||
// à faire dans un fichier groupé par auteur — et surtout pas sous « Auteur non précisé »,
|
||||
// qui désigne des livres dont l'auteur est simplement inconnu.
|
||||
Assert.True(texte.IndexOf("Émile Zola", StringComparison.Ordinal)
|
||||
< texte.IndexOf("Médor", StringComparison.Ordinal));
|
||||
|
||||
Assert.Contains(" - [1] Médor n° 43 (ISSN 2466-6718)", texte);
|
||||
|
||||
// Sans numéro, on n'invente pas de « n° » : c'est la revue entière qui est souhaitée.
|
||||
Assert.Contains(" - [2] XXI", texte);
|
||||
Assert.DoesNotContain("XXI n°", texte);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Le_texte_porte_les_revues_meme_sans_aucun_livre()
|
||||
{
|
||||
var texte = ExportSouhaits.Texte([], [Revue(1, "Médor", "43")], null, Jour);
|
||||
|
||||
Assert.DoesNotContain("(aucune envie dans la liste)", texte);
|
||||
Assert.Contains(" - [1] Médor n° 43", texte);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// La colonne « Type » n'est pas décorative : sans elle, une revue et un livre deviennent
|
||||
/// indiscernables dès que le tableur est trié par titre, et la colonne des codes mêle deux
|
||||
/// numéros de nature différente sans le dire.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Le_csv_distingue_les_revues_des_livres_et_les_range_ensuite()
|
||||
{
|
||||
var csv = ExportSouhaits.Csv(
|
||||
[Souhait(1, "Germinal", "Émile Zola")],
|
||||
[Revue(1, "Médor", "43", "2466-6718", "au kiosque de la gare")]);
|
||||
|
||||
var lignes = csv.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
Assert.StartsWith("1;Livre;Germinal;", lignes[1]);
|
||||
Assert.Equal("1;Revue;Médor n° 43;;;;2466-6718;au kiosque de la gare;2026-08-18", lignes[2]);
|
||||
|
||||
// Le nombre de colonnes est le même pour les deux natures, sinon le tableau se décale.
|
||||
Assert.Equal(ColonnesCsv(lignes[1]).Count, ColonnesCsv(lignes[2]).Count);
|
||||
}
|
||||
|
||||
/// <summary>Découpe une ligne CSV en respectant les guillemets (lecteur minimal, pour le test).</summary>
|
||||
private static List<string> ColonnesCsv(string ligne)
|
||||
{
|
||||
|
||||
@@ -112,6 +112,45 @@ public class NettoyageIsbdTests
|
||||
public void Editeur_laisse_passer_ce_qui_est_deja_propre(string brut, string attendu)
|
||||
=> Assert.Equal(attendu, NettoyageIsbd.Editeur(brut));
|
||||
|
||||
// ── Le nombre de pages, lu dans dc:format ───────────────────────────────
|
||||
//
|
||||
// ⚠️ Ce champ n'est PAS un nombre : c'est une phrase décrivant le support. Les règles sont
|
||||
// donc volontairement étroites, et l'erreur n'est pas symétrique — un champ vide se remplit
|
||||
// à la main en trois secondes, un chiffre faux s'enregistre sans que personne ne le voie.
|
||||
|
||||
[Theory]
|
||||
// Formes réellement observées dans les notices enregistrées sous Fixtures/.
|
||||
[InlineData("1 vol. (113 p.) : ill., couv. ill. en coul. ; 18 cm", 113)]
|
||||
[InlineData("1 vol. (254 p.) ; 18 cm", 254)]
|
||||
[InlineData("503 p. : couv. ill. ; 17 cm", 503)]
|
||||
// Autres formes plausibles du même catalogue.
|
||||
[InlineData("349 p.", 349)]
|
||||
[InlineData("1 vol. (XII-349 p.)", 349)]
|
||||
[InlineData("208 pages", 208)]
|
||||
[InlineData("1 vol. (1248 p.)", 1248)]
|
||||
public void NombreDePages_lit_une_pagination(string brut, int attendu)
|
||||
=> Assert.Equal(attendu, NettoyageIsbd.NombreDePages(brut));
|
||||
|
||||
[Theory]
|
||||
// ⚠️ « 1 vol. » ne vaut pas 1 page, et « 30 cm » ne vaut pas 30 pages : ce sont les deux
|
||||
// façons les plus faciles de fabriquer un chiffre faux.
|
||||
[InlineData("1 vol.")]
|
||||
[InlineData("30 cm")]
|
||||
[InlineData("2 disques compacts")]
|
||||
[InlineData("1 disque compact (1 h 05 min)")]
|
||||
// Une pagination de CONTRIBUTION : les chiffres suivent « p. », ils bornent un extrait
|
||||
// dans un volume et ne disent rien de son épaisseur.
|
||||
[InlineData("1 vol. (p. 45-90)")]
|
||||
// Non paginé entre crochets : on ne sait pas le lire, donc on ne prétend pas.
|
||||
[InlineData("1 vol. (non paginé [128] p.)")]
|
||||
// Un nombre à rallonge n'est pas une pagination, et 0 page n'est pas une donnée.
|
||||
[InlineData("1234567 p.")]
|
||||
[InlineData("0 p.")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void NombreDePages_rend_null_sur_ce_quil_ne_comprend_pas(string? brut)
|
||||
=> Assert.Null(NettoyageIsbd.NombreDePages(brut));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
@@ -121,5 +160,6 @@ public class NettoyageIsbdTests
|
||||
Assert.Null(NettoyageIsbd.Titre(brut));
|
||||
Assert.Null(NettoyageIsbd.Auteur(brut));
|
||||
Assert.Null(NettoyageIsbd.Editeur(brut));
|
||||
Assert.Null(NettoyageIsbd.NombreDePages(brut));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ public class RemonteeRoutesTests
|
||||
[InlineData("/series/7", "/series")]
|
||||
[InlineData("/series", "/")]
|
||||
// Envies
|
||||
[InlineData("/souhaits/5/edition", "/souhaits")]
|
||||
[InlineData("/souhaits/revues/5/edition", "/souhaits")]
|
||||
[InlineData("/souhaits/ordre", "/souhaits")]
|
||||
[InlineData("/souhaits/ajout", "/souhaits")]
|
||||
[InlineData("/souhaits", "/")]
|
||||
|
||||
@@ -118,6 +118,50 @@ public class ServiceCatalogueTests : IDisposable
|
||||
(await _db.Livres.SingleAsync()).UrlNotice);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Le nombre de pages est facultatif, et son absence est une <b>vraie</b> absence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <c>0</c> est refusé plutôt qu'enregistré : la colonne est nullable précisément pour
|
||||
/// que « je ne sais pas » ait sa propre valeur, et un zéro stocké se lirait comme une donnée.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Creer_conserve_le_nombre_de_pages_ou_son_absence()
|
||||
{
|
||||
var avec = await _service.CreerAsync(
|
||||
new EnregistrementLivre { Titre = "Germinal", NombrePages = 503 }, "mathieu");
|
||||
Assert.Equal(503, avec.Livre!.NombrePages);
|
||||
|
||||
var sans = await _service.CreerAsync(
|
||||
new EnregistrementLivre { Titre = "Nana" }, "mathieu");
|
||||
Assert.Null(sans.Livre!.NombrePages);
|
||||
Assert.Null((await _db.Livres.SingleAsync(l => l.Titre == "Nana")).NombrePages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Creer_refuse_un_nombre_de_pages_nul_ou_negatif()
|
||||
{
|
||||
var resultat = await _service.CreerAsync(
|
||||
new EnregistrementLivre { Titre = "Germinal", NombrePages = 0 }, "mathieu");
|
||||
|
||||
Assert.NotNull(resultat.Erreur);
|
||||
Assert.Empty(_db.Livres);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Modifier_peut_effacer_le_nombre_de_pages()
|
||||
{
|
||||
var livre = (await _service.CreerAsync(
|
||||
new EnregistrementLivre { Titre = "Germinal", NombrePages = 503 }, "mathieu")).Livre!;
|
||||
|
||||
var resultat = await _service.ModifierAsync(
|
||||
livre.Id, new EnregistrementLivre { Titre = "Germinal" }, "mathieu");
|
||||
|
||||
// Effacer le champ le remet à « inconnu » : c'est la seule façon de corriger une valeur
|
||||
// proposée à tort par une notice.
|
||||
Assert.Null(resultat.Livre!.NombrePages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Creer_refuse_un_titre_vide()
|
||||
{
|
||||
|
||||
@@ -213,6 +213,66 @@ public class ServiceRenormalisationTests : IDisposable
|
||||
Assert.Equal("l oeuvre", _db.LivresSouhaites.Single().TitreNormalise);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Une_envie_de_revue_ecrite_avant_le_changement_de_regle_est_rattrapee()
|
||||
{
|
||||
// Formes telles qu'elles auraient été calculées avant le développement des ligatures,
|
||||
// et ISSN saisi sans son tiret — deux rattrapages, deux mécanismes distincts.
|
||||
_db.RevuesSouhaitees.Add(new RevueSouhaitee
|
||||
{
|
||||
Utilisateur = "mathieu",
|
||||
Titre = "Cœur",
|
||||
TitreNormalise = "cœur",
|
||||
Numero = "Hors-Série 7",
|
||||
NumeroNormalise = "hors-série 7",
|
||||
Issn = "24666718",
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await ExecuterAsync();
|
||||
|
||||
var envie = _db.RevuesSouhaitees.Single();
|
||||
Assert.Equal("coeur", envie.TitreNormalise);
|
||||
Assert.Equal("hors serie 7", envie.NumeroNormalise);
|
||||
|
||||
// ⚠️ L'ISSN ne suit pas la clé : sur une ligne au titre inchangé, il ne serait jamais
|
||||
// canonisé si ce rattrapage n'existait pas à part.
|
||||
Assert.Equal("2466-6718", envie.Issn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⚠️ Une collision d'unicité au démarrage empêcherait le serveur de se lancer. La règle est
|
||||
/// la même que partout ailleurs : la ligne la plus ancienne prend la nouvelle forme, les
|
||||
/// suivantes gardent l'ancienne. Mal normalisée vaut infiniment mieux que supprimée.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Deux_envies_de_revue_que_la_nouvelle_regle_confond_survivent_toutes_les_deux()
|
||||
{
|
||||
_db.RevuesSouhaitees.AddRange(
|
||||
new RevueSouhaitee
|
||||
{
|
||||
Utilisateur = "mathieu",
|
||||
Titre = "Cœur",
|
||||
TitreNormalise = "cœur",
|
||||
},
|
||||
new RevueSouhaitee
|
||||
{
|
||||
Utilisateur = "mathieu",
|
||||
Titre = "Coeur",
|
||||
TitreNormalise = "coeur",
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await ExecuterAsync();
|
||||
|
||||
var formes = _db.RevuesSouhaitees.OrderBy(e => e.Id).Select(e => e.TitreNormalise).ToList();
|
||||
|
||||
// La seconde garde « coeur » (elle l'avait déjà), la première ne peut pas la prendre.
|
||||
Assert.Equal(2, formes.Count);
|
||||
Assert.Equal("cœur", formes[0]);
|
||||
Assert.Equal("coeur", formes[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Une_serie_et_une_revue_sont_rattrapees_aussi()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
using MaBibli.Api.Data;
|
||||
using MaBibli.Api.Services.Souhaits;
|
||||
using MaBibli.Shared.Dtos;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MaBibli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Les envies de revues, sur une base SQLite <b>en mémoire</b> : l'unicité
|
||||
/// (utilisateur, titre, numéro) n'existe qu'en base.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mêmes invariants que la liste d'envies de livres, et ils comptent autant : la table est
|
||||
/// <b>personnelle</b>, et l'envie d'un autre doit être introuvable, jamais refusée.
|
||||
/// </remarks>
|
||||
public class ServiceRevuesSouhaiteesTests : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connexion;
|
||||
private readonly MaBibliDbContext _db;
|
||||
private readonly ServiceRevuesSouhaitees _envies;
|
||||
|
||||
private const string Mathieu = "mathieu";
|
||||
private const string Camille = "camille";
|
||||
|
||||
public ServiceRevuesSouhaiteesTests()
|
||||
{
|
||||
_connexion = new SqliteConnection("Data Source=:memory:");
|
||||
_connexion.Open();
|
||||
|
||||
_db = new MaBibliDbContext(
|
||||
new DbContextOptionsBuilder<MaBibliDbContext>().UseSqlite(_connexion).Options);
|
||||
_db.Database.EnsureCreated();
|
||||
|
||||
_envies = new ServiceRevuesSouhaitees(_db);
|
||||
}
|
||||
|
||||
private Task<ResultatRevueSouhaitee> AjouterAsync(
|
||||
string titre, string? numero = null, string? issn = null,
|
||||
string? note = null, string utilisateur = Mathieu) =>
|
||||
_envies.AjouterAsync(
|
||||
new EnregistrementRevueSouhaitee
|
||||
{
|
||||
Titre = titre,
|
||||
Numero = numero,
|
||||
Issn = issn,
|
||||
Note = note,
|
||||
},
|
||||
utilisateur);
|
||||
|
||||
/// <summary>
|
||||
/// L'ISSN est le seul code du projet rangé <b>avec son tiret</b> : c'est la forme que
|
||||
/// produit le code-barres et celle qu'interroge la BnF. Sans canonisation, un ISSN tapé à
|
||||
/// la main ne se rapprocherait de rien.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LIssn_est_canonise_avec_son_tiret()
|
||||
{
|
||||
var envie = (await AjouterAsync("Médor", "43", "24666718")).Envie!;
|
||||
|
||||
Assert.Equal("2466-6718", envie.Issn);
|
||||
Assert.Equal("2466-6718", _db.RevuesSouhaitees.Single().Issn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Un_code_qui_nest_pas_un_issn_est_conserve_tel_quel()
|
||||
{
|
||||
// Un code mal recopié doit se voir mal recopié, pas disparaître ni être déguisé.
|
||||
var envie = (await AjouterAsync("Médor", issn: "pas-un-issn")).Envie!;
|
||||
|
||||
Assert.Equal("pas-un-issn", envie.Issn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deux_numeros_de_la_meme_revue_cohabitent()
|
||||
{
|
||||
await AjouterAsync("Médor", "43");
|
||||
var second = await AjouterAsync("Médor", "44");
|
||||
|
||||
Assert.Null(second.Erreur);
|
||||
Assert.Equal(2, _db.RevuesSouhaitees.Count());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⚠️ Le piège que la colonne <c>NumeroNormalise</c> non nullable ferme : SQLite tient deux
|
||||
/// <c>NULL</c> pour distincts, et « Médor, sans numéro » s'ajouterait autant de fois qu'on
|
||||
/// cliquerait.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task La_meme_revue_sans_numero_ne_sajoute_quune_fois()
|
||||
{
|
||||
await AjouterAsync("Médor");
|
||||
var second = await AjouterAsync("Médor");
|
||||
|
||||
Assert.NotNull(second.Erreur);
|
||||
Assert.Contains("déjà dans votre liste", second.Erreur);
|
||||
Assert.Single(_db.RevuesSouhaitees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task La_revue_entiere_et_un_de_ses_numeros_sont_deux_envies()
|
||||
{
|
||||
await AjouterAsync("Médor");
|
||||
var numero = await AjouterAsync("Médor", "43");
|
||||
|
||||
Assert.Null(numero.Erreur);
|
||||
Assert.Equal(2, _db.RevuesSouhaitees.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deux_personnes_peuvent_souhaiter_le_meme_numero()
|
||||
{
|
||||
await AjouterAsync("Médor", "43");
|
||||
var autre = await AjouterAsync("Médor", "43", utilisateur: Camille);
|
||||
|
||||
Assert.Null(autre.Erreur);
|
||||
Assert.Single(await _envies.ListerAsync(Mathieu));
|
||||
Assert.Single(await _envies.ListerAsync(Camille));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task La_liste_suit_le_rang_et_une_envie_neuve_va_en_fin()
|
||||
{
|
||||
await AjouterAsync("Médor", "43");
|
||||
await AjouterAsync("XXI");
|
||||
|
||||
var liste = await _envies.ListerAsync(Mathieu);
|
||||
|
||||
Assert.Equal(["Médor n° 43", "XXI"], liste.Select(e => e.TitreComplet));
|
||||
Assert.Equal([0, 1], _db.RevuesSouhaitees.OrderBy(e => e.Id).Select(e => e.Rang));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Editer_lenvie_dun_autre_est_introuvable_et_ne_change_rien()
|
||||
{
|
||||
var sienne = (await AjouterAsync("Médor", "43", utilisateur: Camille)).Envie!;
|
||||
|
||||
var resultat = await _envies.ModifierAsync(
|
||||
sienne.Id, new EnregistrementRevueSouhaitee { Titre = "Détournée" }, Mathieu);
|
||||
|
||||
Assert.True(resultat.NExistePas);
|
||||
Assert.Null(resultat.Erreur);
|
||||
Assert.Equal("Médor", _db.RevuesSouhaitees.Single().Titre);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Editer_recalcule_les_formes_et_garde_le_rang()
|
||||
{
|
||||
await AjouterAsync("Médor", "43");
|
||||
var seconde = (await AjouterAsync("XXI")).Envie!;
|
||||
|
||||
var resultat = await _envies.ModifierAsync(
|
||||
seconde.Id,
|
||||
new EnregistrementRevueSouhaitee { Titre = "Six Mois", Numero = "Hors-Série 7" },
|
||||
Mathieu);
|
||||
|
||||
Assert.Null(resultat.Erreur);
|
||||
|
||||
var ligne = _db.RevuesSouhaitees.Single(e => e.Id == seconde.Id);
|
||||
Assert.Equal("six mois", ligne.TitreNormalise);
|
||||
Assert.Equal("hors serie 7", ligne.NumeroNormalise);
|
||||
Assert.Equal(1, ligne.Rang);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Editer_vers_une_envie_deja_presente_est_refuse_lisiblement()
|
||||
{
|
||||
await AjouterAsync("Médor", "43");
|
||||
var seconde = (await AjouterAsync("Médor", "44")).Envie!;
|
||||
|
||||
var resultat = await _envies.ModifierAsync(
|
||||
seconde.Id,
|
||||
new EnregistrementRevueSouhaitee { Titre = "MÉDOR", Numero = "43" },
|
||||
Mathieu);
|
||||
|
||||
Assert.False(resultat.NExistePas);
|
||||
Assert.NotNull(resultat.Erreur);
|
||||
Assert.DoesNotContain("UNIQUE", resultat.Erreur, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(2, _db.RevuesSouhaitees.Count());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⚠️ Sans exclusion explicite de sa propre ligne, toute édition se dirait doublon
|
||||
/// d'elle-même — et il serait impossible de corriger une note.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reenregistrer_la_meme_envie_nest_pas_un_doublon_delle_meme()
|
||||
{
|
||||
var envie = (await AjouterAsync("Médor", "43")).Envie!;
|
||||
|
||||
var resultat = await _envies.ModifierAsync(
|
||||
envie.Id,
|
||||
new EnregistrementRevueSouhaitee { Titre = "Médor", Numero = "43", Note = "au kiosque" },
|
||||
Mathieu);
|
||||
|
||||
Assert.Null(resultat.Erreur);
|
||||
Assert.Equal("au kiosque", _db.RevuesSouhaitees.Single().Note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retirer_lenvie_dun_autre_est_refuse()
|
||||
{
|
||||
var sienne = (await AjouterAsync("Médor", "43", utilisateur: Camille)).Envie!;
|
||||
|
||||
Assert.False(await _envies.SupprimerAsync(sienne.Id, Mathieu));
|
||||
Assert.Single(_db.RevuesSouhaitees);
|
||||
|
||||
Assert.True(await _envies.SupprimerAsync(sienne.Id, Camille));
|
||||
Assert.Empty(_db.RevuesSouhaitees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sans_identite_il_ny_a_ni_liste_ni_ecriture()
|
||||
{
|
||||
Assert.Empty(await _envies.ListerAsync(null));
|
||||
Assert.NotNull((await AjouterAsync("Médor", utilisateur: null!)).Erreur);
|
||||
Assert.True((await _envies.ModifierAsync(
|
||||
1, new EnregistrementRevueSouhaitee { Titre = "Médor" }, null)).NExistePas);
|
||||
Assert.False(await _envies.SupprimerAsync(1, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Le_titre_est_obligatoire()
|
||||
{
|
||||
Assert.NotNull((await AjouterAsync(" ")).Erreur);
|
||||
Assert.Empty(_db.RevuesSouhaitees);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Dispose();
|
||||
_connexion.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -649,6 +649,149 @@ public class ServiceSouhaitsTests : IDisposable
|
||||
.Possede);
|
||||
}
|
||||
|
||||
// ── Éditer une envie ────────────────────────────────────────────────────
|
||||
//
|
||||
// La liste est PERSONNELLE : c'est le seul invariant que ces tests protègent vraiment.
|
||||
// Un 403 dirait « elle existe, mais elle n'est pas à vous » — exactement ce qu'on refuse
|
||||
// de laisser deviner.
|
||||
|
||||
[Fact]
|
||||
public async Task Editer_une_envie_recalcule_la_cle_doeuvre_et_lauteur()
|
||||
{
|
||||
var envie = (await AjouterAsync("Les fourmis")).Souhait!;
|
||||
|
||||
var resultat = await _souhaits.ModifierAsync(
|
||||
envie.Id,
|
||||
new EnregistrementSouhait { Titre = "L'Œuvre : roman", Auteur = "Zola, Émile" },
|
||||
Mathieu);
|
||||
|
||||
Assert.Null(resultat.Erreur);
|
||||
|
||||
var ligne = _db.LivresSouhaites.Single();
|
||||
|
||||
// Clé d'ŒUVRE : le sous-titre est coupé, la ligature développée, les accents à plat.
|
||||
Assert.Equal("l oeuvre", ligne.TitreNormalise);
|
||||
|
||||
// Sans ce recalcul, le rapprochement « déjà au catalogue » se ferait sur l'ancien nom.
|
||||
Assert.Equal("emile zola", ligne.AuteurNormalise);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Editer_une_envie_ne_touche_pas_a_son_rang()
|
||||
{
|
||||
await AjouterAsync("Première");
|
||||
var seconde = (await AjouterAsync("Seconde")).Souhait!;
|
||||
|
||||
await _souhaits.ModifierAsync(
|
||||
seconde.Id, new EnregistrementSouhait { Titre = "Seconde, corrigée" }, Mathieu);
|
||||
|
||||
Assert.Equal(1, _db.LivresSouhaites.Single(s => s.Id == seconde.Id).Rang);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Une correction ne doit pas effacer la couverture : aucun écran n'offre de champ pour la
|
||||
/// saisir, un remplacement inconditionnel la perdrait donc à la première faute de frappe
|
||||
/// corrigée.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Editer_une_envie_conserve_sa_couverture()
|
||||
{
|
||||
var envie = (await _souhaits.AjouterAsync(
|
||||
new EnregistrementSouhait
|
||||
{
|
||||
Titre = "Les fourmis",
|
||||
CoverUrl = "https://exemple.test/fourmis.jpg",
|
||||
},
|
||||
Mathieu)).Souhait!;
|
||||
|
||||
await _souhaits.ModifierAsync(
|
||||
envie.Id, new EnregistrementSouhait { Titre = "Les fourmis : roman" }, Mathieu);
|
||||
|
||||
Assert.Equal("https://exemple.test/fourmis.jpg", _db.LivresSouhaites.Single().CoverUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Editer_lenvie_dun_autre_est_introuvable_et_ne_change_rien()
|
||||
{
|
||||
var sienne = (await AjouterAsync("Les fourmis", utilisateur: Camille)).Souhait!;
|
||||
|
||||
var resultat = await _souhaits.ModifierAsync(
|
||||
sienne.Id, new EnregistrementSouhait { Titre = "Détourné" }, Mathieu);
|
||||
|
||||
// ⚠️ Introuvable, et non « refusé » : l'inexistence et l'appartenance à autrui doivent
|
||||
// rester indiscernables de l'extérieur.
|
||||
Assert.True(resultat.NExistePas);
|
||||
Assert.Null(resultat.Erreur);
|
||||
Assert.Equal("Les fourmis", _db.LivresSouhaites.Single().Titre);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Editer_une_envie_inexistante_est_introuvable()
|
||||
{
|
||||
var resultat = await _souhaits.ModifierAsync(
|
||||
404, new EnregistrementSouhait { Titre = "Fantôme" }, Mathieu);
|
||||
|
||||
Assert.True(resultat.NExistePas);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Le cas que l'ajout ne connaît pas : renommer une envie <b>en une autre déjà présente</b>.
|
||||
/// L'unicité (Utilisateur, TitreNormalise, AuteurNormalise) l'interdit — il faut répondre
|
||||
/// en français, pas laisser remonter « UNIQUE constraint failed ».
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Editer_vers_une_envie_deja_presente_est_refuse_lisiblement()
|
||||
{
|
||||
await AjouterAsync("Les fourmis");
|
||||
var seconde = (await AjouterAsync("Les thanatonautes")).Souhait!;
|
||||
|
||||
var resultat = await _souhaits.ModifierAsync(
|
||||
seconde.Id,
|
||||
new EnregistrementSouhait { Titre = "Les fourmis : roman", Auteur = "Bernard Werber" },
|
||||
Mathieu);
|
||||
|
||||
Assert.False(resultat.NExistePas);
|
||||
Assert.NotNull(resultat.Erreur);
|
||||
Assert.Contains("déjà dans votre liste", resultat.Erreur);
|
||||
Assert.DoesNotContain("UNIQUE", resultat.Erreur, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Et rien n'a bougé en base : le refus est un refus, pas une écriture partielle.
|
||||
Assert.Equal(2, _db.LivresSouhaites.Count());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deux personnes peuvent parfaitement viser la même œuvre : l'utilisateur ouvre l'index
|
||||
/// unique, il n'est pas seulement un filtre de lecture.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Editer_vers_lenvie_dun_autre_utilisateur_est_permis()
|
||||
{
|
||||
await AjouterAsync("Les fourmis", utilisateur: Camille);
|
||||
var sienne = (await AjouterAsync("Les thanatonautes")).Souhait!;
|
||||
|
||||
var resultat = await _souhaits.ModifierAsync(
|
||||
sienne.Id,
|
||||
new EnregistrementSouhait { Titre = "Les fourmis", Auteur = "Bernard Werber" },
|
||||
Mathieu);
|
||||
|
||||
Assert.Null(resultat.Erreur);
|
||||
Assert.Equal(2, _db.LivresSouhaites.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Editer_avec_un_isbn_invalide_est_refuse()
|
||||
{
|
||||
var envie = (await AjouterAsync("Les fourmis")).Souhait!;
|
||||
|
||||
var resultat = await _souhaits.ModifierAsync(
|
||||
envie.Id,
|
||||
new EnregistrementSouhait { Titre = "Les fourmis", Isbn = "123" },
|
||||
Mathieu);
|
||||
|
||||
Assert.NotNull(resultat.Erreur);
|
||||
Assert.Null(_db.LivresSouhaites.Single().Isbn);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Dispose();
|
||||
|
||||
Reference in New Issue
Block a user