From 4e372ab694884d9334979de731a65ae1c5b8ead5 Mon Sep 17 00:00:00 2001 From: mathieu Date: Wed, 19 Aug 2026 22:03:08 +0200 Subject: [PATCH] Ajouter les sagas et cycles, avec leurs tomes manquants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Première relation entre livres du projet. Une série contient des séries (un cycle EST une série de séries) et des PLACES dans l'ordre de lecture — pas des livres : une place sans livre est le trou qu'on vient voir. L'ordre est stocké, jamais déduit d'une année : une préquelle se lit avant le livre paru dix ans plus tôt. Les tomes non possédés se saisissent à la main, aucune source ne donnant l'ordre de lecture d'une saga. Le titre de la place survit à la suppression du livre (SetNull, pas de cascade), sans quoi perdre un exemplaire effacerait le tome 3 de la série. Les séries sont communes au foyer. Seule exception : la mise en envies d'un tome manquant, qui écrit dans une liste personnelle — c'est le seul point où les deux portées se rencontrent. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 95 ++++ IDEES.md | 34 +- MaBibli.Api/Data/MaBibliDbContext.cs | 56 ++ .../20260819195104_SagasEtCycles.Designer.cs | 410 ++++++++++++++ .../20260819195104_SagasEtCycles.cs | 105 ++++ .../MaBibliDbContextModelSnapshot.cs | 103 ++++ MaBibli.Api/Endpoints/SeriesEndpoints.cs | 151 +++++ MaBibli.Api/Program.cs | 3 + MaBibli.Api/Services/Series/ServiceSeries.cs | 450 +++++++++++++++ MaBibli.Client/Layout/MainLayout.razor | 5 +- MaBibli.Client/Layout/MainLayout.razor.css | 5 +- MaBibli.Client/Pages/FicheLivre.razor | 43 ++ MaBibli.Client/Pages/Serie.razor | 534 ++++++++++++++++++ MaBibli.Client/Pages/Series.razor | 142 +++++ MaBibli.Client/Services/CacheHorsLigne.cs | 14 + MaBibli.Client/Services/ServiceLivresApi.cs | 98 ++++ MaBibli.Client/wwwroot/css/app.css | 78 +++ MaBibli.Shared/Dtos/SerieDto.cs | 95 ++++ MaBibli.Shared/Entites/Serie.cs | 109 ++++ MaBibli.Tests/ServiceSeriesTests.cs | 274 +++++++++ 20 files changed, 2768 insertions(+), 36 deletions(-) create mode 100644 MaBibli.Api/Data/Migrations/20260819195104_SagasEtCycles.Designer.cs create mode 100644 MaBibli.Api/Data/Migrations/20260819195104_SagasEtCycles.cs create mode 100644 MaBibli.Api/Endpoints/SeriesEndpoints.cs create mode 100644 MaBibli.Api/Services/Series/ServiceSeries.cs create mode 100644 MaBibli.Client/Pages/Serie.razor create mode 100644 MaBibli.Client/Pages/Series.razor create mode 100644 MaBibli.Shared/Dtos/SerieDto.cs create mode 100644 MaBibli.Shared/Entites/Serie.cs create mode 100644 MaBibli.Tests/ServiceSeriesTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 1206e37..531fbf4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -717,6 +717,23 @@ RapprochementRefuse (mémoire des « non » de l'utilisateur) ├── AuteurAId ─┐ UNIQUE, couple toujours rangé (petit Id d'abord) └── AuteurBId ─┘ pour qu'un refus vaille dans les deux sens +Serie (sagas et cycles — COMMUNS au foyer, comme le catalogue) +├── Id +├── Titre +├── TitreNormalise (index UNIQUE — une série, une fiche) +├── SerieParenteId : int? (auto-référence : un cycle EST une série de séries) +├── Position (rang dans la série parente) +├── DateAjout +└── AjoutePar (trace, PAS une frontière — comme Livre.AjoutePar) + +ElementSerie (une PLACE dans l'ordre de lecture, occupée ou non) +├── Id +├── SerieId (FK Serie, cascade) +├── Position (ordre de LECTURE, stocké — jamais déduit d'une année) +├── LivreId : int? (FK Livre, ⚠ SetNull — NULL = un tome qu'on ne possède pas) +└── Titre (toujours renseigné, pour survivre à la suppression du livre) + UNIQUE (SerieId, LivreId) WHERE LivreId IS NOT NULL + Pret ├── Id ├── LivreId (FK Livre, cascade — index simple pour l'historique) @@ -1273,6 +1290,84 @@ l'œil. piste, avec sa limite connue — les œuvres remontent en langue originale et ne se rapprochent pas du catalogue par `CleOeuvre`. +## Sagas et cycles — migration `SagasEtCycles` (2026-08-19) + +Cas donné : *La Légende de Drizzt*. C'est la première notion du projet qui porte sur une +**relation entre livres** plutôt que sur un livre, et un champ texte « série » sur `Livre` n'en +couvrirait aucun aspect. + +| Ce que le cas impose | Ce qui le porte | +|---|---| +| Un cycle contient des **séries**, qui contiennent des tomes | `Serie.SerieParenteId`, auto-référence : un cycle **est** une série de séries | +| L'**ordre de lecture** n'est pas celui de publication (*L'Elfe noir* est une préquelle) | `ElementSerie.Position`, **stockée**, jamais déduite d'une année | +| Un livre appartient à **plusieurs** regroupements | aucune unicité sur `LivreId` seul | +| Montrer les **trous** — « il vous manque le tome 3 » | `ElementSerie.LivreId` **nullable** | + +### La table ne contient pas des livres, mais des places + +⚠️ C'est la clé de tout l'écran : `ElementSerie` est une **position dans l'ordre de lecture**, +pas « un livre de la série ». Une place sans livre n'est pas une anomalie, c'est le trou qu'on +vient voir. + +**Conséquence, décidée avec l'utilisateur le 2026-08-19** : les tomes non possédés **existent en +base** et se saisissent à la main. Aucune source ne donne l'ordre de lecture d'une saga — la BnF +ne l'expose pas, et l'ordre de publication est justement faux pour une préquelle. Sans saisie, +l'écran ne pourrait qu'ordonner ce qu'on a déjà, donc ne rien dire de ce qui manque. + +⚠️ **`Titre` est renseigné même quand le livre est là**, et la clé étrangère est en **`SetNull`**, +surtout pas en cascade : supprimer un livre du catalogue laisse un **trou nommé** à sa place. +Une cascade effacerait le tome 3 de la saga parce qu'on a perdu son exemplaire. Un test le +verrouille. + +⚠️ L'unicité `(SerieId, LivreId)` est un **index partiel**, comme celui des prêts en cours : les +places vides doivent pouvoir se répéter autant que nécessaire, et deux `NULL` passeraient de +toute façon. Un livre ne tient donc qu'une place **dans une série donnée**, mais vit dans +plusieurs séries. + +### Portée : commune, sauf en un seul point + +Les séries sont **communes au foyer**, comme le catalogue et les prêts : l'ordre de lecture est +une propriété de l'œuvre, il ne change pas selon qui regarde. Aucune méthode de `ServiceSeries` +ne prend d'utilisateur — **sauf `MettreEnEnviesAsync`**, et c'est là que les deux portées se +rencontrent : + +> un tome manquant est **commun** ; l'envie qu'il devient est **personnelle**. + +Deux membres du foyer peuvent donc vouloir le même tome absent, chacun dans sa liste. ⚠️ Aucun +auteur n'est transmis à l'envie créée : une place dans une saga n'en porte pas, et en inventer un +fausserait le rapprochement avec le catalogue. Le revers assumé : cette envie **ne sera pas** +marquée « déjà au catalogue » à l'achat du livre, la règle exigeant un auteur commun. + +### Deux garde-fous qui n'ont l'air de rien + +- **Supprimer un cycle ne détruit pas les séries qu'il contient** (`SetNull` sur la parenté) : + ce sont de vraies séries de vrais livres, elles remontent simplement à la racine. +- **Une série ne peut pas devenir sa propre ancêtre.** Sans ce contrôle, l'arbre se refermerait + sur lui-même et l'affichage boucherait à l'infini. La remontée de parenté est **bornée par le + nombre de séries**, pour s'arrêter même si une boucle existait déjà en base. + +### Une seule lecture, un seul instantané + +`GET /api/series` rend **tout l'arbre à plat**, le client le rebâtit. Le détail d'une série s'y +lit sans second appel : un instantané hors-ligne (`series`) suffit donc à servir la liste, chaque +détail, **et** le « fait partie de » de la fiche livre — au lieu d'un instantané par série +consultée. C'est aussi ce qui rend consultable hors-ligne une série qu'on n'avait pas ouverte +avant la coupure, exactement comme le catalogue complet. + +⚠️ Le rafraîchissement en tâche de fond (`RafraichirEnFondAsync`) **doit** inclure les séries, +sans quoi une coupure juste après un ajout de tome rendrait la série d'avant. + +### L'écran, et ce qu'il refuse de faire + +- **Un cinquième onglet.** Vérifié à 320 px : les cinq libellés tiennent sans troncature + (258 px sur 320), au prix d'un `font-size` passé de 0.85 à 0.8 rem. +- **Des flèches, pas de glisser-déposer** : le drag & drop HTML5 ne fonctionne pas au doigt, et + c'est sur téléphone qu'on consulte une saga. Même décision que pour la liste d'envies. +- **Le titre affiché est celui du livre quand il est rattaché**, le titre saisi restant le filet. + Rattacher le mauvais livre se voit donc immédiatement — et « Détacher » restitue le nom du tome. +- **Aucune détection automatique** de série depuis un titre (« Tome 3 »), aucune source tierce. + Ce qui est affiché a été saisi ; rien n'est deviné. + ## Interface — décisions actées le 2026-08-18 Retours d'usage d'`IDEES.md`, appliqués et donc retirés de ce fichier-là. diff --git a/IDEES.md b/IDEES.md index 54a45ef..01535c1 100644 --- a/IDEES.md +++ b/IDEES.md @@ -85,7 +85,8 @@ rendu en canvas, pas sur une image de caméra. ⚠️ La demande jumelle, **les BD**, est traitée : `TypeDocument` existe (voir `CLAUDE.md`). Restent les champs propres à la BD — série, tome, scénariste/dessinateur — à ne faire que si le besoin se confirme, le modèle mettant aujourd'hui tous les auteurs dans `LivreAuteur` sans -distinguer les rôles. La question des **séries** rejoint celle des sagas, plus bas. +distinguer les rôles. La question des **séries** est traitée (voir « Sagas et cycles » dans `CLAUDE.md`) : une BD +d'une série s'y range comme un roman. ⚠️ Rouvre la décision du lot 1, qui écartait le catalogage des périodiques faute de modèle. Le modèle est maintenant choisi. @@ -106,37 +107,6 @@ Ce que cela demande : zbar sait maintenant décoder, est le bon candidat — à vérifier sur des magazines réels ; - décider ce qu'un **prêt** signifie pour une revue : on prête un numéro, pas un abonnement. -## Sagas et cycles : plusieurs livres, un ordre de lecture - -Demandé le 2026-08-19. Cas donné : [Drizzt Do'Urden](https://fr.wikipedia.org/wiki/Drizzt_Do%27Urden) -— **plusieurs regroupements de plusieurs livres, à lire dans un ordre précis**. Vaut pour les -romans comme pour les BD. - -C'est la première demande du projet qui porte sur une **relation entre livres** plutôt que sur -un livre. Ce que le cas Drizzt impose, et qu'un simple champ « série » ne couvrirait pas : - -- **Deux niveaux.** *La Légende de Drizzt* regroupe plusieurs trilogies (*L'Elfe noir*, - *La Séquence de l'Icewind Dale*…) : un cycle contient des séries, qui contiennent des tomes. - Un champ texte plat perdrait ce regroupement. -- **L'ordre de lecture n'est pas l'ordre de publication.** *L'Elfe noir* est une préquelle écrite - après. C'est précisément l'information qu'on vient chercher : elle doit être **stockée**, pas - déduite de l'année. -- **Un livre peut appartenir à plusieurs regroupements** (une intégrale, une série, un cycle). -- **Ce qu'on possède est partiel.** L'intérêt de l'écran est de montrer les trous — « il vous - manque le tome 3 » — comme la bibliographie montre déjà ce qui manque d'un auteur. - -Questions à trancher avant tout modèle : - -- **D'où viennent les séries ?** La BnF ne les expose pas de façon exploitable en Dublin Core. - Saisie à la main, déduction depuis le titre (« Tome 3 »), ou source tierce ? -- **Portée** : commune au foyer, comme le catalogue, ou personnelle ? L'ordre de lecture est une - propriété de l'œuvre et non du lecteur — donc commune, a priori. -- **Les tomes non possédés** existent-ils en base, ou sont-ils seulement affichés ? S'ils - existent, ils ressemblent beaucoup à des envies, et il faudra dire comment les deux cohabitent. - -⚠️ Ne pas confondre avec le regroupement d'œuvres de la bibliographie (`CleOeuvre`), qui réunit -les **rééditions d'un même titre**. Ici, ce sont des titres **différents** qui se suivent. - ## Auteurs et bibliographie ### Homonymes : « Between two worlds », un autre Robert Harper diff --git a/MaBibli.Api/Data/MaBibliDbContext.cs b/MaBibli.Api/Data/MaBibliDbContext.cs index 53935b1..b6bde0b 100644 --- a/MaBibli.Api/Data/MaBibliDbContext.cs +++ b/MaBibli.Api/Data/MaBibliDbContext.cs @@ -24,6 +24,14 @@ public class MaBibliDbContext(DbContextOptions options) : DbCo /// public DbSet LivresSouhaites => Set(); + /// + /// Sagas, cycles et séries. Communs au foyer, comme le catalogue : l'ordre de lecture + /// est une propriété de l'œuvre, pas du lecteur. + /// + public DbSet Series => Set(); + + public DbSet ElementsSerie => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -121,6 +129,54 @@ public class MaBibliDbContext(DbContextOptions options) : DbCo .IsUnique(); }); + modelBuilder.Entity(serie => + { + serie.Property(s => s.Titre).IsRequired(); + serie.Property(s => s.TitreNormalise).IsRequired(); + + // Une série, une fiche. Voir l'entité : l'unicité est GLOBALE et non par parent, + // le parent étant nullable et deux NULL étant distincts pour SQLite. + serie.HasIndex(s => s.TitreNormalise).IsUnique(); + + // SetNull et non Cascade : supprimer un cycle ne doit pas emporter les trilogies + // qu'il contient — ce sont de vraies séries de vrais livres, qui se tiennent seules. + serie.HasOne(s => s.SerieParente) + .WithMany(s => s.SousSeries) + .HasForeignKey(s => s.SerieParenteId) + .OnDelete(DeleteBehavior.SetNull); + + serie.HasIndex(s => s.SerieParenteId); + }); + + modelBuilder.Entity(element => + { + element.Property(e => e.Titre).IsRequired(); + + element.HasOne(e => e.Serie) + .WithMany(s => s.Elements) + .HasForeignKey(e => e.SerieId) + .OnDelete(DeleteBehavior.Cascade); + + // ⚠️ SetNull, surtout pas Cascade : supprimer un livre du catalogue doit laisser sa + // place dans la saga, devenue un trou nommé. Cascade effacerait le tome 3 de la + // série parce qu'on a prêté puis perdu son exemplaire. + element.HasOne(e => e.Livre) + .WithMany() + .HasForeignKey(e => e.LivreId) + .OnDelete(DeleteBehavior.SetNull); + + element.HasIndex(e => e.SerieId, "IX_ElementsSerie_SerieId"); + + // Un même livre ne tient qu'une place dans une série donnée. Index PARTIEL, comme + // pour les prêts : les positions sans livre (les trous) doivent pouvoir se répéter + // autant que nécessaire, et deux NULL passeraient de toute façon. + // Un livre peut en revanche appartenir à PLUSIEURS séries — une intégrale, une + // série, un cycle — d'où l'absence d'unicité sur LivreId seul. + element.HasIndex(e => new { e.SerieId, e.LivreId }, "IX_ElementsSerie_SerieId_LivreId") + .IsUnique() + .HasFilter("\"LivreId\" IS NOT NULL"); + }); + modelBuilder.Entity(pret => { pret.Property(p => p.Emprunteur).IsRequired(); diff --git a/MaBibli.Api/Data/Migrations/20260819195104_SagasEtCycles.Designer.cs b/MaBibli.Api/Data/Migrations/20260819195104_SagasEtCycles.Designer.cs new file mode 100644 index 0000000..7335d9e --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260819195104_SagasEtCycles.Designer.cs @@ -0,0 +1,410 @@ +// +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("20260819195104_SagasEtCycles")] + partial class SagasEtCycles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + 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.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.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.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.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.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.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.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"); + }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.Navigation("Elements"); + + b.Navigation("SousSeries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MaBibli.Api/Data/Migrations/20260819195104_SagasEtCycles.cs b/MaBibli.Api/Data/Migrations/20260819195104_SagasEtCycles.cs new file mode 100644 index 0000000..62d0984 --- /dev/null +++ b/MaBibli.Api/Data/Migrations/20260819195104_SagasEtCycles.cs @@ -0,0 +1,105 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MaBibli.Api.Data.Migrations +{ + /// + public partial class SagasEtCycles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Series", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Titre = table.Column(type: "TEXT", nullable: false), + TitreNormalise = table.Column(type: "TEXT", nullable: false), + SerieParenteId = table.Column(type: "INTEGER", nullable: true), + Position = table.Column(type: "INTEGER", nullable: false), + DateAjout = table.Column(type: "TEXT", nullable: false), + AjoutePar = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Series", x => x.Id); + table.ForeignKey( + name: "FK_Series_Series_SerieParenteId", + column: x => x.SerieParenteId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "ElementsSerie", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SerieId = table.Column(type: "INTEGER", nullable: false), + Position = table.Column(type: "INTEGER", nullable: false), + LivreId = table.Column(type: "INTEGER", nullable: true), + Titre = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ElementsSerie", x => x.Id); + table.ForeignKey( + name: "FK_ElementsSerie_Livres_LivreId", + column: x => x.LivreId, + principalTable: "Livres", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_ElementsSerie_Series_SerieId", + column: x => x.SerieId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ElementsSerie_LivreId", + table: "ElementsSerie", + column: "LivreId"); + + migrationBuilder.CreateIndex( + name: "IX_ElementsSerie_SerieId", + table: "ElementsSerie", + column: "SerieId"); + + migrationBuilder.CreateIndex( + name: "IX_ElementsSerie_SerieId_LivreId", + table: "ElementsSerie", + columns: new[] { "SerieId", "LivreId" }, + unique: true, + filter: "\"LivreId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Series_SerieParenteId", + table: "Series", + column: "SerieParenteId"); + + migrationBuilder.CreateIndex( + name: "IX_Series_TitreNormalise", + table: "Series", + column: "TitreNormalise", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ElementsSerie"); + + migrationBuilder.DropTable( + name: "Series"); + } + } +} diff --git a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs index cbbd0b5..bede4e6 100644 --- a/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs +++ b/MaBibli.Api/Data/Migrations/MaBibliDbContextModelSnapshot.cs @@ -45,6 +45,38 @@ namespace MaBibli.Api.Data.Migrations b.ToTable("Auteurs"); }); + 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") @@ -215,6 +247,42 @@ namespace MaBibli.Api.Data.Migrations b.ToTable("RapprochementsRefuses"); }); + 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") @@ -244,6 +312,24 @@ namespace MaBibli.Api.Data.Migrations b.ToTable("StatutsLecture"); }); + 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") @@ -274,6 +360,16 @@ namespace MaBibli.Api.Data.Migrations 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") @@ -298,6 +394,13 @@ namespace MaBibli.Api.Data.Migrations b.Navigation("Statuts"); }); + + modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => + { + b.Navigation("Elements"); + + b.Navigation("SousSeries"); + }); #pragma warning restore 612, 618 } } diff --git a/MaBibli.Api/Endpoints/SeriesEndpoints.cs b/MaBibli.Api/Endpoints/SeriesEndpoints.cs new file mode 100644 index 0000000..0989cea --- /dev/null +++ b/MaBibli.Api/Endpoints/SeriesEndpoints.cs @@ -0,0 +1,151 @@ +using MaBibli.Api.Services.Identite; +using MaBibli.Api.Services.Series; +using MaBibli.Shared.Dtos; + +namespace MaBibli.Api.Endpoints; + +public static class SeriesEndpoints +{ + /// + /// Sagas, cycles et séries : ce qui se lit dans un ordre, et ce qui manque pour le lire. + /// + /// + /// ⚠️ Aucun point d'entrée n'accepte ni ne rend d'identité, à une exception près : + /// la mise en envies. Les séries sont communes au foyer, comme le catalogue et les + /// prêts — l'ordre de lecture d'une saga ne dépend pas de qui regarde. La liste d'envies, + /// elle, reste personnelle, et c'est le serveur qui sait à qui il parle. + /// + /// La lecture est unique : GET /api/series rend tout l'arbre à plat. Le détail + /// d'une série s'y lit sans second appel, ce qui donne un seul instantané hors-ligne à + /// tenir à jour plutôt qu'un par série consultée. + /// + /// + public static IEndpointRouteBuilder MapSeriesEndpoints(this IEndpointRouteBuilder routes) + { + var groupe = routes.MapGroup("/api/series").WithTags("Séries"); + + groupe.MapGet("/", async (IServiceSeries service, CancellationToken ct) => + Results.Ok(await service.ListerAsync(ct))) + .WithName("ListerSeries") + .WithSummary("Toutes les séries, à plat, chacune avec ses tomes dans l'ordre de lecture.") + .WithDescription( + "Le client rebâtit l'arbre à partir de « serieParenteId ». Un tome sans " + + "« livreId » est un tome qu'on ne possède pas : c'est ce que l'écran montre.") + .Produces>(); + + groupe.MapPost("/", async ( + EnregistrementSerie saisie, + IServiceSeries service, + IFournisseurUtilisateur utilisateurs, + CancellationToken ct) => + { + var resultat = await service.CreerAsync(saisie, utilisateurs.Obtenir().Identifiant, ct); + + return resultat.Erreur is not null + ? Results.BadRequest(new { erreur = resultat.Erreur }) + : Results.Created($"/api/series/{resultat.Serie!.Id}", resultat.Serie); + }) + .WithName("CreerSerie") + .WithSummary("Crée une série. « AjoutePar » est déterminé par le serveur, et n'est qu'une trace.") + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest); + + groupe.MapPut("/{id:int}", async ( + int id, EnregistrementSerie saisie, IServiceSeries service, CancellationToken ct) => + Repondre(await service.ModifierAsync(id, saisie, ct))) + .WithName("ModifierSerie") + .WithSummary("Renomme la série, ou la range dans un cycle.") + .Produces() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + groupe.MapDelete("/{id:int}", async (int id, IServiceSeries service, CancellationToken ct) => + await service.SupprimerAsync(id, ct) ? Results.NoContent() : Results.NotFound()) + .WithName("SupprimerSerie") + .WithSummary("Supprime la série. Ses sous-séries remontent à la racine, elles ne sont pas détruites.") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound); + + groupe.MapPost("/{id:int}/elements", async ( + int id, AjoutElementSerie saisie, IServiceSeries service, CancellationToken ct) => + Repondre(await service.AjouterElementAsync(id, saisie, ct))) + .WithName("AjouterElementSerie") + .WithSummary("Ajoute un tome en fin de série : un livre du catalogue, ou un simple titre.") + .Produces() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + groupe.MapPut("/{id:int}/ordre", async ( + int id, OrdreElementsSerie ordre, IServiceSeries service, CancellationToken ct) => + Repondre(await service.ReordonnerAsync(id, ordre.Ids, ct))) + .WithName("ReordonnerSerie") + .WithSummary("Fixe l'ordre de lecture — la liste entière des tomes, dans l'ordre voulu.") + .Produces() + .Produces(StatusCodes.Status404NotFound); + + groupe.MapPut("/elements/{elementId:int}/livre", async ( + int elementId, + RattachementLivre rattachement, + IServiceSeries service, + CancellationToken ct) => + Repondre(await service.RattacherLivreAsync(elementId, rattachement.LivreId, ct))) + .WithName("RattacherLivreSerie") + .WithSummary("Rattache un livre possédé à une place, ou l'en détache (« livreId » nul).") + .WithDescription( + "Détacher laisse un trou NOMMÉ : le titre du tome est conservé, il ne dépend " + + "pas de l'exemplaire.") + .Produces() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + groupe.MapDelete("/elements/{elementId:int}", async ( + int elementId, IServiceSeries service, CancellationToken ct) => + await service.RetirerElementAsync(elementId, ct) + ? Results.NoContent() + : Results.NotFound()) + .WithName("RetirerElementSerie") + .WithSummary("Retire une place de la série. Le livre, lui, reste au catalogue.") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound); + + groupe.MapPost("/elements/{elementId:int}/souhait", async ( + int elementId, + IServiceSeries service, + IFournisseurUtilisateur utilisateurs, + CancellationToken ct) => + { + // ⚠️ Le seul endroit où une série (commune) touche à une liste d'envies + // (personnelle). L'envie créée est celle de l'appelant, jamais celle du foyer. + var resultat = await service.MettreEnEnviesAsync( + elementId, utilisateurs.Obtenir().Identifiant, ct); + + return resultat.Erreur is not null + ? Results.BadRequest(new { erreur = resultat.Erreur }) + : Results.Ok(resultat.Souhait); + }) + .WithName("SouhaiterTomeManquant") + .WithSummary("Met un tome manquant dans la liste d'envies de l'appelant.") + .Produces() + .Produces(StatusCodes.Status400BadRequest); + + return routes; + } + + private static IResult Repondre(ResultatSerie resultat) + { + if (resultat.EstIntrouvable) + { + return Results.NotFound(); + } + + return resultat.Erreur is not null + ? Results.BadRequest(new { erreur = resultat.Erreur }) + : Results.Ok(resultat.Serie); + } +} + +/// Charge utile du rattachement d'un livre à une place. null détache. +public record RattachementLivre +{ + public int? LivreId { get; set; } +} diff --git a/MaBibli.Api/Program.cs b/MaBibli.Api/Program.cs index 96b200f..cad3fbb 100644 --- a/MaBibli.Api/Program.cs +++ b/MaBibli.Api/Program.cs @@ -4,6 +4,7 @@ using MaBibli.Api.Services.Catalogue; using MaBibli.Api.Services.Identite; using MaBibli.Api.Services.Isbn; using MaBibli.Api.Services.Prets; +using MaBibli.Api.Services.Series; using MaBibli.Api.Services.Souhaits; using Microsoft.EntityFrameworkCore; @@ -19,6 +20,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Identité : lue dans les en-têtes injectés par SSOwat, avec repli sur un utilisateur simulé // configuré en développement (il n'y a pas de portail YunoHost en local). @@ -77,6 +79,7 @@ app.MapLivresEndpoints(); app.MapAuteursEndpoints(); app.MapPretsEndpoints(); app.MapSouhaitsEndpoints(); +app.MapSeriesEndpoints(); app.MapBibliographieEndpoints(); app.MapIdentiteEndpoints(); diff --git a/MaBibli.Api/Services/Series/ServiceSeries.cs b/MaBibli.Api/Services/Series/ServiceSeries.cs new file mode 100644 index 0000000..b95aefe --- /dev/null +++ b/MaBibli.Api/Services/Series/ServiceSeries.cs @@ -0,0 +1,450 @@ +using MaBibli.Api.Data; +using MaBibli.Api.Services.Souhaits; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; +using MaBibli.Shared.Textes; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Api.Services.Series; + +/// Issue d'une écriture sur une série : la série, ou un message pour l'utilisateur. +public readonly record struct ResultatSerie(SerieDto? Serie, string? Erreur) +{ + public static ResultatSerie Ok(SerieDto serie) => new(serie, null); + + public static ResultatSerie Invalide(string message) => new(null, message); + + /// Ni série ni erreur : la ressource demandée n'existe pas. + public static readonly ResultatSerie Introuvable = new(null, null); + + public bool EstIntrouvable => Serie is null && Erreur is null; +} + +public interface IServiceSeries +{ + Task> ListerAsync(CancellationToken ct = default); + + Task CreerAsync( + EnregistrementSerie saisie, string? utilisateur, CancellationToken ct = default); + + Task ModifierAsync( + int id, EnregistrementSerie saisie, CancellationToken ct = default); + + Task SupprimerAsync(int id, CancellationToken ct = default); + + Task AjouterElementAsync( + int serieId, AjoutElementSerie saisie, CancellationToken ct = default); + + Task RattacherLivreAsync(int elementId, int? livreId, CancellationToken ct = default); + + Task RetirerElementAsync(int elementId, CancellationToken ct = default); + + Task ReordonnerAsync( + int serieId, IReadOnlyList idsOrdonnes, CancellationToken ct = default); + + /// Bascule un tome manquant vers la liste d'envies de l'appelant. + Task MettreEnEnviesAsync( + int elementId, string? utilisateur, CancellationToken ct = default); +} + +/// +/// Sagas, cycles et séries : ce qui se lit dans un ordre, et ce qui manque pour le lire. +/// +/// +/// ⚠️ Portée COMMUNE au foyer, comme le catalogue et les prêts. Aucune méthode ne prend +/// d'utilisateur pour lire ou écrire une série — l'ordre de lecture d'une saga ne change pas +/// selon qui regarde. La seule exception est , qui écrit dans +/// une liste d'envies, et qui est donc obligée de savoir de qui elle parle : c'est là que +/// les deux portées se rencontrent, et nulle part ailleurs. +/// +public sealed class ServiceSeries(MaBibliDbContext db, IServiceSouhaits souhaits) : IServiceSeries +{ + public async Task> ListerAsync(CancellationToken ct = default) + { + // AUCUN filtre sur AjoutePar : les séries sont communes, comme le catalogue. + var series = await db.Series + .AsNoTracking() + .Include(s => s.Elements).ThenInclude(e => e.Livre).ThenInclude(l => l!.Auteurs) + .ThenInclude(la => la.Auteur) + .ToListAsync(ct); + + var livreIds = series + .SelectMany(s => s.Elements) + .Select(e => e.LivreId) + .OfType() + .ToList(); + + var prets = await PretsOuvertsAsync(livreIds, ct); + + return series + .OrderBy(s => s.Position) + .ThenBy(s => s.TitreNormalise, StringComparer.Ordinal) + .Select(s => Projeter(s, prets)) + .ToList(); + } + + public async Task CreerAsync( + EnregistrementSerie saisie, string? utilisateur, CancellationToken ct = default) + { + var titre = saisie.Titre?.Trim(); + if (string.IsNullOrWhiteSpace(titre)) + { + return ResultatSerie.Invalide("Le titre de la série est obligatoire."); + } + + var normalise = NormalisationTexte.Normaliser(titre); + if (await db.Series.AnyAsync(s => s.TitreNormalise == normalise, ct)) + { + // Message plutôt qu'index unique brut : « UNIQUE constraint failed » ne veut rien + // dire pour l'utilisateur. L'index reste là comme garantie de dernier recours. + return ResultatSerie.Invalide($"La série « {titre} » existe déjà."); + } + + if (saisie.SerieParenteId is { } parenteId + && !await db.Series.AnyAsync(s => s.Id == parenteId, ct)) + { + return ResultatSerie.Invalide("La série parente n'existe pas."); + } + + var serie = new Serie + { + Titre = titre, + SerieParenteId = saisie.SerieParenteId, + DateAjout = DateTime.UtcNow, + + // Trace de saisie, jamais un cloisonnement — comme Livre.AjoutePar. + AjoutePar = utilisateur, + }; + + serie.RecalculerFormes(); + serie.Position = await ProchainePositionAsync(saisie.SerieParenteId, ct); + + db.Series.Add(serie); + await db.SaveChangesAsync(ct); + + return await RelireAsync(serie.Id, ct); + } + + public async Task ModifierAsync( + int id, EnregistrementSerie saisie, CancellationToken ct = default) + { + var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id, ct); + if (serie is null) + { + return ResultatSerie.Introuvable; + } + + var titre = saisie.Titre?.Trim(); + if (string.IsNullOrWhiteSpace(titre)) + { + return ResultatSerie.Invalide("Le titre de la série est obligatoire."); + } + + var normalise = NormalisationTexte.Normaliser(titre); + if (await db.Series.AnyAsync(s => s.Id != id && s.TitreNormalise == normalise, ct)) + { + return ResultatSerie.Invalide($"La série « {titre} » existe déjà."); + } + + if (saisie.SerieParenteId is { } parenteId) + { + if (!await db.Series.AnyAsync(s => s.Id == parenteId, ct)) + { + return ResultatSerie.Invalide("La série parente n'existe pas."); + } + + if (await EstDescendanteAsync(parenteId, id, ct)) + { + // Sans ce garde-fou, une série deviendrait sa propre ancêtre : l'arbre se + // refermerait sur lui-même et l'affichage boucherait à l'infini. + return ResultatSerie.Invalide( + "Une série ne peut pas être rangée dans elle-même, ni dans l'une des siennes."); + } + } + + if (serie.SerieParenteId != saisie.SerieParenteId) + { + serie.SerieParenteId = saisie.SerieParenteId; + serie.Position = await ProchainePositionAsync(saisie.SerieParenteId, ct); + } + + serie.Titre = titre; + serie.RecalculerFormes(); + + await db.SaveChangesAsync(ct); + return await RelireAsync(id, ct); + } + + public async Task SupprimerAsync(int id, CancellationToken ct = default) + { + var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id, ct); + if (serie is null) + { + return false; + } + + // Les éléments partent en cascade — ce ne sont que des positions. Les sous-séries, elles, + // remontent à la racine (SetNull) : ce sont de vraies séries de vrais livres. + db.Series.Remove(serie); + await db.SaveChangesAsync(ct); + return true; + } + + public async Task AjouterElementAsync( + int serieId, AjoutElementSerie saisie, CancellationToken ct = default) + { + var serie = await db.Series.Include(s => s.Elements).FirstOrDefaultAsync(s => s.Id == serieId, ct); + if (serie is null) + { + return ResultatSerie.Introuvable; + } + + var titre = saisie.Titre?.Trim(); + + if (saisie.LivreId is { } livreId) + { + var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == livreId, ct); + if (livre is null) + { + return ResultatSerie.Invalide("Ce livre n'existe plus."); + } + + if (serie.Elements.Any(e => e.LivreId == livreId)) + { + return ResultatSerie.Invalide($"« {livre.Titre} » est déjà dans cette série."); + } + + // Le titre du tome est copié du livre : il doit survivre à sa suppression, faute de + // quoi la saga se retrouverait avec une place muette. + titre = string.IsNullOrWhiteSpace(titre) ? livre.Titre : titre; + } + + if (string.IsNullOrWhiteSpace(titre)) + { + return ResultatSerie.Invalide("Le titre du tome est obligatoire."); + } + + var element = new ElementSerie + { + SerieId = serieId, + LivreId = saisie.LivreId, + Titre = titre, + Position = serie.Elements.Count == 0 ? 0 : serie.Elements.Max(e => e.Position) + 1, + }; + + element.RecalculerFormes(); + db.ElementsSerie.Add(element); + await db.SaveChangesAsync(ct); + + return await RelireAsync(serieId, ct); + } + + public async Task RattacherLivreAsync( + int elementId, int? livreId, CancellationToken ct = default) + { + var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct); + if (element is null) + { + return ResultatSerie.Introuvable; + } + + if (livreId is { } id) + { + var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id, ct); + if (livre is null) + { + return ResultatSerie.Invalide("Ce livre n'existe plus."); + } + + var deja = await db.ElementsSerie.AnyAsync( + e => e.SerieId == element.SerieId && e.LivreId == id && e.Id != elementId, ct); + + if (deja) + { + return ResultatSerie.Invalide($"« {livre.Titre} » occupe déjà une place dans cette série."); + } + } + + // Le titre saisi est conservé : c'est celui du tome, pas celui de l'exemplaire. Détacher + // un livre laisse donc un trou nommé, et non une ligne vide. + element.LivreId = livreId; + await db.SaveChangesAsync(ct); + + return await RelireAsync(element.SerieId, ct); + } + + public async Task RetirerElementAsync(int elementId, CancellationToken ct = default) + { + var element = await db.ElementsSerie.FirstOrDefaultAsync(e => e.Id == elementId, ct); + if (element is null) + { + return false; + } + + db.ElementsSerie.Remove(element); + await db.SaveChangesAsync(ct); + return true; + } + + public async Task ReordonnerAsync( + int serieId, IReadOnlyList idsOrdonnes, CancellationToken ct = default) + { + var elements = await db.ElementsSerie.Where(e => e.SerieId == serieId).ToListAsync(ct); + if (elements.Count == 0) + { + return await db.Series.AnyAsync(s => s.Id == serieId, ct) + ? await RelireAsync(serieId, ct) + : ResultatSerie.Introuvable; + } + + // Même prudence que pour la liste d'envies : on relit les éléments DE CETTE SÉRIE et on + // ne se fie pas à ce que le client envoie. Un identifiant étranger est ignoré, et un + // tome absent de l'ordre reçu est conservé à la suite — la liste du client peut être + // périmée, et mal classé vaut infiniment mieux que disparu. + var parId = elements.ToDictionary(e => e.Id); + var position = 0; + + foreach (var id in idsOrdonnes.Distinct()) + { + if (parId.Remove(id, out var element)) + { + element.Position = position++; + } + } + + foreach (var oublie in parId.Values.OrderBy(e => e.Position).ThenBy(e => e.Id)) + { + oublie.Position = position++; + } + + await db.SaveChangesAsync(ct); + return await RelireAsync(serieId, ct); + } + + public async Task MettreEnEnviesAsync( + int elementId, string? utilisateur, CancellationToken ct = default) + { + var element = await db.ElementsSerie + .AsNoTracking() + .FirstOrDefaultAsync(e => e.Id == elementId, ct); + + if (element is null) + { + return ResultatSouhait.Invalide("Ce tome n'existe plus."); + } + + if (element.LivreId is not null) + { + // Souhaiter ce qu'on a déjà n'aurait aucun sens, et la liste d'envies le signalerait + // aussitôt comme « déjà au catalogue ». + return ResultatSouhait.Invalide($"« {element.Titre} » est déjà dans votre bibliothèque."); + } + + // ⚠️ Ici et seulement ici, une série (commune) écrit dans une liste d'envies + // (personnelle). L'envie est donc celle de l'APPELANT, jamais celle du foyer : deux + // membres peuvent vouloir le même tome manquant, chacun dans sa liste. + // Aucun auteur n'est transmis : une position dans une saga n'en porte pas, et en + // inventer un fausserait le rapprochement avec le catalogue. + return await souhaits.AjouterAsync( + new EnregistrementSouhait { Titre = element.Titre }, utilisateur, ct); + } + + /// Prêts en cours des livres concernés. Communs au foyer, comme partout ailleurs. + private async Task> PretsOuvertsAsync( + IReadOnlyList livreIds, CancellationToken ct) + { + if (livreIds.Count == 0) + { + return []; + } + + return await db.Prets + .AsNoTracking() + .Where(p => p.DateRetour == null && livreIds.Contains(p.LivreId)) + .ToDictionaryAsync(p => p.LivreId, p => p.Emprunteur, ct); + } + + private async Task ProchainePositionAsync(int? parenteId, CancellationToken ct) + { + var derniere = await db.Series + .Where(s => s.SerieParenteId == parenteId) + .Select(s => (int?)s.Position) + .MaxAsync(ct); + + return (derniere ?? -1) + 1; + } + + /// est-elle ou l'une de ses filles ? + private async Task EstDescendanteAsync(int candidate, int serieId, CancellationToken ct) + { + var courante = (int?)candidate; + + // Remontée bornée par le nombre de séries : même si une boucle existait déjà en base, + // cette fonction s'arrête au lieu de tourner indéfiniment. + var garde = await db.Series.CountAsync(ct) + 1; + + while (courante is { } id && garde-- > 0) + { + if (id == serieId) + { + return true; + } + + courante = await db.Series + .Where(s => s.Id == id) + .Select(s => s.SerieParenteId) + .FirstOrDefaultAsync(ct); + } + + return false; + } + + private async Task RelireAsync(int serieId, CancellationToken ct) + { + var serie = await db.Series + .AsNoTracking() + .Include(s => s.Elements).ThenInclude(e => e.Livre).ThenInclude(l => l!.Auteurs) + .ThenInclude(la => la.Auteur) + .FirstOrDefaultAsync(s => s.Id == serieId, ct); + + if (serie is null) + { + return ResultatSerie.Introuvable; + } + + var prets = await PretsOuvertsAsync( + serie.Elements.Select(e => e.LivreId).OfType().ToList(), ct); + + return ResultatSerie.Ok(Projeter(serie, prets)); + } + + private static SerieDto Projeter(Serie serie, IReadOnlyDictionary prets) => new() + { + Id = serie.Id, + Titre = serie.Titre, + SerieParenteId = serie.SerieParenteId, + Position = serie.Position, + AjoutePar = serie.AjoutePar, + Elements = serie.Elements + .OrderBy(e => e.Position) + .ThenBy(e => e.Id) + .Select(e => new ElementSerieDto + { + Id = e.Id, + Position = e.Position, + + // Le titre du livre prime quand il est là : c'est lui qui a pu être corrigé + // depuis la saisie du tome. Le titre stocké reste le filet en cas de suppression. + Titre = e.Livre?.Titre ?? e.Titre, + LivreId = e.LivreId, + Auteurs = e.Livre is null || e.Livre.Auteurs.Count == 0 + ? null + : string.Join(", ", e.Livre.Auteurs + .OrderBy(la => la.Position) + .Select(la => la.Auteur?.Nom) + .Where(nom => !string.IsNullOrWhiteSpace(nom))), + PreteA = e.LivreId is { } id && prets.TryGetValue(id, out var qui) ? qui : null, + }) + .ToList(), + }; +} diff --git a/MaBibli.Client/Layout/MainLayout.razor b/MaBibli.Client/Layout/MainLayout.razor index 353a56d..e288018 100644 --- a/MaBibli.Client/Layout/MainLayout.razor +++ b/MaBibli.Client/Layout/MainLayout.razor @@ -44,7 +44,7 @@ @* - Navigation permanente, en bas de l'écran : quatre onglets, toujours au même endroit. + Navigation permanente, en bas de l'écran : cinq onglets, toujours au même endroit. Elle remplace les listes de liens que chaque page portait dans sa barre d'actions — cinq boutons y passaient sur deux ou trois lignes sur un téléphone, et l'entrée « Envies » ne figurait pas partout. Les barres d'actions restantes ne portent plus que des ACTIONS. @@ -53,12 +53,13 @@ qu'un rang d'onglets sous le bandeau. Les actions de page se placent juste au-dessus (variable --mb-onglets-hauteur, partagée avec .actions-flottantes). - Les onglets restent actifs hors-ligne : les quatre écrans se consultent depuis leurs + Les onglets restent actifs hors-ligne : les cinq écrans se consultent depuis leurs instantanés. Ce sont les écritures qui se désactivent, pas la navigation. *@ diff --git a/MaBibli.Client/Layout/MainLayout.razor.css b/MaBibli.Client/Layout/MainLayout.razor.css index 77f201d..14dfa78 100644 --- a/MaBibli.Client/Layout/MainLayout.razor.css +++ b/MaBibli.Client/Layout/MainLayout.razor.css @@ -86,13 +86,14 @@ line-height: 1.4; text-align: center; text-decoration: none; - font-size: 0.85rem; + font-size: 0.8rem; color: var(--mb-texte-doux); /* Le trait actif est posé en haut de l'onglet, du côté du contenu qu'il désigne. */ border-top: 3px solid transparent; - /* Le libellé ne doit jamais passer sur deux lignes : quatre onglets tiennent sur 320 px. */ + /* Le libellé ne doit jamais passer sur deux lignes. Cinq onglets tiennent sur 320 px à + cette taille ; c'est la raison du 0.8rem plutôt que du 0.85rem d'avant « Séries ». */ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/MaBibli.Client/Pages/FicheLivre.razor b/MaBibli.Client/Pages/FicheLivre.razor index 433892a..1c3fca8 100644 --- a/MaBibli.Client/Pages/FicheLivre.razor +++ b/MaBibli.Client/Pages/FicheLivre.razor @@ -109,6 +109,19 @@ else

} + @* + Les séries auxquelles ce livre appartient. Il peut y en avoir plusieurs — une + trilogie, une intégrale, un cycle — d'où une liste et non un champ. + ⚠️ Lu depuis l'instantané des séries, donc disponible hors-ligne comme le reste. + *@ + @foreach (var appartenance in _appartenances) + { +

+ Tome @appartenance.Rang de + @appartenance.Serie.Titre +

+ } +
@if (!string.IsNullOrWhiteSpace(_livre.Editeur)) { @@ -180,6 +193,35 @@ else [Parameter] public int Id { get; set; } private LivreDto? _livre; + + /// Places de ce livre dans des séries : la série, et son rang de lecture. + private IReadOnlyList _appartenances = []; + + private sealed record AppartenanceSerie(SerieDto Serie, int Rang); + + /// + /// Cherche ce livre dans les séries. + /// + /// + /// Une seule lecture rend tout l'arbre, et elle est déjà en cache : chercher côté client + /// évite un point d'entrée « séries d'un livre » qui n'aurait servi qu'ici, et qui aurait + /// eu son propre instantané à tenir. + /// + /// Le rang affiché est la position dans l'ordre de lecture, à partir de 1 — pas un + /// numéro de tome imprimé sur la couverture, que le modèle ne connaît pas. + /// + /// + private async Task ChargerAppartenancesAsync() + { + var series = await Api.ListerSeriesAsync(); + + _appartenances = series + .SelectMany(serie => serie.Elements + .Select((element, index) => (serie, element, index)) + .Where(x => x.element.LivreId == Id) + .Select(x => new AppartenanceSerie(x.serie, x.index + 1))) + .ToList(); + } private EnregistrementLivre? _saisie; private bool _introuvable; private bool _enregistrement; @@ -252,6 +294,7 @@ else try { _livre = await Api.ObtenirAsync(Id); + await ChargerAppartenancesAsync(); } catch (Exception ex) { diff --git a/MaBibli.Client/Pages/Serie.razor b/MaBibli.Client/Pages/Serie.razor new file mode 100644 index 0000000..5b049e4 --- /dev/null +++ b/MaBibli.Client/Pages/Serie.razor @@ -0,0 +1,534 @@ +@page "/series/{Id:int}" +@inject ServiceLivresApi Api +@inject NavigationManager Navigation +@inject EtatReseau Reseau +@implements IDisposable + +MaBibli — @(_serie?.Titre ?? "série") + +@if (_serie is null) +{ +

+ @(_chargement ? "Chargement…" : "Cette série n'est pas dans les données enregistrées sur cet appareil.") +

+} +else +{ + @if (_parente is { } parente) + { +

+ Fait partie de @parente.Titre. +

+ } + +

@_serie.Titre

+ +

+ @if (_serie.Elements.Count == 0) + { + Aucun tome recensé. Ajoutez ceux que vous possédez, et ceux qui vous manquent. + } + else + { + @_serie.NombrePossedes sur @_serie.Elements.Count tomes dans la bibliothèque. + } +

+ + @if (_erreur is not null) + { + + } + + @* La bascule vers les envies ne change rien à l'écran : sans un mot, on ne saurait pas + qu'elle a eu lieu, et on cliquerait deux fois. *@ + @if (_message is not null) + { +

@_message

+ } + + @* Les sous-séries d'un cycle : on les liste, on ne les déplie pas — chacune a son écran. *@ + @if (_filles.Count > 0) + { +

Séries de ce cycle

+
    + @foreach (var fille in _filles) + { +
  • + @fille.Titre + + @fille.NombrePossedes sur @fille.Elements.Count + +
  • + } +
+ } + + @if (_serie.Elements.Count > 0) + { +

Ordre de lecture

+ + @* + ⚠️ L'ordre est STOCKÉ, jamais déduit d'une année de publication : une préquelle se + lit avant le livre paru dix ans plus tôt. Les flèches sont le seul moyen de le + changer — le glisser-déposer HTML5 ne fonctionne pas au doigt, et c'est sur + téléphone qu'on consulte une saga en librairie. + *@ +
    + @foreach (var (element, index) in _serie.Elements.Select((e, i) => (e, i))) + { +
  • +
    + + + + + +
    + +
    + @if (element.LivreId is { } livreId) + { + @element.Titre + } + else + { +

    @element.Titre

    + } + + @if (!string.IsNullOrWhiteSpace(element.Auteurs)) + { +

    @element.Auteurs

    + } + +

    + @if (!element.Possede) + { + @* Dire ce qui manque, et non laisser deviner par l'absence de lien. *@ + Pas dans la bibliothèque + } + + @if (element.PreteA is { } emprunteur) + { + Prêté à @emprunteur + } +

    + +
    + @if (!element.Possede) + { + @* Le tome manquant est COMMUN au foyer, l'envie qu'il devient est + PERSONNELLE : deux membres peuvent vouloir le même tome. *@ + + + + } + else + { + + } + + +
    + + @if (_rattachement == element.Id) + { +
    + + + @foreach (var livre in _resultats) + { + + } +
    + } +
    +
  • + } +
+ } + +

Ajouter un tome

+ + @* + Deux voies, parce qu'une saga se recense d'un coup — souvent avant d'en posséder la + moitié — puis se remplit au fil des achats. Un tome saisi sans livre N'EST PAS un + brouillon : c'est ce qui rend le trou visible. + *@ +
+ +
+ +
+ + + +
+ + @if (_rattachement == AjoutDepuisCatalogue) + { +
+ + + @foreach (var livre in _resultats) + { + + } +
+ } + +

La série

+ + + + + +
+ + + +
+ + @if (_confirmeSuppression) + { +

+ Les livres restent au catalogue ; seules les places disparaissent. + @if (_filles.Count > 0) + { + Les @_filles.Count série(s) de ce cycle deviendront des séries de premier niveau. + } +

+ } +} + +@code { + /// Marqueur de la recherche « ajouter depuis le catalogue », qui ne vise aucun élément. + private const int AjoutDepuisCatalogue = -1; + + [Parameter] public int Id { get; set; } + + private IReadOnlyList _toutes = []; + private SerieDto? _serie; + private SerieDto? _parente; + private List _filles = []; + + private bool _chargement = true; + private bool _enCours; + private string? _erreur; + + private string _titre = string.Empty; + private int _parenteId; + private bool _confirmeSuppression; + + private string _nouveauTome = string.Empty; + + private int? _rattachement; + private string _recherche = string.Empty; + private IReadOnlyList _resultats = []; + + private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; + + /// + /// Séries dans lesquelles celle-ci peut être rangée. + /// + /// + /// Ni elle-même, ni ses descendantes : l'arbre se refermerait sur lui-même. Le serveur le + /// refuse aussi — cette liste évite seulement de proposer un choix qui sera rejeté. + /// + private IEnumerable Candidates => + _toutes.Where(s => s.Id != Id && !EstDescendante(s.Id)); + + private bool EstDescendante(int candidate) + { + var courante = _toutes.FirstOrDefault(s => s.Id == candidate); + var garde = _toutes.Count + 1; + + while (courante is not null && garde-- > 0) + { + if (courante.Id == Id) + { + return true; + } + + courante = courante.SerieParenteId is { } parenteId + ? _toutes.FirstOrDefault(s => s.Id == parenteId) + : null; + } + + return false; + } + + protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + + private void SurChangementReseau() => _ = InvokeAsync(async () => + { + await ChargerAsync(); + StateHasChanged(); + }); + + public void Dispose() => Reseau.Change -= SurChangementReseau; + + protected override Task OnParametersSetAsync() => ChargerAsync(); + + private async Task ChargerAsync() + { + _chargement = true; + _toutes = await Api.ListerSeriesAsync(); + _chargement = false; + + Rattacher(); + } + + /// Recale les vues dérivées après un chargement ou une écriture. + private void Rattacher() + { + _serie = _toutes.FirstOrDefault(s => s.Id == Id); + + if (_serie is null) + { + return; + } + + _parente = _serie.SerieParenteId is { } parenteId + ? _toutes.FirstOrDefault(s => s.Id == parenteId) + : null; + + _filles = _toutes.Where(s => s.SerieParenteId == Id).OrderBy(s => s.Position).ToList(); + + _titre = _serie.Titre; + _parenteId = _serie.SerieParenteId ?? 0; + } + + /// Remplace la série courante par celle que vient de rendre le serveur. + private void Appliquer(SerieDto serie) + { + _toutes = _toutes.Select(s => s.Id == serie.Id ? serie : s).ToList(); + Rattacher(); + } + + private async Task SurToucheTome(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouveauTome) && Reseau.EnLigne) + { + await AjouterTomeAsync(null); + } + } + + private async Task AjouterTomeAsync(int? livreId) + { + await EcrireAsync(() => Api.AjouterElementSerieAsync( + Id, new AjoutElementSerie { LivreId = livreId, Titre = livreId is null ? _nouveauTome : null })); + + if (_erreur is null) + { + _nouveauTome = string.Empty; + FermerRecherche(); + } + } + + private Task RattacherAsync(int elementId, int? livreId) => + EcrireAsync(async () => + { + var resultat = await Api.RattacherLivreSerieAsync(elementId, livreId); + FermerRecherche(); + return resultat; + }); + + private async Task RetirerAsync(ElementSerieDto element) + { + _enCours = true; + _erreur = await Api.RetirerElementSerieAsync(element.Id); + _enCours = false; + + if (_erreur is null) + { + await ChargerAsync(); + } + } + + private async Task SouhaiterAsync(ElementSerieDto element) + { + _enCours = true; + _erreur = null; + + var resultat = await Api.SouhaiterTomeAsync(element.Id); + + _enCours = false; + _erreur = resultat.Erreur; + + if (resultat.EstOk) + { + _message = $"« {element.Titre} » est dans votre liste d'envies."; + } + } + + private string? _message; + + private async Task DeplacerAsync(int depuis, int vers) + { + if (_serie is null || vers < 0 || vers >= _serie.Elements.Count) + { + return; + } + + // Réordonné localement d'abord : la flèche doit répondre tout de suite, l'aller-retour + // réseau ne doit pas se voir. Un échec recharge l'ordre du serveur. + var ids = _serie.Elements.Select(e => e.Id).ToList(); + (ids[depuis], ids[vers]) = (ids[vers], ids[depuis]); + + await EcrireAsync(() => Api.ReordonnerSerieAsync(Id, ids)); + } + + private async Task EnregistrerAsync() + { + await EcrireAsync(() => Api.ModifierSerieAsync( + Id, new EnregistrementSerie { Titre = _titre, SerieParenteId = _parenteId == 0 ? null : _parenteId })); + + // Le rangement dans un cycle change la parenté des AUTRES séries affichées : on relit. + if (_erreur is null) + { + await ChargerAsync(); + } + } + + private async Task SupprimerAsync() + { + if (!_confirmeSuppression) + { + _confirmeSuppression = true; + return; + } + + _enCours = true; + _erreur = await Api.SupprimerSerieAsync(Id); + _enCours = false; + + if (_erreur is null) + { + Navigation.NavigateTo("series"); + } + } + + private void OuvrirRattachement(int cible) + { + _rattachement = cible; + _recherche = string.Empty; + _resultats = []; + } + + private void FermerRecherche() + { + _rattachement = null; + _recherche = string.Empty; + _resultats = []; + } + + private async Task ChercherAsync(ChangeEventArgs e) + { + _recherche = e.Value?.ToString() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(_recherche)) + { + _resultats = []; + return; + } + + // La recherche passe par le service habituel : hors-ligne elle interroge l'instantané, + // en ligne le serveur. Rien de particulier à prévoir ici. + _resultats = (await Api.ListerAsync(new CritereLivres { Recherche = _recherche })) + .Take(8) + .ToList(); + } + + /// Exécute une écriture qui rend la série, en tenant l'état d'attente et l'erreur. + private async Task EcrireAsync(Func>> appel) + { + _enCours = true; + _erreur = null; + _message = null; + + var resultat = await appel(); + + _enCours = false; + + if (!resultat.EstOk) + { + _erreur = resultat.Erreur; + + // L'ordre affiché a pu être modifié localement avant l'appel : on reprend celui du + // serveur plutôt que de laisser croire à un enregistrement. + await ChargerAsync(); + return; + } + + Appliquer(resultat.Valeur!); + } +} diff --git a/MaBibli.Client/Pages/Series.razor b/MaBibli.Client/Pages/Series.razor new file mode 100644 index 0000000..56547ff --- /dev/null +++ b/MaBibli.Client/Pages/Series.razor @@ -0,0 +1,142 @@ +@page "/series" +@inject ServiceLivresApi Api +@inject EtatReseau Reseau +@implements IDisposable + +MaBibli — séries + +

Séries

+ +

+ Sagas, cycles et trilogies, dans leur ordre de lecture — qui n'est pas + toujours celui de publication. Les séries sont communes au foyer, comme le catalogue. +

+ +@if (_erreur is not null) +{ + +} + +@* Créer une série depuis la liste : c'est le seul endroit d'où l'on part, et un écran de plus + pour un unique champ ne se justifierait pas. *@ +
+ +
+ +
+ +
+ +@if (_series is null) +{ +

Chargement…

+} +else if (_series.Count == 0) +{ +

+ Aucune série pour l'instant. Créez-en une, puis ajoutez-y vos livres et les tomes + qui vous manquent : c'est ce qui permet de voir les trous. +

+} +else +{ +
    + @foreach (var serie in Racines) + { +
  • + @serie.Titre +

    @Avancement(serie)

    + + @* Un cycle contient des séries : elles se voient d'ici, sans ouvrir le cycle. *@ + @if (Filles(serie.Id) is { Count: > 0 } filles) + { +
      + @foreach (var fille in filles) + { +
    • + @fille.Titre + @Avancement(fille) +
    • + } +
    + } +
  • + } +
+} + +@code { + private IReadOnlyList? _series; + private string _nouveau = string.Empty; + private bool _enCours; + private string? _erreur; + + private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; + + /// Séries de premier niveau. Celles rangées dans un cycle s'affichent sous lui. + private IEnumerable Racines => + _series?.Where(s => s.SerieParenteId is null) ?? []; + + private List Filles(int parenteId) => + _series?.Where(s => s.SerieParenteId == parenteId).OrderBy(s => s.Position).ToList() ?? []; + + /// + /// « 4 tomes sur 7 » : le compteur est l'information principale de l'écran. + /// + /// + /// Une série sans aucun tome ne se décrit pas par « 0 sur 0 », qui ressemble à une erreur : + /// elle dit ce qu'il faut faire. + /// + private static string Avancement(SerieDto serie) => + serie.Elements.Count == 0 + ? "Aucun tome recensé" + : $"{serie.NombrePossedes} tome{(serie.NombrePossedes > 1 ? "s" : "")} sur {serie.Elements.Count}"; + + protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + + private void SurChangementReseau() => _ = InvokeAsync(async () => + { + await ChargerAsync(); + StateHasChanged(); + }); + + public void Dispose() => Reseau.Change -= SurChangementReseau; + + protected override Task OnInitializedAsync() => ChargerAsync(); + + private async Task ChargerAsync() => _series = await Api.ListerSeriesAsync(); + + private async Task SurTouche(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouveau) && Reseau.EnLigne) + { + await CreerAsync(); + } + } + + private async Task CreerAsync() + { + _enCours = true; + _erreur = null; + + var resultat = await Api.CreerSerieAsync(new EnregistrementSerie { Titre = _nouveau }); + + _enCours = false; + + if (!resultat.EstOk) + { + _erreur = resultat.Erreur; + return; + } + + _nouveau = string.Empty; + await ChargerAsync(); + } +} diff --git a/MaBibli.Client/Services/CacheHorsLigne.cs b/MaBibli.Client/Services/CacheHorsLigne.cs index 47f57c3..03e6058 100644 --- a/MaBibli.Client/Services/CacheHorsLigne.cs +++ b/MaBibli.Client/Services/CacheHorsLigne.cs @@ -34,6 +34,20 @@ public static class ClesCache /// /// public const string Souhaits = "souhaits"; + + /// + /// Toutes les séries, à plat, avec leurs tomes. + /// + /// + /// Un seul instantané sert la liste et le détail de chaque série, l'API rendant + /// l'arbre entier en une lecture. C'est ce qui évite un instantané par série consultée — + /// et surtout ce qui rend consultable hors-ligne une série qu'on n'avait pas ouverte avant + /// la coupure, exactement pour la même raison que le catalogue complet. + /// + /// Communes au foyer, contrairement à . + /// + /// + public const string Series = "series"; } /// Un instantané relu du cache, avec la date de la synchronisation qui l'a produit. diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs index 8491bce..57417bd 100644 --- a/MaBibli.Client/Services/ServiceLivresApi.cs +++ b/MaBibli.Client/Services/ServiceLivresApi.cs @@ -47,6 +47,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat await ListerAuteursAsync(ct); await ListerPretsEnCoursAsync(ct); await ListerSouhaitsAsync(ct); + await ListerSeriesAsync(ct); await ObtenirUtilisateurAsync(ct); return reseau.EnLigne; } @@ -516,6 +517,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat await ListerPretsEnCoursAsync(); await ListerAuteursAsync(); await ListerSouhaitsAsync(); + await ListerSeriesAsync(); } catch (Exception) { @@ -737,6 +739,102 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat : await LireAsync(reponse, ct); } + // ───────────────────────────────────────────────────────────────────────── + // Sagas, cycles et séries. COMMUNES au foyer, comme le catalogue : aucune identité ne + // transite, sauf pour la mise en envies, qui écrit dans une liste personnelle. + // ───────────────────────────────────────────────────────────────────────── + + /// + /// Toutes les séries, avec leurs tomes. Une seule lecture sert la liste et chaque détail. + /// + public async Task> ListerSeriesAsync(CancellationToken ct = default) + { + var (ok, series) = await EssayerAsync( + () => http.GetFromJsonAsync>("api/series", Json, ct)); + + if (ok) + { + await MemoriserAsync(ClesCache.Series, series ?? []); + return series ?? []; + } + + var instantane = await LireCacheAsync>(ClesCache.Series); + return instantane?.Donnees ?? []; + } + + public async Task> CreerSerieAsync( + EnregistrementSerie saisie, CancellationToken ct = default) => + await EcrireAsync(() => http.PostAsJsonAsync("api/series", saisie, Json, ct), ct); + + public async Task> ModifierSerieAsync( + int id, EnregistrementSerie saisie, CancellationToken ct = default) => + await EcrireAsync(() => http.PutAsJsonAsync($"api/series/{id}", saisie, Json, ct), ct); + + public async Task> AjouterElementSerieAsync( + int serieId, AjoutElementSerie saisie, CancellationToken ct = default) => + await EcrireAsync( + () => http.PostAsJsonAsync($"api/series/{serieId}/elements", saisie, Json, ct), ct); + + /// Rattache un livre possédé à une place, ou l'en détache avec null. + public async Task> RattacherLivreSerieAsync( + int elementId, int? livreId, CancellationToken ct = default) => + await EcrireAsync( + () => http.PutAsJsonAsync( + $"api/series/elements/{elementId}/livre", new { livreId }, Json, ct), ct); + + public async Task> ReordonnerSerieAsync( + int serieId, IReadOnlyList ids, CancellationToken ct = default) => + await EcrireAsync( + () => http.PutAsJsonAsync( + $"api/series/{serieId}/ordre", new OrdreElementsSerie { Ids = [.. ids] }, Json, ct), ct); + + /// Met un tome manquant dans ma liste d'envies — la série, elle, est commune. + public async Task> SouhaiterTomeAsync( + int elementId, CancellationToken ct = default) => + await EcrireAsync( + () => http.PostAsync($"api/series/elements/{elementId}/souhait", null, ct), ct); + + /// + /// Retire une place de la série, ou la série entière. null si c'est fait. + /// + /// + /// Comme les autres suppressions : réponse 204 No Content, donc pas de + /// désérialisation, donc pas . + /// + public Task RetirerElementSerieAsync(int elementId, CancellationToken ct = default) => + SupprimerAsync($"api/series/elements/{elementId}", ct); + + public Task SupprimerSerieAsync(int id, CancellationToken ct = default) => + SupprimerAsync($"api/series/{id}", ct); + + /// Suppression générique : refus hors-ligne, 204 attendu, instantanés rafraîchis. + private async Task SupprimerAsync(string url, CancellationToken ct) + { + if (!reseau.EnLigne) + { + return EtatReseau.MotifHorsLigne; + } + + try + { + var reponse = await http.DeleteAsync(url, ct); + reseau.SignalerSuccesReseau(); + + if (!reponse.IsSuccessStatusCode) + { + return await MessageErreurAsync(reponse, ct); + } + + _ = RafraichirEnFondAsync(); + return null; + } + catch (HttpRequestException) + { + reseau.SignalerEchecReseau(); + return EtatReseau.MotifHorsLigne; + } + } + private sealed record ErreurApi(string? Erreur); } diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index 9327e3d..bb35559 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -937,3 +937,81 @@ body { .onglets-secondaires .segment { flex: 1 1 0; } + +/* --- Sagas, cycles et séries --- */ + +.liste-series, +.liste-tomes, +.liste-sous-series { + list-style: none; + margin: 0.5rem 0; + padding: 0; +} + +.carte-serie { + padding: 0.7rem 0; + border-bottom: 1px solid var(--mb-bord); +} + +.liste-sous-series { + margin: 0.35rem 0 0 1rem; + font-size: 0.9rem; +} + +.liste-sous-series li { + padding: 0.2rem 0; + display: flex; + gap: 0.5rem; + align-items: baseline; + flex-wrap: wrap; +} + +.carte-tome { + display: flex; + gap: 0.6rem; + align-items: flex-start; + padding: 0.6rem 0; + border-bottom: 1px solid var(--mb-bord); +} + +/* Le tome absent est estompé, comme une œuvre déjà possédée l'est dans la bibliographie — + mais l'opacité ne dit rien à un lecteur d'écran, d'où l'étiquette qui l'accompagne. */ +.tome-manquant .carte-titre { + font-weight: 400; + color: var(--mb-texte-doux); +} + +.etiquette-manquant { + background: #f3e2e2; + color: #7a2f2f; + font-weight: 600; +} + +.actions-tome { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.35rem; +} + +/* Rattacher un livre à une place : la recherche s'ouvre SOUS le tome concerné, pour qu'on voie + ce qu'on est en train de compléter. */ +.recherche-livre { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin-top: 0.5rem; + padding: 0.5rem; + background: var(--mb-fond-doux); + border-radius: 6px; +} + +.recherche-livre .bouton { + text-align: left; +} + +.fiche-serie { + margin: 0.2rem 0; + font-size: 0.9rem; + color: var(--mb-texte-doux); +} diff --git a/MaBibli.Shared/Dtos/SerieDto.cs b/MaBibli.Shared/Dtos/SerieDto.cs new file mode 100644 index 0000000..3607292 --- /dev/null +++ b/MaBibli.Shared/Dtos/SerieDto.cs @@ -0,0 +1,95 @@ +namespace MaBibli.Shared.Dtos; + +/// +/// Une série telle qu'exposée par l'API — trilogie, cycle, intégrale. +/// +/// +/// La liste est rendue à plat, chaque série portant l'identifiant de sa parente, et c'est +/// le client qui rebâtit l'arbre. Deux raisons : un JSON récursif se cache mal dans un +/// instantané hors-ligne, et une seule lecture suffit alors à servir la liste et le +/// détail — donc un seul instantané à tenir à jour. +/// +public record SerieDto +{ + public required int Id { get; init; } + + public required string Titre { get; init; } + + /// La série qui contient celle-ci, si elle fait partie d'un cycle. + public int? SerieParenteId { get; init; } + + /// Rang dans la série parente. Sans objet à la racine. + public int Position { get; init; } + + /// Les tomes, dans l'ordre de lecture. + public IReadOnlyList Elements { get; init; } = []; + + /// Qui a créé la série. Trace d'affichage : la série est commune au foyer. + public string? AjoutePar { get; init; } + + /// Tomes possédés sur tomes recensés — « 4 sur 7 », le cœur de l'écran. + public int NombrePossedes => Elements.Count(e => e.Possede); +} + +/// +/// Une place dans l'ordre de lecture, occupée ou non. +/// +/// +/// ⚠️ Un élément sans n'est pas une anomalie : c'est un tome qu'on +/// ne possède pas, et c'est précisément ce que l'écran doit montrer. +/// +public record ElementSerieDto +{ + public required int Id { get; init; } + + public int Position { get; init; } + + /// Titre du tome : celui du livre quand il est rattaché, sinon celui qui a été saisi. + public required string Titre { get; init; } + + public int? LivreId { get; init; } + + public bool Possede => LivreId is not null; + + /// Auteurs du livre rattaché, pour ne pas avoir à ouvrir sa fiche. + public string? Auteurs { get; init; } + + /// À qui le tome est prêté, s'il est dehors. Commun au foyer, comme tout prêt. + public string? PreteA { get; init; } +} + +/// Charge utile de création ou de renommage d'une série. +public record EnregistrementSerie +{ + public string Titre { get; set; } = string.Empty; + + /// Série parente, pour ranger une trilogie dans un cycle. null = à la racine. + public int? SerieParenteId { get; set; } +} + +/// +/// Charge utile d'ajout d'un tome : soit un livre du catalogue, soit un simple titre. +/// +/// +/// Les deux voies existent parce qu'une saga se recense d'un coup — souvent avant d'en posséder +/// la moitié — puis se remplit au fil des achats. +/// +public record AjoutElementSerie +{ + /// Livre du catalogue à placer ici. null = un tome qu'on ne possède pas. + public int? LivreId { get; set; } + + /// Titre du tome. Obligatoire sans livre ; repris du livre sinon. + public string? Titre { get; set; } +} + +/// Nouvel ordre de lecture : la liste entière des identifiants d'éléments. +/// +/// Comme pour la liste d'envies, on prend la liste complète plutôt qu'un déplacement unitaire : +/// une seule opération à vérifier, et c'est ce dont a besoin le glisser-déposer comme les +/// flèches. +/// +public record OrdreElementsSerie +{ + public List Ids { get; set; } = []; +} diff --git a/MaBibli.Shared/Entites/Serie.cs b/MaBibli.Shared/Entites/Serie.cs new file mode 100644 index 0000000..c7f15c2 --- /dev/null +++ b/MaBibli.Shared/Entites/Serie.cs @@ -0,0 +1,109 @@ +using MaBibli.Shared.Textes; + +namespace MaBibli.Shared.Entites; + +/// +/// Un regroupement ordonné de livres : trilogie, série, cycle, intégrale. +/// +/// +/// C'est la première notion du projet qui porte sur une relation entre livres plutôt que +/// sur un livre. Trois choses en découlent, et un simple champ texte « série » sur +/// n'en couvrirait aucune. +/// +/// 1. Deux niveaux, au moins. La Légende de Drizzt regroupe plusieurs trilogies. +/// D'où , une auto-référence : un cycle est une série qui +/// contient des séries. Un champ « cycle » séparé figerait la profondeur à deux et obligerait à +/// tout refaire au troisième niveau. +/// +/// +/// 2. L'ordre de lecture n'est pas l'ordre de publication. L'Elfe noir est une +/// préquelle écrite après. C'est précisément l'information qu'on vient chercher : elle est +/// stockée (), jamais déduite d'une année. +/// +/// +/// 3. La portée est COMMUNE au foyer, comme le catalogue et les prêts, contrairement au +/// statut de lecture et à la liste d'envies. L'ordre de lecture d'une saga est une propriété de +/// l'œuvre : il ne change pas selon qui regarde. est donc une trace, +/// pas une frontière — ne jamais filtrer dessus, comme . +/// +/// +public class Serie +{ + public int Id { get; set; } + + public string Titre { get; set; } = string.Empty; + + /// + /// Titre mis à plat. Porte un index unique : une série, une fiche. + /// + /// + /// L'unicité est globale et non « par série parente », parce que + /// est nullable et que SQLite tient deux NULL pour + /// distincts : une unicité incluant le parent laisserait passer autant de doublons qu'on + /// veut à la racine — exactement le piège documenté sur LivreSouhaite.AuteurNormalise. + /// + public string TitreNormalise { get; set; } = string.Empty; + + /// La série qui contient celle-ci, quand c'en est une partie d'un cycle. + public int? SerieParenteId { get; set; } + + public Serie? SerieParente { get; set; } + + public List SousSeries { get; set; } = []; + + /// Rang de cette série dans sa série parente. Sans objet à la racine. + public int Position { get; set; } + + public List Elements { get; set; } = []; + + public DateTime DateAjout { get; set; } + + /// Qui a créé la série. Trace, pas frontière : la série est commune. + public string? AjoutePar { get; set; } + + public void RecalculerFormes() + { + Titre = Titre.Trim(); + TitreNormalise = NormalisationTexte.Normaliser(Titre); + } +} + +/// +/// Une position dans une série : le tome n, qu'on le possède ou non. +/// +/// +/// ⚠️ Ce n'est pas « un livre de la série » mais une place dans l'ordre de lecture. La +/// nuance est tout l'intérêt de l'écran : sans position sans livre, on ne pourrait pas montrer +/// les trous — « il vous manque le tome 3 » —, qui est la demande d'origine. Aucune source ne +/// donne l'ordre de lecture d'une saga (la BnF ne l'expose pas, et l'ordre de publication est +/// justement faux pour une préquelle) : les tomes absents n'existent que si on les saisit. +/// +/// est donc toujours renseigné, y compris quand +/// l'est : c'est ce qui permet à la suppression d'un livre de laisser un +/// trou nommé plutôt qu'une ligne muette. La clé étrangère est en SetNull pour cette +/// raison précise — supprimer un livre ne doit pas trouer la structure de la saga. +/// +/// +public class ElementSerie +{ + public int Id { get; set; } + + public int SerieId { get; set; } + + public Serie? Serie { get; set; } + + /// Rang dans l'ordre de lecture, à partir de 0. + public int Position { get; set; } + + /// Le livre du catalogue, s'il est possédé. null = un trou. + public int? LivreId { get; set; } + + public Livre? Livre { get; set; } + + /// + /// Titre du tome. Renseigné même quand le livre est là, pour survivre à sa suppression. + /// + public string Titre { get; set; } = string.Empty; + + public void RecalculerFormes() => Titre = Titre.Trim(); +} diff --git a/MaBibli.Tests/ServiceSeriesTests.cs b/MaBibli.Tests/ServiceSeriesTests.cs new file mode 100644 index 0000000..5710843 --- /dev/null +++ b/MaBibli.Tests/ServiceSeriesTests.cs @@ -0,0 +1,274 @@ +using MaBibli.Api.Data; +using MaBibli.Api.Services.Catalogue; +using MaBibli.Api.Services.Prets; +using MaBibli.Api.Services.Series; +using MaBibli.Api.Services.Souhaits; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Tests; + +/// +/// Sagas, cycles et séries, sur une base SQLite en mémoire : les invariants tenus ici +/// sont pour moitié des index (unicité du titre, un livre une seule fois par série) et ne se +/// vérifieraient pas sur un faux dépôt. +/// +/// +/// Ce qui est protégé en priorité : un tome sans livre est normal (c'est le trou qu'on +/// vient voir), une suppression de livre laisse le trou, et l'ordre de lecture est +/// stocké — jamais déduit. +/// +public class ServiceSeriesTests : IDisposable +{ + private readonly SqliteConnection _connexion; + private readonly MaBibliDbContext _db; + private readonly ServiceSeries _series; + private readonly ServiceCatalogue _catalogue; + private readonly ServiceSouhaits _souhaits; + + private const string Mathieu = "mathieu"; + private const string Camille = "camille"; + + public ServiceSeriesTests() + { + _connexion = new SqliteConnection("Data Source=:memory:"); + _connexion.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connexion) + .Options; + + _db = new MaBibliDbContext(options); + _db.Database.EnsureCreated(); + + _souhaits = new ServiceSouhaits(_db); + _series = new ServiceSeries(_db, _souhaits); + _catalogue = new ServiceCatalogue(_db, new ServiceAuteurs(_db)); + } + + private async Task LivreAsync(string titre, string auteur = "R. A. Salvatore") + { + var resultat = await _catalogue.CreerAsync( + new EnregistrementLivre { Titre = titre, Auteur = auteur }, Mathieu, confirmerDoublon: true); + + return resultat.Livre!.Id; + } + + private async Task SerieAsync(string titre, int? parenteId = null) + { + var resultat = await _series.CreerAsync( + new EnregistrementSerie { Titre = titre, SerieParenteId = parenteId }, Mathieu); + + Assert.Null(resultat.Erreur); + return resultat.Serie!; + } + + [Fact] + public async Task Une_serie_melange_tomes_possedes_et_tomes_manquants() + { + var serie = await SerieAsync("L'Elfe noir"); + var livreId = await LivreAsync("Terre natale"); + + await _series.AjouterElementAsync(serie.Id, new AjoutElementSerie { LivreId = livreId }); + var apres = (await _series.AjouterElementAsync( + serie.Id, new AjoutElementSerie { Titre = "Terre d'exil" })).Serie!; + + Assert.Equal(["Terre natale", "Terre d'exil"], apres.Elements.Select(e => e.Titre)); + Assert.Equal([true, false], apres.Elements.Select(e => e.Possede)); + + // Le compteur de l'écran : « 1 sur 2 ». + Assert.Equal(1, apres.NombrePossedes); + } + + [Fact] + public async Task Un_tome_sans_titre_ni_livre_est_refuse() + { + var serie = await SerieAsync("L'Elfe noir"); + + var resultat = await _series.AjouterElementAsync(serie.Id, new AjoutElementSerie()); + + Assert.NotNull(resultat.Erreur); + } + + [Fact] + public async Task Supprimer_un_livre_laisse_un_trou_nomme_dans_la_serie() + { + var serie = await SerieAsync("L'Elfe noir"); + var livreId = await LivreAsync("Terre natale"); + await _series.AjouterElementAsync(serie.Id, new AjoutElementSerie { LivreId = livreId }); + + await _catalogue.SupprimerAsync(livreId); + + // ⚠️ L'invariant décisif : la place survit, avec son titre. Une cascade effacerait le + // tome 1 de la saga parce qu'on a perdu son exemplaire. + var element = (await _series.ListerAsync()).Single().Elements.Single(); + Assert.Equal("Terre natale", element.Titre); + Assert.False(element.Possede); + } + + [Fact] + public async Task Lordre_de_lecture_est_stocke_et_se_reordonne() + { + var serie = await SerieAsync("L'Elfe noir"); + var avant = serie; + + foreach (var titre in new[] { "Terre natale", "Terre d'exil", "Terre promise" }) + { + avant = (await _series.AjouterElementAsync( + serie.Id, new AjoutElementSerie { Titre = titre })).Serie!; + } + + var inverse = avant.Elements.Select(e => e.Id).Reverse().ToList(); + var apres = (await _series.ReordonnerAsync(serie.Id, inverse)).Serie!; + + Assert.Equal(["Terre promise", "Terre d'exil", "Terre natale"], apres.Elements.Select(e => e.Titre)); + Assert.Equal([0, 1, 2], apres.Elements.Select(e => e.Position)); + } + + [Fact] + public async Task Un_tome_absent_de_lordre_recu_est_conserve_a_la_suite() + { + var serie = await SerieAsync("L'Elfe noir"); + var un = (await _series.AjouterElementAsync(serie.Id, new AjoutElementSerie { Titre = "Un" })).Serie!; + await _series.AjouterElementAsync(serie.Id, new AjoutElementSerie { Titre = "Deux" }); + + // Le client ne connaissait que le premier tome : le second ne doit pas disparaître. + var apres = (await _series.ReordonnerAsync(serie.Id, [un.Elements[0].Id])).Serie!; + + Assert.Equal(["Un", "Deux"], apres.Elements.Select(e => e.Titre)); + } + + [Fact] + public async Task Un_meme_livre_ne_tient_quune_place_dans_une_serie_mais_vit_dans_plusieurs() + { + var serie = await SerieAsync("L'Elfe noir"); + var autre = await SerieAsync("Les Royaumes oubliés"); + var livreId = await LivreAsync("Terre natale"); + + await _series.AjouterElementAsync(serie.Id, new AjoutElementSerie { LivreId = livreId }); + + var doublon = await _series.AjouterElementAsync(serie.Id, new AjoutElementSerie { LivreId = livreId }); + var ailleurs = await _series.AjouterElementAsync(autre.Id, new AjoutElementSerie { LivreId = livreId }); + + Assert.NotNull(doublon.Erreur); + + // Un livre appartient légitimement à plusieurs regroupements : une série, un cycle, + // une intégrale. + Assert.Null(ailleurs.Erreur); + } + + [Fact] + public async Task Une_serie_se_range_dans_un_cycle_mais_jamais_dans_elle_meme() + { + var cycle = await SerieAsync("La Légende de Drizzt"); + var trilogie = await SerieAsync("L'Elfe noir"); + + var range = await _series.ModifierAsync( + trilogie.Id, new EnregistrementSerie { Titre = trilogie.Titre, SerieParenteId = cycle.Id }); + + Assert.Equal(cycle.Id, range.Serie!.SerieParenteId); + + // Refermer l'arbre sur lui-même ferait boucler l'affichage à l'infini. + var boucle = await _series.ModifierAsync( + cycle.Id, new EnregistrementSerie { Titre = cycle.Titre, SerieParenteId = trilogie.Id }); + + Assert.NotNull(boucle.Erreur); + } + + [Fact] + public async Task Supprimer_un_cycle_ne_detruit_pas_les_series_quil_contient() + { + var cycle = await SerieAsync("La Légende de Drizzt"); + var trilogie = await SerieAsync("L'Elfe noir", cycle.Id); + + await _series.SupprimerAsync(cycle.Id); + + var restantes = await _series.ListerAsync(); + var seule = Assert.Single(restantes); + Assert.Equal(trilogie.Id, seule.Id); + Assert.Null(seule.SerieParenteId); + } + + [Fact] + public async Task Deux_series_ne_peuvent_pas_porter_le_meme_titre() + { + await SerieAsync("L'Elfe noir"); + + var doublon = await _series.CreerAsync( + new EnregistrementSerie { Titre = "l'elfe noir" }, Mathieu); + + Assert.NotNull(doublon.Erreur); + } + + [Fact] + public async Task Un_tome_manquant_part_dans_la_liste_denvies_de_lappelant() + { + var serie = await SerieAsync("L'Elfe noir"); + var apres = (await _series.AjouterElementAsync( + serie.Id, new AjoutElementSerie { Titre = "Terre d'exil" })).Serie!; + + var resultat = await _series.MettreEnEnviesAsync(apres.Elements[0].Id, Camille); + + Assert.Null(resultat.Erreur); + + // ⚠️ La série est COMMUNE, l'envie est PERSONNELLE : elle n'apparaît que chez Camille. + Assert.Equal(["Terre d'exil"], (await _souhaits.ListerAsync(Camille)).Select(s => s.Titre)); + Assert.Empty(await _souhaits.ListerAsync(Mathieu)); + } + + [Fact] + public async Task Un_tome_deja_possede_ne_part_pas_en_envies() + { + var serie = await SerieAsync("L'Elfe noir"); + var livreId = await LivreAsync("Terre natale"); + var apres = (await _series.AjouterElementAsync( + serie.Id, new AjoutElementSerie { LivreId = livreId })).Serie!; + + var resultat = await _series.MettreEnEnviesAsync(apres.Elements[0].Id, Mathieu); + + Assert.NotNull(resultat.Erreur); + Assert.Empty(await _souhaits.ListerAsync(Mathieu)); + } + + [Fact] + public async Task Detacher_un_livre_laisse_la_place_avec_son_titre() + { + var serie = await SerieAsync("L'Elfe noir"); + var livreId = await LivreAsync("Terre natale"); + var apres = (await _series.AjouterElementAsync( + serie.Id, new AjoutElementSerie { LivreId = livreId, Titre = "Tome 1" })).Serie!; + + var detache = (await _series.RattacherLivreAsync(apres.Elements[0].Id, null)).Serie!; + + Assert.False(detache.Elements[0].Possede); + Assert.Equal("Tome 1", detache.Elements[0].Titre); + + // Le livre, lui, n'a pas bougé du catalogue. + Assert.NotNull(await _catalogue.ObtenirAsync(livreId, Mathieu)); + } + + [Fact] + public async Task Un_tome_prete_le_dit_dans_la_serie() + { + var serie = await SerieAsync("L'Elfe noir"); + var livreId = await LivreAsync("Terre natale"); + await _series.AjouterElementAsync(serie.Id, new AjoutElementSerie { LivreId = livreId }); + + var prets = new ServicePrets(_db); + await prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" }); + + var element = (await _series.ListerAsync()).Single().Elements.Single(); + + // « Où est le tome 1 ? » a une seule réponse, la même pour tout le foyer. + Assert.Equal("Paul", element.PreteA); + } + + public void Dispose() + { + _db.Dispose(); + _connexion.Dispose(); + GC.SuppressFinalize(this); + } +}