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)
+ {
+
@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.")
+
+ @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)
+ {
+
@_erreur
+ }
+
+ @* 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.
+ *@
+
+ @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
+ {
+
+ }
+
+
+
+
+ @*
+ 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.
+ *@
+
+ 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)
+{
+
@_erreur
+}
+
+@* 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.
+
+}
+
+@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);
+ }
+}