From 94f0ef15c0189ac886143ab5f3434b615c4541d9 Mon Sep 17 00:00:00 2001 From: mathieu Date: Fri, 21 Aug 2026 14:02:09 +0200 Subject: [PATCH] =?UTF-8?q?Compte=20les=20pages=20d'un=20livre,=20et=20fai?= =?UTF-8?q?s=20des=20envies=20une=20liste=20=C3=A0=20deux=20moiti=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- MaBibli.Api/Data/MaBibliDbContext.cs | 29 + .../20260821114427_NombreDePages.Designer.cs | 644 ++++++++ .../20260821114427_NombreDePages.cs | 37 + .../20260821115007_EnviesDeRevues.Designer.cs | 693 +++++++++ .../20260821115007_EnviesDeRevues.cs | 66 + .../MaBibliDbContextModelSnapshot.cs | 1328 +++++++++-------- MaBibli.Api/Data/ServiceRenormalisation.cs | 29 + MaBibli.Api/Endpoints/SouhaitsEndpoints.cs | 134 +- MaBibli.Api/Program.cs | 1 + .../Services/Catalogue/ServiceCatalogue.cs | 12 + MaBibli.Api/Services/Isbn/BnfSruParser.cs | 7 + MaBibli.Api/Services/Isbn/NettoyageIsbd.cs | 52 + .../Services/Souhaits/ExportSouhaits.cs | 123 +- .../Souhaits/ServiceRevuesSouhaitees.cs | 208 +++ .../Services/Souhaits/ServiceSouhaits.cs | 106 +- .../Composants/FormulaireLivre.razor | 14 + MaBibli.Client/Pages/AjoutIsbn.razor | 4 + MaBibli.Client/Pages/AjoutSouhait.razor | 68 +- MaBibli.Client/Pages/EditionSouhait.razor | 176 +++ .../Pages/EditionSouhaitRevue.razor | 144 ++ MaBibli.Client/Pages/FicheLivre.razor | 11 + MaBibli.Client/Pages/Serie.razor | 1 + MaBibli.Client/Pages/Souhaits.razor | 122 +- MaBibli.Client/Services/CacheHorsLigne.cs | 11 + MaBibli.Client/Services/RemonteeRoutes.cs | 6 +- MaBibli.Client/Services/ServiceLivresApi.cs | 80 + MaBibli.Client/wwwroot/css/app.css | 11 + MaBibli.Shared/Dtos/CandidatLivre.cs | 11 + MaBibli.Shared/Dtos/EnregistrementLivre.cs | 9 + MaBibli.Shared/Dtos/LivreDto.cs | 4 + MaBibli.Shared/Dtos/SouhaitDto.cs | 49 + MaBibli.Shared/Entites/Livre.cs | 15 + MaBibli.Shared/Entites/RevueSouhaitee.cs | 107 ++ MaBibli.Tests/BnfSruParserTests.cs | 21 + MaBibli.Tests/ExportSouhaitsTests.cs | 107 +- MaBibli.Tests/NettoyageIsbdTests.cs | 40 + MaBibli.Tests/RemonteeRoutesTests.cs | 2 + MaBibli.Tests/ServiceCatalogueTests.cs | 44 + MaBibli.Tests/ServiceRenormalisationTests.cs | 60 + MaBibli.Tests/ServiceRevuesSouhaiteesTests.cs | 235 +++ MaBibli.Tests/ServiceSouhaitsTests.cs | 143 ++ 41 files changed, 4279 insertions(+), 685 deletions(-) create mode 100644 MaBibli.Api/Data/Migrations/20260821114427_NombreDePages.Designer.cs create mode 100644 MaBibli.Api/Data/Migrations/20260821114427_NombreDePages.cs create mode 100644 MaBibli.Api/Data/Migrations/20260821115007_EnviesDeRevues.Designer.cs create mode 100644 MaBibli.Api/Data/Migrations/20260821115007_EnviesDeRevues.cs create mode 100644 MaBibli.Api/Services/Souhaits/ServiceRevuesSouhaitees.cs create mode 100644 MaBibli.Client/Pages/EditionSouhait.razor create mode 100644 MaBibli.Client/Pages/EditionSouhaitRevue.razor create mode 100644 MaBibli.Shared/Entites/RevueSouhaitee.cs create mode 100644 MaBibli.Tests/ServiceRevuesSouhaiteesTests.cs diff --git a/MaBibli.Api/Data/MaBibliDbContext.cs b/MaBibli.Api/Data/MaBibliDbContext.cs index 4ce55d6..53052d4 100644 --- a/MaBibli.Api/Data/MaBibliDbContext.cs +++ b/MaBibli.Api/Data/MaBibliDbContext.cs @@ -28,6 +28,13 @@ public class MaBibliDbContext(DbContextOptions options) : DbCo /// public DbSet LivresSouhaites => Set(); + /// + /// Envies de revues et de numéros, personnelles. Table sœur de + /// : 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. + /// + public DbSet RevuesSouhaitees => Set(); + public DbSet BibliographiesMasquees => Set(); /// @@ -174,6 +181,28 @@ public class MaBibliDbContext(DbContextOptions options) : DbCo .IsUnique(); }); + modelBuilder.Entity(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(masquee => { masquee.Property(m => m.Utilisateur).IsRequired(); diff --git a/MaBibli.Api/Data/Migrations/20260821114427_NombreDePages.Designer.cs b/MaBibli.Api/Data/Migrations/20260821114427_NombreDePages.Designer.cs new file mode 100644 index 0000000..823225e --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260821114427_NombreDePages.Designer.cs @@ -0,0 +1,644 @@ +// +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 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NumeroRevueId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NumeroRevueId", "TitreNormalise") + .IsUnique(); + + b.ToTable("ArticlesUne"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CleRegroupement") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Nom") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NomNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CleRegroupement") + .IsUnique(); + + b.HasIndex("NomNormalise"); + + b.ToTable("Auteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.BibliographieMasquee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Utilisateur", "AuteurId"); + + b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre") + .IsUnique(); + + b.ToTable("BibliographiesMasquees"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SerieId") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LivreId"); + + b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId"); + + b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId") + .IsUnique() + .HasFilter("\"LivreId\" IS NOT NULL"); + + b.ToTable("ElementsSerie"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("NombrePages") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeDocument") + .HasColumnType("INTEGER"); + + b.Property("UrlNotice") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Isbn"); + + b.HasIndex("TitreNormalise"); + + b.ToTable("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "AuteurId"); + + b.HasIndex("AuteurId"); + + b.ToTable("LivreAuteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Annee") + .HasColumnType("TEXT"); + + b.Property("Auteur") + .HasColumnType("TEXT"); + + b.Property("AuteurNormalise") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Rang") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur"); + + b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre") + .IsUnique(); + + b.ToTable("LivresSouhaites"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "ThemeId"); + + b.HasIndex("ThemeId"); + + b.ToTable("LivreThemes"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("DateParution") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Numero") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NumeroNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevueId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RevueId", "NumeroNormalise") + .IsUnique(); + + b.ToTable("NumerosRevue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DatePret") + .HasColumnType("TEXT"); + + b.Property("DateRetour") + .HasColumnType("TEXT"); + + b.Property("Emprunteur") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours") + .IsUnique() + .HasFilter("\"DateRetour\" IS NULL"); + + b.ToTable("Prets"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurAId") + .HasColumnType("INTEGER"); + + b.Property("AuteurBId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuteurAId", "AuteurBId") + .IsUnique(); + + b.ToTable("RapprochementsRefuses"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Issn") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TitreNormalise") + .IsUnique(); + + b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn") + .IsUnique() + .HasFilter("\"Issn\" IS NOT NULL"); + + b.ToTable("Revues"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SerieParenteId") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SerieParenteId"); + + b.HasIndex("TitreNormalise") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateMaj") + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Statut") + .HasColumnType("INTEGER"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Utilisateur"); + + b.HasIndex("LivreId", "Utilisateur") + .IsUnique(); + + b.ToTable("StatutsLecture"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Nom") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NomNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NomNormalise") + .IsUnique(); + + b.ToTable("Themes"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b => + { + b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue") + .WithMany("Articles") + .HasForeignKey("NumeroRevueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NumeroRevue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany() + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("MaBibli.Shared.Entites.Serie", "Serie") + .WithMany("Elements") + .HasForeignKey("SerieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + + b.Navigation("Serie"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur") + .WithMany("Livres") + .HasForeignKey("AuteurId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Auteurs") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Auteur"); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Themes") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MaBibli.Shared.Entites.Theme", "Theme") + .WithMany("Livres") + .HasForeignKey("ThemeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Livre"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.HasOne("MaBibli.Shared.Entites.Revue", "Revue") + .WithMany("Numeros") + .HasForeignKey("RevueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Revue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Prets") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente") + .WithMany("SousSeries") + .HasForeignKey("SerieParenteId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("SerieParente"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Statuts") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Navigation("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Navigation("Auteurs"); + + b.Navigation("Prets"); + + b.Navigation("Statuts"); + + b.Navigation("Themes"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.Navigation("Articles"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => + { + b.Navigation("Numeros"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.Navigation("Elements"); + + b.Navigation("SousSeries"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b => + { + b.Navigation("Livres"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MaBibli.Api/Data/Migrations/20260821114427_NombreDePages.cs b/MaBibli.Api/Data/Migrations/20260821114427_NombreDePages.cs new file mode 100644 index 0000000..684414f --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260821114427_NombreDePages.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + /// + /// Ajoute le nombre de pages, nullable, sans rien reprendre de l'existant. + /// + /// + /// ⚠️ Aucune valeur par défaut, et surtout pas 0 : 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 à + /// TypeDeDocument de se réduire à un AddColumn — un défaut qui ne prétend + /// rien n'a rien à reprendre, contrairement à RangDesEnvies. + /// + public partial class NombreDePages : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "NombrePages", + table: "Livres", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "NombrePages", + table: "Livres"); + } + } +} diff --git a/MaBibli.Api/Data/Migrations/20260821115007_EnviesDeRevues.Designer.cs b/MaBibli.Api/Data/Migrations/20260821115007_EnviesDeRevues.Designer.cs new file mode 100644 index 0000000..a729c70 --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260821115007_EnviesDeRevues.Designer.cs @@ -0,0 +1,693 @@ +// +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 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NumeroRevueId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NumeroRevueId", "TitreNormalise") + .IsUnique(); + + b.ToTable("ArticlesUne"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CleRegroupement") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Nom") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NomNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CleRegroupement") + .IsUnique(); + + b.HasIndex("NomNormalise"); + + b.ToTable("Auteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.BibliographieMasquee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Utilisateur", "AuteurId"); + + b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre") + .IsUnique(); + + b.ToTable("BibliographiesMasquees"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SerieId") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LivreId"); + + b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId"); + + b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId") + .IsUnique() + .HasFilter("\"LivreId\" IS NOT NULL"); + + b.ToTable("ElementsSerie"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("NombrePages") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeDocument") + .HasColumnType("INTEGER"); + + b.Property("UrlNotice") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Isbn"); + + b.HasIndex("TitreNormalise"); + + b.ToTable("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "AuteurId"); + + b.HasIndex("AuteurId"); + + b.ToTable("LivreAuteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Annee") + .HasColumnType("TEXT"); + + b.Property("Auteur") + .HasColumnType("TEXT"); + + b.Property("AuteurNormalise") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Rang") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur"); + + b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre") + .IsUnique(); + + b.ToTable("LivresSouhaites"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "ThemeId"); + + b.HasIndex("ThemeId"); + + b.ToTable("LivreThemes"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("DateParution") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Numero") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NumeroNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevueId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RevueId", "NumeroNormalise") + .IsUnique(); + + b.ToTable("NumerosRevue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DatePret") + .HasColumnType("TEXT"); + + b.Property("DateRetour") + .HasColumnType("TEXT"); + + b.Property("Emprunteur") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours") + .IsUnique() + .HasFilter("\"DateRetour\" IS NULL"); + + b.ToTable("Prets"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurAId") + .HasColumnType("INTEGER"); + + b.Property("AuteurBId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuteurAId", "AuteurBId") + .IsUnique(); + + b.ToTable("RapprochementsRefuses"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Issn") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TitreNormalise") + .IsUnique(); + + b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn") + .IsUnique() + .HasFilter("\"Issn\" IS NOT NULL"); + + b.ToTable("Revues"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.RevueSouhaitee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Issn") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Numero") + .HasColumnType("TEXT"); + + b.Property("NumeroNormalise") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("Rang") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Utilisateur" }, "IX_RevuesSouhaitees_Utilisateur"); + + b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" }, "IX_RevuesSouhaitees_Utilisateur_Revue") + .IsUnique(); + + b.ToTable("RevuesSouhaitees"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SerieParenteId") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SerieParenteId"); + + b.HasIndex("TitreNormalise") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateMaj") + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Statut") + .HasColumnType("INTEGER"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Utilisateur"); + + b.HasIndex("LivreId", "Utilisateur") + .IsUnique(); + + b.ToTable("StatutsLecture"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Nom") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NomNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NomNormalise") + .IsUnique(); + + b.ToTable("Themes"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b => + { + b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue") + .WithMany("Articles") + .HasForeignKey("NumeroRevueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NumeroRevue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany() + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("MaBibli.Shared.Entites.Serie", "Serie") + .WithMany("Elements") + .HasForeignKey("SerieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + + b.Navigation("Serie"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur") + .WithMany("Livres") + .HasForeignKey("AuteurId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Auteurs") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Auteur"); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Themes") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MaBibli.Shared.Entites.Theme", "Theme") + .WithMany("Livres") + .HasForeignKey("ThemeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Livre"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.HasOne("MaBibli.Shared.Entites.Revue", "Revue") + .WithMany("Numeros") + .HasForeignKey("RevueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Revue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Prets") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente") + .WithMany("SousSeries") + .HasForeignKey("SerieParenteId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("SerieParente"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Statuts") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Navigation("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Navigation("Auteurs"); + + b.Navigation("Prets"); + + b.Navigation("Statuts"); + + b.Navigation("Themes"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.Navigation("Articles"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => + { + b.Navigation("Numeros"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.Navigation("Elements"); + + b.Navigation("SousSeries"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b => + { + b.Navigation("Livres"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MaBibli.Api/Data/Migrations/20260821115007_EnviesDeRevues.cs b/MaBibli.Api/Data/Migrations/20260821115007_EnviesDeRevues.cs new file mode 100644 index 0000000..313a0ac --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260821115007_EnviesDeRevues.cs @@ -0,0 +1,66 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + /// + /// Crée la table sœur des envies : les revues et numéros souhaités. + /// + /// + /// Rien à reprendre de l'existant — la notion n'existait pas — donc une simple création de + /// table, contrairement à RangDesEnvies qui devait reconduire un ordre déjà affiché. + /// + /// ⚠️ NumeroNormalise est NOT NULL avec un défaut vide, et ce n'est pas une + /// coquetterie : SQLite tient deux NULL 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 LivresSouhaites.AuteurNormalise. + /// + /// + public partial class EnviesDeRevues : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RevuesSouhaitees", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Utilisateur = table.Column(type: "TEXT", nullable: false), + Titre = table.Column(type: "TEXT", nullable: false), + TitreNormalise = table.Column(type: "TEXT", nullable: false), + Numero = table.Column(type: "TEXT", nullable: true), + NumeroNormalise = table.Column(type: "TEXT", nullable: false, defaultValue: ""), + Issn = table.Column(type: "TEXT", nullable: true), + Note = table.Column(type: "TEXT", nullable: true), + DateAjout = table.Column(type: "TEXT", nullable: false), + Rang = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RevuesSouhaitees"); + } + } +} diff --git a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs index 4b9695d..84937f5 100644 --- a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs +++ b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs @@ -1,638 +1,690 @@ -// -using System; -using MaBibli.Api.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace MaBibli.Api.Data.Migrations -{ - [DbContext(typeof(MaBibliDbContext))] - partial class MaBibliDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); - - modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("NumeroRevueId") - .HasColumnType("INTEGER"); - - b.Property("Position") - .HasColumnType("INTEGER"); - - b.Property("Titre") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("TitreNormalise") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NumeroRevueId", "TitreNormalise") - .IsUnique(); - - b.ToTable("ArticlesUne"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CleRegroupement") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Nom") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("NomNormalise") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CleRegroupement") - .IsUnique(); - - b.HasIndex("NomNormalise"); - - b.ToTable("Auteurs"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.BibliographieMasquee", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AuteurId") - .HasColumnType("INTEGER"); - - b.Property("TitreNormalise") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Utilisateur") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Utilisateur", "AuteurId"); - - b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre") - .IsUnique(); - - b.ToTable("BibliographiesMasquees"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("LivreId") - .HasColumnType("INTEGER"); - - b.Property("Position") - .HasColumnType("INTEGER"); - - b.Property("SerieId") - .HasColumnType("INTEGER"); - - b.Property("Titre") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("LivreId"); - - b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId"); - - b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId") - .IsUnique() - .HasFilter("\"LivreId\" IS NOT NULL"); - - b.ToTable("ElementsSerie"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AjoutePar") - .HasColumnType("TEXT"); - - b.Property("CoverUrl") - .HasColumnType("TEXT"); - - b.Property("DateAjout") - .HasColumnType("TEXT"); - - b.Property("Editeur") - .HasColumnType("TEXT"); - - b.Property("Format") - .HasColumnType("INTEGER"); - - b.Property("Isbn") - .HasColumnType("TEXT"); - - b.Property("Titre") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("TitreNormalise") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("TypeDocument") - .HasColumnType("INTEGER"); - - b.Property("UrlNotice") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Isbn"); - - b.HasIndex("TitreNormalise"); - - b.ToTable("Livres"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => - { - b.Property("LivreId") - .HasColumnType("INTEGER"); - - b.Property("AuteurId") - .HasColumnType("INTEGER"); - - b.Property("Position") - .HasColumnType("INTEGER"); - - b.Property("Role") - .HasColumnType("INTEGER"); - - b.HasKey("LivreId", "AuteurId"); - - b.HasIndex("AuteurId"); - - b.ToTable("LivreAuteurs"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Annee") - .HasColumnType("TEXT"); - - b.Property("Auteur") - .HasColumnType("TEXT"); - - b.Property("AuteurNormalise") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue(""); - - b.Property("CoverUrl") - .HasColumnType("TEXT"); - - b.Property("DateAjout") - .HasColumnType("TEXT"); - - b.Property("Editeur") - .HasColumnType("TEXT"); - - b.Property("Isbn") - .HasColumnType("TEXT"); - - b.Property("Note") - .HasColumnType("TEXT"); - - b.Property("Rang") - .HasColumnType("INTEGER"); - - b.Property("Titre") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("TitreNormalise") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Utilisateur") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur"); - - b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre") - .IsUnique(); - - b.ToTable("LivresSouhaites"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b => - { - b.Property("LivreId") - .HasColumnType("INTEGER"); - - b.Property("ThemeId") - .HasColumnType("INTEGER"); - - b.HasKey("LivreId", "ThemeId"); - - b.HasIndex("ThemeId"); - - b.ToTable("LivreThemes"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CoverUrl") - .HasColumnType("TEXT"); - - b.Property("DateAjout") - .HasColumnType("TEXT"); - - b.Property("DateParution") - .HasColumnType("TEXT"); - - b.Property("Note") - .HasColumnType("TEXT"); - - b.Property("Numero") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("NumeroNormalise") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("RevueId") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("RevueId", "NumeroNormalise") - .IsUnique(); - - b.ToTable("NumerosRevue"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("DatePret") - .HasColumnType("TEXT"); - - b.Property("DateRetour") - .HasColumnType("TEXT"); - - b.Property("Emprunteur") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("LivreId") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId"); - - b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours") - .IsUnique() - .HasFilter("\"DateRetour\" IS NULL"); - - b.ToTable("Prets"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AuteurAId") - .HasColumnType("INTEGER"); - - b.Property("AuteurBId") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("AuteurAId", "AuteurBId") - .IsUnique(); - - b.ToTable("RapprochementsRefuses"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AjoutePar") - .HasColumnType("TEXT"); - - b.Property("DateAjout") - .HasColumnType("TEXT"); - - b.Property("Editeur") - .HasColumnType("TEXT"); - - b.Property("Issn") - .HasColumnType("TEXT"); - - b.Property("Titre") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("TitreNormalise") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("TitreNormalise") - .IsUnique(); - - b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn") - .IsUnique() - .HasFilter("\"Issn\" IS NOT NULL"); - - b.ToTable("Revues"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AjoutePar") - .HasColumnType("TEXT"); - - b.Property("DateAjout") - .HasColumnType("TEXT"); - - b.Property("Position") - .HasColumnType("INTEGER"); - - b.Property("SerieParenteId") - .HasColumnType("INTEGER"); - - b.Property("Titre") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("TitreNormalise") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("SerieParenteId"); - - b.HasIndex("TitreNormalise") - .IsUnique(); - - b.ToTable("Series"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("DateMaj") - .HasColumnType("TEXT"); - - b.Property("LivreId") - .HasColumnType("INTEGER"); - - b.Property("Statut") - .HasColumnType("INTEGER"); - - b.Property("Utilisateur") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("Utilisateur"); - - b.HasIndex("LivreId", "Utilisateur") - .IsUnique(); - - b.ToTable("StatutsLecture"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Nom") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("NomNormalise") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NomNormalise") - .IsUnique(); - - b.ToTable("Themes"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b => - { - b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue") - .WithMany("Articles") - .HasForeignKey("NumeroRevueId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("NumeroRevue"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b => - { - b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") - .WithMany() - .HasForeignKey("LivreId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("MaBibli.Shared.Entites.Serie", "Serie") - .WithMany("Elements") - .HasForeignKey("SerieId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Livre"); - - b.Navigation("Serie"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => - { - b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur") - .WithMany("Livres") - .HasForeignKey("AuteurId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") - .WithMany("Auteurs") - .HasForeignKey("LivreId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Auteur"); - - b.Navigation("Livre"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b => - { - b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") - .WithMany("Themes") - .HasForeignKey("LivreId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("MaBibli.Shared.Entites.Theme", "Theme") - .WithMany("Livres") - .HasForeignKey("ThemeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Livre"); - - b.Navigation("Theme"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => - { - b.HasOne("MaBibli.Shared.Entites.Revue", "Revue") - .WithMany("Numeros") - .HasForeignKey("RevueId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Revue"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => - { - b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") - .WithMany("Prets") - .HasForeignKey("LivreId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Livre"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => - { - b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente") - .WithMany("SousSeries") - .HasForeignKey("SerieParenteId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("SerieParente"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => - { - b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") - .WithMany("Statuts") - .HasForeignKey("LivreId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Livre"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => - { - b.Navigation("Livres"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => - { - b.Navigation("Auteurs"); - - b.Navigation("Prets"); - - b.Navigation("Statuts"); - - b.Navigation("Themes"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => - { - b.Navigation("Articles"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => - { - b.Navigation("Numeros"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => - { - b.Navigation("Elements"); - - b.Navigation("SousSeries"); - }); - - modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b => - { - b.Navigation("Livres"); - }); -#pragma warning restore 612, 618 - } - } -} +// +using System; +using MaBibli.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + [DbContext(typeof(MaBibliDbContext))] + partial class MaBibliDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NumeroRevueId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NumeroRevueId", "TitreNormalise") + .IsUnique(); + + b.ToTable("ArticlesUne"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CleRegroupement") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Nom") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NomNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CleRegroupement") + .IsUnique(); + + b.HasIndex("NomNormalise"); + + b.ToTable("Auteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.BibliographieMasquee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Utilisateur", "AuteurId"); + + b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre") + .IsUnique(); + + b.ToTable("BibliographiesMasquees"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SerieId") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LivreId"); + + b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId"); + + b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId") + .IsUnique() + .HasFilter("\"LivreId\" IS NOT NULL"); + + b.ToTable("ElementsSerie"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("NombrePages") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeDocument") + .HasColumnType("INTEGER"); + + b.Property("UrlNotice") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Isbn"); + + b.HasIndex("TitreNormalise"); + + b.ToTable("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("AuteurId") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "AuteurId"); + + b.HasIndex("AuteurId"); + + b.ToTable("LivreAuteurs"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Annee") + .HasColumnType("TEXT"); + + b.Property("Auteur") + .HasColumnType("TEXT"); + + b.Property("AuteurNormalise") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Rang") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur"); + + b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre") + .IsUnique(); + + b.ToTable("LivresSouhaites"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b => + { + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("LivreId", "ThemeId"); + + b.HasIndex("ThemeId"); + + b.ToTable("LivreThemes"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("DateParution") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Numero") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NumeroNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevueId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RevueId", "NumeroNormalise") + .IsUnique(); + + b.ToTable("NumerosRevue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DatePret") + .HasColumnType("TEXT"); + + b.Property("DateRetour") + .HasColumnType("TEXT"); + + b.Property("Emprunteur") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId"); + + b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours") + .IsUnique() + .HasFilter("\"DateRetour\" IS NULL"); + + b.ToTable("Prets"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuteurAId") + .HasColumnType("INTEGER"); + + b.Property("AuteurBId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuteurAId", "AuteurBId") + .IsUnique(); + + b.ToTable("RapprochementsRefuses"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Editeur") + .HasColumnType("TEXT"); + + b.Property("Issn") + .HasColumnType("TEXT"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TitreNormalise") + .IsUnique(); + + b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn") + .IsUnique() + .HasFilter("\"Issn\" IS NOT NULL"); + + b.ToTable("Revues"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.RevueSouhaitee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Issn") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("Numero") + .HasColumnType("TEXT"); + + b.Property("NumeroNormalise") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("Rang") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Utilisateur" }, "IX_RevuesSouhaitees_Utilisateur"); + + b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "NumeroNormalise" }, "IX_RevuesSouhaitees_Utilisateur_Revue") + .IsUnique(); + + b.ToTable("RevuesSouhaitees"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AjoutePar") + .HasColumnType("TEXT"); + + b.Property("DateAjout") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SerieParenteId") + .HasColumnType("INTEGER"); + + b.Property("Titre") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TitreNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SerieParenteId"); + + b.HasIndex("TitreNormalise") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateMaj") + .HasColumnType("TEXT"); + + b.Property("LivreId") + .HasColumnType("INTEGER"); + + b.Property("Statut") + .HasColumnType("INTEGER"); + + b.Property("Utilisateur") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Utilisateur"); + + b.HasIndex("LivreId", "Utilisateur") + .IsUnique(); + + b.ToTable("StatutsLecture"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Nom") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NomNormalise") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NomNormalise") + .IsUnique(); + + b.ToTable("Themes"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b => + { + b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue") + .WithMany("Articles") + .HasForeignKey("NumeroRevueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NumeroRevue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany() + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("MaBibli.Shared.Entites.Serie", "Serie") + .WithMany("Elements") + .HasForeignKey("SerieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + + b.Navigation("Serie"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b => + { + b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur") + .WithMany("Livres") + .HasForeignKey("AuteurId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Auteurs") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Auteur"); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Themes") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MaBibli.Shared.Entites.Theme", "Theme") + .WithMany("Livres") + .HasForeignKey("ThemeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Livre"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.HasOne("MaBibli.Shared.Entites.Revue", "Revue") + .WithMany("Numeros") + .HasForeignKey("RevueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Revue"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Prets") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente") + .WithMany("SousSeries") + .HasForeignKey("SerieParenteId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("SerieParente"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b => + { + b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") + .WithMany("Statuts") + .HasForeignKey("LivreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Livre"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b => + { + b.Navigation("Livres"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b => + { + b.Navigation("Auteurs"); + + b.Navigation("Prets"); + + b.Navigation("Statuts"); + + b.Navigation("Themes"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b => + { + b.Navigation("Articles"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b => + { + b.Navigation("Numeros"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.Navigation("Elements"); + + b.Navigation("SousSeries"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b => + { + b.Navigation("Livres"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MaBibli.Api/Data/ServiceRenormalisation.cs b/MaBibli.Api/Data/ServiceRenormalisation.cs index 0930941..40df5b1 100644 --- a/MaBibli.Api/Data/ServiceRenormalisation.cs +++ b/MaBibli.Api/Data/ServiceRenormalisation.cs @@ -66,6 +66,35 @@ public sealed class ServiceRenormalisation(MaBibliDbContext db, ILogger 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, diff --git a/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs b/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs index 5d7a967..f5480e6 100644 --- a/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs +++ b/MaBibli.Api/Endpoints/SouhaitsEndpoints.cs @@ -74,6 +74,37 @@ public static class SouhaitsEndpoints .Produces(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() + .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>(); + + 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(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() + .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 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 diff --git a/MaBibli.Api/Program.cs b/MaBibli.Api/Program.cs index bfe30f6..d0acd93 100644 --- a/MaBibli.Api/Program.cs +++ b/MaBibli.Api/Program.cs @@ -21,6 +21,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs index f6c0e5f..72eae2b 100644 --- a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs +++ b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs @@ -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, diff --git a/MaBibli.Api/Services/Isbn/BnfSruParser.cs b/MaBibli.Api/Services/Isbn/BnfSruParser.cs index a688bc0..001c27f 100644 --- a/MaBibli.Api/Services/Isbn/BnfSruParser.cs +++ b/MaBibli.Api/Services/Isbn/BnfSruParser.cs @@ -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, diff --git a/MaBibli.Api/Services/Isbn/NettoyageIsbd.cs b/MaBibli.Api/Services/Isbn/NettoyageIsbd.cs index b6df0d3..2ea4147 100644 --- a/MaBibli.Api/Services/Isbn/NettoyageIsbd.cs +++ b/MaBibli.Api/Services/Isbn/NettoyageIsbd.cs @@ -23,6 +23,13 @@ public static partial class NettoyageIsbd [GeneratedRegex(@"\s*\([^)]*\)\s*$")] private static partial Regex ParenthesesFinales(); + /// + /// Un nombre collé à une mention de pages. Le nombre doit précéder « p. » : dans + /// « p. 45-90 », les chiffres décrivent un intervalle dans un volume, pas son épaisseur. + /// + [GeneratedRegex(@"(? /// Titre : couper à la première mention de responsabilité (« / »). /// @@ -166,6 +173,51 @@ public static partial class NettoyageIsbd return s.Length == 0 ? null : s; } + /// + /// Nombre de pages lu dans la description matérielle (dc:format), ou null. + /// + /// + /// Ce champ n'est pas un nombre, c'est une phrase : 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 Fixtures/). Les règles restent + /// volontairement étroites : on ne reconnaît qu'un nombre suivi de « p. » ou de + /// « page(s) », et tout le reste rend null — le catalogue décrit aussi des + /// disques compacts et des dimensions en centimètres. + /// + /// ⚠️ 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. + /// + /// + /// La valeur reste de toute façon proposée dans un champ modifiable, jamais imposée. + /// + /// + 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; + } + /// /// Titre d'un périodique : couper la mention de responsabilité puis retirer le /// qualificatif entre parenthèses. diff --git a/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs b/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs index 1697a36..d766157 100644 --- a/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs +++ b/MaBibli.Api/Services/Souhaits/ExportSouhaits.cs @@ -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"; + /// + /// Produit le fichier, les deux tables réunies. + /// + /// + /// ⚠️ 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. + /// + /// Elles forment une section à part 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. + /// + /// public static string Produire( - FormatExportSouhaits format, IReadOnlyList souhaits, string? proprietaire, DateTime maintenant) => + FormatExportSouhaits format, + IReadOnlyList souhaits, + IReadOnlyList 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), }; /// @@ -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. /// - public static string Texte(IReadOnlyList souhaits, string? proprietaire, DateTime maintenant) + public static string Texte( + IReadOnlyList souhaits, + IReadOnlyList 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(); } - sortie.Append('\n').Append(souhaits.Count).Append(souhaits.Count > 1 ? " livres" : " livre").Append('\n'); + 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(); + } + + /// Les livres souhaités, groupés par auteur. + private static void Livres(StringBuilder sortie, IReadOnlyList 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(); } - /// Tableau ouvrable dans un tableur, une envie par ligne. - public static string Csv(IReadOnlyList souhaits) + /// + /// Les revues souhaitées, en section à part, à la fin. + /// + /// + /// ⚠️ 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. + /// + private static void Revues(StringBuilder sortie, IReadOnlyList 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'); + } + } + } + + /// + /// Tableau ouvrable dans un tableur, une envie par ligne, les deux tables réunies. + /// + /// + /// ⚠️ 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. + /// + public static string Csv(IReadOnlyList souhaits, IReadOnlyList 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(); } diff --git a/MaBibli.Api/Services/Souhaits/ServiceRevuesSouhaitees.cs b/MaBibli.Api/Services/Souhaits/ServiceRevuesSouhaitees.cs new file mode 100644 index 0000000..ce71281 --- /dev/null +++ b/MaBibli.Api/Services/Souhaits/ServiceRevuesSouhaitees.cs @@ -0,0 +1,208 @@ +using MaBibli.Api.Data; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Api.Services.Souhaits; + +/// Issue d'une écriture d'envie de revue : la ligne, un message, ou rien du tout. +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> ListerAsync( + string? utilisateur, CancellationToken ct = default); + + Task AjouterAsync( + EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default); + + Task ModifierAsync( + int id, EnregistrementRevueSouhaitee saisie, string? utilisateur, CancellationToken ct = default); + + Task SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default); +} + +/// +/// Les envies de revues et de numéros, personnelles. +/// +/// +/// Mêmes règles que , 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. +/// +/// ⚠️ Il n'y a pas 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. +/// +/// +public sealed class ServiceRevuesSouhaitees(MaBibliDbContext db) : IServiceRevuesSouhaitees +{ + public async Task> 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 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 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 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; + } + + /// + /// Le doublon est refusé avec un message, jamais laissé à l'index unique. + /// + /// + /// ⚠️ 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. + /// + private Task 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(); + + /// Projection vers le DTO, sans le champ Utilisateur. + 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, + }; +} diff --git a/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs b/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs index f6b6b3b..73d43c1 100644 --- a/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs +++ b/MaBibli.Api/Services/Souhaits/ServiceSouhaits.cs @@ -7,9 +7,16 @@ using Microsoft.EntityFrameworkCore; namespace MaBibli.Api.Services.Souhaits; -/// Issue d'un ajout d'envie : soit la ligne créée, soit un message pour l'utilisateur. -public readonly record struct ResultatSouhait(SouhaitDto? Souhait, string? Erreur) +/// Issue d'une écriture d'envie : la ligne, un message, ou rien du tout. +/// +/// ⚠️ « Introuvable » est un troisième état, 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. +/// +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 AjouterAsync( EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default); + /// + /// Corrige une envie existante de l'appelant. + /// + /// + /// ⚠️ Rang 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. + /// + Task ModifierAsync( + int id, EnregistrementSouhait saisie, string? utilisateur, CancellationToken ct = default); + Task SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default); /// @@ -153,6 +170,91 @@ public sealed class ServiceSouhaits(MaBibliDbContext db) : IServiceSouhaits return ResultatSouhait.Ok(Projeter(souhait, Correspondant(possede, souhait.Id))); } + public async Task 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 SupprimerAsync(int id, string? utilisateur, CancellationToken ct = default) { if (utilisateur is null) diff --git a/MaBibli.Client/Composants/FormulaireLivre.razor b/MaBibli.Client/Composants/FormulaireLivre.razor index 49b2b33..16c8cc2 100644 --- a/MaBibli.Client/Composants/FormulaireLivre.razor +++ b/MaBibli.Client/Composants/FormulaireLivre.razor @@ -131,6 +131,20 @@ autocomplete="off" /> + @* + ⚠️ 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. + *@ + + public Statut? Statut { get; set; } + /// + /// Nombre de pages. null = non renseigné, et surtout pas 0. + /// + /// + /// Peut arriver prérempli depuis dc:format de la BnF ; c'est alors une + /// proposition, que la saisie corrige ou efface. + /// + public int? NombrePages { get; set; } + public string? CoverUrl { get; set; } /// diff --git a/MaBibli.Shared/Dtos/LivreDto.cs b/MaBibli.Shared/Dtos/LivreDto.cs index bb26284..c8c8e3f 100644 --- a/MaBibli.Shared/Dtos/LivreDto.cs +++ b/MaBibli.Shared/Dtos/LivreDto.cs @@ -58,6 +58,10 @@ public record LivreDto /// public Statut? Statut { get; init; } + /// Nombre de pages, ou null s'il n'a jamais été renseigné. + /// ⚠️ Jamais 0 pour dire « inconnu » : l'interface ne l'affiche que renseigné. + public int? NombrePages { get; init; } + public string? CoverUrl { get; init; } public string? UrlNotice { get; init; } diff --git a/MaBibli.Shared/Dtos/SouhaitDto.cs b/MaBibli.Shared/Dtos/SouhaitDto.cs index 9a29cc6..9e7d53c 100644 --- a/MaBibli.Shared/Dtos/SouhaitDto.cs +++ b/MaBibli.Shared/Dtos/SouhaitDto.cs @@ -68,6 +68,55 @@ public record EnregistrementSouhait public string? Note { get; set; } } +/// +/// Une envie de revue — ou de numéro précis — telle qu'exposée par l'API. +/// +/// +/// Comme , aucun champ « utilisateur » : l'API ne rend que les +/// envies de l'appelant. +/// +/// Il n'y a volontairement ni « Possede » ni « RevueId », 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é. +/// +/// +public record RevueSouhaiteeDto +{ + public required int Id { get; init; } + + public required string Titre { get; init; } + + /// null = la revue entière, pas un numéro particulier. + public string? Numero { get; init; } + + /// ISSN sous sa forme à tiret, la seule rangée en base. + public string? Issn { get; init; } + + public string? Note { get; init; } + + public required DateTime DateAjout { get; init; } + + /// Titre suivi de son numéro, tel qu'on l'épelle à un kiosquier. + [JsonIgnore] + public string TitreComplet => + string.IsNullOrWhiteSpace(Numero) ? Titre : $"{Titre} n° {Numero}"; +} + +/// Charge utile d'ajout ou de modification d'une envie de revue. +/// Sans identité : le propriétaire est déterminé par le serveur. +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; } +} + /// Charge utile du masquage personnel d'une œuvre bibliographique. public record MasquageBibliographie { diff --git a/MaBibli.Shared/Entites/Livre.cs b/MaBibli.Shared/Entites/Livre.cs index 9c5cdb8..5cd8951 100644 --- a/MaBibli.Shared/Entites/Livre.cs +++ b/MaBibli.Shared/Entites/Livre.cs @@ -37,6 +37,21 @@ public class Livre /// public TypeDocument TypeDocument { get; set; } + /// + /// Nombre de pages, facultatif. + /// + /// + /// ⚠️ null et non 0 : 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 , dont le défaut ne prétend rien. + /// + /// Il peut être proposé par la BnF (dc:format : « 1 vol. (349 p.) »), mais il reste + /// modifiable et n'est jamais imposé. Rien n'est déduit pour un ebook, dont la + /// pagination dépend de la police. + /// + /// + public int? NombrePages { get; set; } + /// URL de la couverture (OpenLibrary), pas de fichier stocké localement. public string? CoverUrl { get; set; } diff --git a/MaBibli.Shared/Entites/RevueSouhaitee.cs b/MaBibli.Shared/Entites/RevueSouhaitee.cs new file mode 100644 index 0000000..c915e6a --- /dev/null +++ b/MaBibli.Shared/Entites/RevueSouhaitee.cs @@ -0,0 +1,107 @@ +using MaBibli.Shared.Isbn; +using MaBibli.Shared.Textes; + +namespace MaBibli.Shared.Entites; + +/// +/// Une revue — ou un numéro précis — que l'on aimerait acquérir. Personnelle, comme +/// . +/// +/// +/// Pourquoi une table sœur plutôt que des colonnes de plus sur ? +/// 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 +/// pas d'auteur et se distingue par son numéro — deux notions que la clé de +/// LivreSouhaite 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é +/// de . +/// +/// ⚠️ Le coût est assumé et il est réel : 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. +/// +/// +/// est une frontière, 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. +/// +/// +public class RevueSouhaitee +{ + public int Id { get; set; } + + /// + /// YNH_USER du propriétaire de l'envie. Frontière : toute lecture filtre dessus. + /// + public string Utilisateur { get; set; } = string.Empty; + + /// Titre de la revue (« Médor »), sans son numéro. + public string Titre { get; set; } = string.Empty; + + /// Titre mis à plat. Sert au dédoublonnage. + /// + /// ⚠️ et non CleOeuvre : 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 . + /// + public string TitreNormalise { get; set; } = string.Empty; + + /// + /// Numéro souhaité (« 43 », « hors-série 7 »), ou null pour la revue entière. + /// + /// + /// 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. + /// + public string? Numero { get; set; } + + /// + /// Numéro mis à plat. Jamais null — chaîne vide pour « la revue entière ». + /// + /// + /// ⚠️ Même piège que : SQLite tient deux + /// NULL pour distincts, et l'unicité laisserait alors passer autant de fois « Médor, + /// pas de numéro » qu'on voudrait. + /// + public string NumeroNormalise { get; set; } = string.Empty; + + /// + /// ISSN, facultatif, canonisé avec son tiret (« 2466-6718 »). + /// + /// + /// 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. + /// + public string? Issn { get; set; } + + /// Note libre : « vu chez le dentiste », « demander au kiosque ». + public string? Note { get; set; } + + public DateTime DateAjout { get; set; } + + /// + /// Rang dans la section « revues » de la liste, du plus désiré au moins désiré. + /// + /// + /// ⚠️ Cette numérotation est indépendante de celle de : + /// 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. + /// + 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); + } +} diff --git a/MaBibli.Tests/BnfSruParserTests.cs b/MaBibli.Tests/BnfSruParserTests.cs index 973f0ab..3bb8645 100644 --- a/MaBibli.Tests/BnfSruParserTests.cs +++ b/MaBibli.Tests/BnfSruParserTests.cs @@ -39,6 +39,27 @@ public class BnfSruParserTests Assert.Equal($"https://catalogue.bnf.fr/{c.IdentifiantSource}", c.UrlNotice); } + /// + /// Le nombre de pages est lu dans dc:format, qui décrit le support en toutes lettres. + /// + /// + /// ⚠️ Il est proposé, 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. + /// + [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() { diff --git a/MaBibli.Tests/ExportSouhaitsTests.cs b/MaBibli.Tests/ExportSouhaitsTests.cs index 2a7d453..2030ce9 100644 --- a/MaBibli.Tests/ExportSouhaitsTests.cs +++ b/MaBibli.Tests/ExportSouhaitsTests.cs @@ -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]); } /// @@ -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); } /// @@ -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); + } + + /// + /// 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. + /// + [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); + } + /// Découpe une ligne CSV en respectant les guillemets (lecteur minimal, pour le test). private static List ColonnesCsv(string ligne) { diff --git a/MaBibli.Tests/NettoyageIsbdTests.cs b/MaBibli.Tests/NettoyageIsbdTests.cs index 7c11296..5d65c79 100644 --- a/MaBibli.Tests/NettoyageIsbdTests.cs +++ b/MaBibli.Tests/NettoyageIsbdTests.cs @@ -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)); } } diff --git a/MaBibli.Tests/RemonteeRoutesTests.cs b/MaBibli.Tests/RemonteeRoutesTests.cs index d114f13..7b91cda 100644 --- a/MaBibli.Tests/RemonteeRoutesTests.cs +++ b/MaBibli.Tests/RemonteeRoutesTests.cs @@ -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", "/")] diff --git a/MaBibli.Tests/ServiceCatalogueTests.cs b/MaBibli.Tests/ServiceCatalogueTests.cs index f79d068..a4b3fc0 100644 --- a/MaBibli.Tests/ServiceCatalogueTests.cs +++ b/MaBibli.Tests/ServiceCatalogueTests.cs @@ -118,6 +118,50 @@ public class ServiceCatalogueTests : IDisposable (await _db.Livres.SingleAsync()).UrlNotice); } + /// + /// Le nombre de pages est facultatif, et son absence est une vraie absence. + /// + /// + /// ⚠️ 0 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. + /// + [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() { diff --git a/MaBibli.Tests/ServiceRenormalisationTests.cs b/MaBibli.Tests/ServiceRenormalisationTests.cs index c46afc1..7963c55 100644 --- a/MaBibli.Tests/ServiceRenormalisationTests.cs +++ b/MaBibli.Tests/ServiceRenormalisationTests.cs @@ -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); + } + + /// + /// ⚠️ 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. + /// + [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() { diff --git a/MaBibli.Tests/ServiceRevuesSouhaiteesTests.cs b/MaBibli.Tests/ServiceRevuesSouhaiteesTests.cs new file mode 100644 index 0000000..e4bfbb5 --- /dev/null +++ b/MaBibli.Tests/ServiceRevuesSouhaiteesTests.cs @@ -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; + +/// +/// Les envies de revues, sur une base SQLite en mémoire : l'unicité +/// (utilisateur, titre, numéro) n'existe qu'en base. +/// +/// +/// Mêmes invariants que la liste d'envies de livres, et ils comptent autant : la table est +/// personnelle, et l'envie d'un autre doit être introuvable, jamais refusée. +/// +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().UseSqlite(_connexion).Options); + _db.Database.EnsureCreated(); + + _envies = new ServiceRevuesSouhaitees(_db); + } + + private Task 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); + + /// + /// L'ISSN est le seul code du projet rangé avec son tiret : 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. + /// + [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()); + } + + /// + /// ⚠️ Le piège que la colonne NumeroNormalise non nullable ferme : SQLite tient deux + /// NULL pour distincts, et « Médor, sans numéro » s'ajouterait autant de fois qu'on + /// cliquerait. + /// + [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()); + } + + /// + /// ⚠️ Sans exclusion explicite de sa propre ligne, toute édition se dirait doublon + /// d'elle-même — et il serait impossible de corriger une note. + /// + [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); + } +} diff --git a/MaBibli.Tests/ServiceSouhaitsTests.cs b/MaBibli.Tests/ServiceSouhaitsTests.cs index b8ca629..76b4c65 100644 --- a/MaBibli.Tests/ServiceSouhaitsTests.cs +++ b/MaBibli.Tests/ServiceSouhaitsTests.cs @@ -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); + } + + /// + /// 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. + /// + [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); + } + + /// + /// Le cas que l'ajout ne connaît pas : renommer une envie en une autre déjà présente. + /// L'unicité (Utilisateur, TitreNormalise, AuteurNormalise) l'interdit — il faut répondre + /// en français, pas laisser remonter « UNIQUE constraint failed ». + /// + [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()); + } + + /// + /// Deux personnes peuvent parfaitement viser la même œuvre : l'utilisateur ouvre l'index + /// unique, il n'est pas seulement un filtre de lecture. + /// + [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();