Cataloguer les revues : une fiche, ses numéros à l'intérieur

Clôt la décision du premier lot, qui écartait les périodiques faute de
modèle. Douze numéros d'un magazine ne font plus douze fiches identiques.

Table à part, pour la troisième fois et pour le même motif que la liste
d'envies et les séries : logée dans Livres, une revue entrerait dans le
catalogue, ses compteurs, les doublons et les bibliographies, et il
faudrait écrire « et qui n'est pas une revue » à chaque lecture.

Un numéro est recensé, rien de plus — ni prêt ni statut, tous deux liés à
Livre par clé étrangère. Réversible.

« Créer ou retrouver » et non « créer » : scanner le numéro suivant du même
magazine retombe forcément sur la même revue. L'unicité de l'ISSN est un
index partiel, sans quoi les revues sans ISSN se bloqueraient entre elles.

Le flux 977 mène désormais à la fiche de la revue, et ne pré-remplit plus
le formulaire d'un livre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-19 22:19:08 +02:00
co-authored by Claude Opus 5
parent 4e372ab694
commit ca0a9dc18c
20 changed files with 2110 additions and 42 deletions
+87
View File
@@ -717,6 +717,25 @@ RapprochementRefuse (mémoire des « non » de l'utilisateur)
├── AuteurAId ─┐ UNIQUE, couple toujours rangé (petit Id d'abord) ├── AuteurAId ─┐ UNIQUE, couple toujours rangé (petit Id d'abord)
└── AuteurBId ─┘ pour qu'un refus vaille dans les deux sens └── AuteurBId ─┘ pour qu'un refus vaille dans les deux sens
Revue (magazines — COMMUNS au foyer ; PAS des livres)
├── Id
├── Titre
├── TitreNormalise (index UNIQUE — une revue, une fiche)
├── Issn (index UNIQUE PARTIEL, WHERE Issn IS NOT NULL)
├── Editeur
├── DateAjout
└── AjoutePar (trace, pas une frontière)
NumeroRevue (recensé seulement : ni prêt, ni statut de lecture)
├── Id
├── RevueId (FK Revue, cascade — un numéro n'existe que par sa revue)
├── Numero (obligatoire : « 43 », « hors-série 7 »)
├── NumeroNormalise
├── DateParution (UTC, nullable)
├── Note
├── DateAjout
└── UNIQUE (RevueId, NumeroNormalise)
Serie (sagas et cycles — COMMUNS au foyer, comme le catalogue) Serie (sagas et cycles — COMMUNS au foyer, comme le catalogue)
├── Id ├── Id
├── Titre ├── Titre
@@ -1290,6 +1309,74 @@ l'œil.
piste, avec sa limite connue — les œuvres remontent en langue originale et ne se rapprochent pas piste, avec sa limite connue — les œuvres remontent en langue originale et ne se rapprochent pas
du catalogue par `CleOeuvre`. du catalogue par `CleOeuvre`.
## Revues et magazines — migration `Revues` (2026-08-19)
Rouvre — et clôt — la décision du premier lot, qui écartait le catalogage des périodiques faute
de modèle. **Une fiche par revue, les numéros à l'intérieur** : douze numéros d'un même magazine
ne font pas douze fiches identiques, puisqu'ils partagent la fiche de leur revue.
### Une table à part, pour la troisième fois — et c'est le même argument
Comme `LivreSouhaite` et comme `Serie` : logée dans `Livres`, une revue entrerait
**mécaniquement** dans le catalogue, ses compteurs, la détection de doublons, les séries et les
bibliographies par auteur — et il faudrait ajouter « et qui n'est pas une revue » à **chaque**
lecture. Un invariant qu'on réécrit partout finit par être oublié quelque part.
Vérifié en exécution : la revue *Médor* n'apparaît ni au catalogue, ni dans une recherche sur
son nom.
⚠️ L'`Issn` vit sur `Revue`, **jamais** dans `Livre.Isbn` : c'était déjà la règle du flux `977`,
elle a maintenant un endroit où se ranger.
### Un numéro est recensé, rien de plus (décidé avec l'utilisateur le 2026-08-19)
Ni prêt, ni statut de lecture. Ces deux mécanismes sont attachés à `Livre` **par clé étrangère** :
les étendre demanderait une seconde table de prêts ou une parenté commune entre livres et
numéros, pour un usage non confirmé. Le choix est **réversible** — les ajouter plus tard ne
détruit rien de ce qui aura été saisi.
Deux détails qui ne sont pas des détails :
- **`Numero` est obligatoire.** Sans lui, deux numéros de la même revue seraient indiscernables,
et l'unicité `(RevueId, NumeroNormalise)` ne voudrait rien dire.
- **Les numéros s'affichent du plus récent au plus ancien**, à l'inverse d'une série qui suit son
ordre de lecture : devant un kiosque, la question est « ai-je le dernier ? ». ⚠️ Les numéros
**sans date ferment la liste** au lieu de passer pour les plus vieux — une date inconnue n'est
pas une date ancienne.
### « Créer ou retrouver », et non « créer »
`POST /api/revues` rend la revue existante quand l'ISSN **ou** le titre la désigne. C'est ce
qu'exige le flux du code-barres : scanner le numéro suivant du même magazine retombe forcément
sur la même revue, et le geste de l'utilisateur est toujours « voici un numéro de plus ».
Refuser le doublon l'obligerait à comprendre qu'il doit aller chercher la fiche lui-même.
Corollaire testé : **une revue saisie à la main apprend son ISSN au premier scan**, ce qui permet
de la retrouver ensuite. L'unicité de l'ISSN est un index **partiel** (`WHERE Issn IS NOT NULL`),
sans quoi les revues sans ISSN se bloqueraient les unes les autres — deux `NULL` étant distincts
pour SQLite, c'est exactement le piège déjà rencontré sur `LivreSouhaite.AuteurNormalise`.
### Le flux `977` ne remplit plus un formulaire de livre
Avant, un code de périodique basculait sur la saisie manuelle d'un **livre**, titre pré-rempli.
C'était le moins mauvais choix tant que les revues n'avaient pas de modèle. L'écran propose
maintenant **« Ouvrir la fiche de cette revue »**, qui la crée au besoin et y mène.
⚠️ **Le numéro se saisit à la main, et ce n'est pas un oubli** : les deux chiffres de parution du
code-barres ne sont pas un numéro fiable (déjà acté), et l'add-on **EAN-2** qui le porterait
vraiment n'a pas pu être vérifié — il faudrait un magazine réel devant la caméra. La piste reste
ouverte dans IDEES.md.
Vérifié en exécution sur `9772466671438` : la BnF nomme « Médor », la fiche est créée avec son
ISSN, deux numéros s'y ajoutent, et **rescanner le même code retombe sur la même fiche**.
### Un sixième onglet, mesuré
`Catalogue / Auteurs / Séries / Revues / Prêts / Envies`. À 320 px — le plus étroit des
téléphones réalistes — les six libellés occupent **312 px sans troncature**, huit de marge.
⚠️ C'est la limite : un septième onglet, ou un libellé plus long que « Catalogue », imposera de
regrouper plutôt que d'ajouter.
## Sagas et cycles — migration `SagasEtCycles` (2026-08-19) ## 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 Cas donné : *La Légende de Drizzt*. C'est la première notion du projet qui porte sur une
+13 -22
View File
@@ -80,32 +80,23 @@ rendu en canvas, pas sur une image de caméra.
# Retours d'usage du 2026-08-19 (3ᵉ série) # Retours d'usage du 2026-08-19 (3ᵉ série)
## Les magazines : une fiche par revue, les numéros à l'intérieur ## Ce qui reste des magazines : le numéro scanné
⚠️ La demande jumelle, **les BD**, est traitée : `TypeDocument` existe (voir `CLAUDE.md`). Les revues sont implémentées (voir `CLAUDE.md`) : fiche par revue, numéros à l'intérieur, et le
Restent les champs propres à la BD — série, tome, scénariste/dessinateur — à ne faire que si le code-barres `977` mène désormais à la fiche de la revue.
besoin se confirme, le modèle mettant aujourd'hui tous les auteurs dans `LivreAuteur` sans
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 numéro, lui, se saisit à la main.** Les deux chiffres de parution du code-barres ne sont
Le modèle est maintenant choisi. pas fiables ; l'**add-on EAN-2** imprimé à côté du code principal est le bon candidat, et zbar
sait le décoder — mais `decoder()` l'écarte aujourd'hui, et rien n'a pu être vérifié faute d'un
magazine réel devant la caméra. À reprendre avec un exemplaire en main : lire l'EAN-2, le
comparer au numéro imprimé sur la couverture, et ne le pré-remplir que si les deux concordent
sur plusieurs numéros.
**Décidé le 2026-08-19 : une fiche par REVUE**, et dans sa page, la sélection d'un numéro Autres pistes non traitées, sans urgence :
précis. C'est exactement ce qui évite le défaut redouté — douze numéros d'un même magazine ne
font plus douze fiches identiques, puisqu'ils partagent la fiche de leur revue.
Ce que cela demande : - **Champs propres à la BD** (série, tome, scénariste/dessinateur). Le regroupement en série est
couvert par les sagas ; il reste la distinction des **rôles**, le modèle mettant tous les
- un **ISSN** sur la fiche de revue, distinct de l'ISBN. ⚠️ Ranger un code `977` dans auteurs dans `LivreAuteur` sans les distinguer.
`Livre.Isbn` casserait tout lookup ultérieur sur cette fiche ;
- une table de **numéros** rattachés à la revue, portant au minimum un numéro et une date de
parution ;
- reprendre le flux `977` : il nomme aujourd'hui la revue et bascule sur la saisie manuelle, il
devra créer ou compléter la fiche puis proposer d'ajouter le numéro scanné. ⚠️ Les deux
chiffres de parution du code-barres **ne sont pas un numéro fiable** ; l'add-on **EAN-2**, que
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.
## Auteurs et bibliographie ## Auteurs et bibliographie
+40
View File
@@ -32,6 +32,14 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
public DbSet<ElementSerie> ElementsSerie => Set<ElementSerie>(); public DbSet<ElementSerie> ElementsSerie => Set<ElementSerie>();
/// <summary>
/// Revues et magazines. <b>Table séparée des <see cref="Livres"/></b> : une revue n'est pas
/// un livre, et n'a rien à faire dans le catalogue, ses compteurs ou ses doublons.
/// </summary>
public DbSet<Revue> Revues => Set<Revue>();
public DbSet<NumeroRevue> NumerosRevue => Set<NumeroRevue>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
@@ -177,6 +185,38 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
.HasFilter("\"LivreId\" IS NOT NULL"); .HasFilter("\"LivreId\" IS NOT NULL");
}); });
modelBuilder.Entity<Revue>(revue =>
{
revue.Property(r => r.Titre).IsRequired();
revue.Property(r => r.TitreNormalise).IsRequired();
revue.HasIndex(r => r.TitreNormalise).IsUnique();
// Index PARTIEL : une revue peut n'avoir aucun ISSN (saisie à la main), et deux
// NULL étant distincts pour SQLite, une unicité simple ne dirait rien. Filtré, il
// garantit qu'un ISSN connu ne désigne qu'une fiche — ce qui permet au flux « 977 »
// de retrouver la revue déjà créée au lieu d'en faire une seconde.
revue.HasIndex(r => r.Issn, "IX_Revues_Issn")
.IsUnique()
.HasFilter("\"Issn\" IS NOT NULL");
});
modelBuilder.Entity<NumeroRevue>(numero =>
{
numero.Property(n => n.Numero).IsRequired();
numero.Property(n => n.NumeroNormalise).IsRequired();
numero.HasOne(n => n.Revue)
.WithMany(r => r.Numeros)
.HasForeignKey(n => n.RevueId)
.OnDelete(DeleteBehavior.Cascade);
// Un numéro ne se possède qu'une fois par revue : rescanner le même exemplaire ne
// doit pas allonger la liste. Contrairement aux livres, un second exemplaire d'un
// numéro de magazine n'a pas d'usage identifié — et l'index se retire si un jour si.
numero.HasIndex(n => new { n.RevueId, n.NumeroNormalise }).IsUnique();
});
modelBuilder.Entity<Pret>(pret => modelBuilder.Entity<Pret>(pret =>
{ {
pret.Property(p => p.Emprunteur).IsRequired(); pret.Property(p => p.Emprunteur).IsRequired();
@@ -0,0 +1,498 @@
// <auto-generated />
using System;
using MaBibli.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
[DbContext(typeof(MaBibliDbContext))]
[Migration("20260819201008_Revues")]
partial class Revues
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CleRegroupement")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CleRegroupement")
.IsUnique();
b.HasIndex("NomNormalise");
b.ToTable("Auteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("SerieId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LivreId");
b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId");
b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId")
.IsUnique()
.HasFilter("\"LivreId\" IS NOT NULL");
b.ToTable("ElementsSerie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("TypeDocument")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Isbn");
b.HasIndex("TitreNormalise");
b.ToTable("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
b.ToTable("LivreAuteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Annee")
.HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("AuteurNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre")
.IsUnique();
b.ToTable("LivresSouhaites");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateParution")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RevueId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RevueId", "NumeroNormalise")
.IsUnique();
b.ToTable("NumerosRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours")
.IsUnique()
.HasFilter("\"DateRetour\" IS NULL");
b.ToTable("Prets");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurAId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurBId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AuteurAId", "AuteurBId")
.IsUnique();
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TitreNormalise")
.IsUnique();
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
.IsUnique()
.HasFilter("\"Issn\" IS NOT NULL");
b.ToTable("Revues");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("SerieParenteId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SerieParenteId");
b.HasIndex("TitreNormalise")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateMaj")
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur");
b.HasIndex("LivreId", "Utilisateur")
.IsUnique();
b.ToTable("StatutsLecture");
});
modelBuilder.Entity("MaBibli.Shared.Entites.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.NumeroRevue", b =>
{
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
.WithMany("Numeros")
.HasForeignKey("RevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Revue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Prets")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente")
.WithMany("SousSeries")
.HasForeignKey("SerieParenteId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SerieParente");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Statuts")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Navigation("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Navigation("Auteurs");
b.Navigation("Prets");
b.Navigation("Statuts");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Navigation("Numeros");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Navigation("Elements");
b.Navigation("SousSeries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,86 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <inheritdoc />
public partial class Revues : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Revues",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Titre = table.Column<string>(type: "TEXT", nullable: false),
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
Issn = table.Column<string>(type: "TEXT", nullable: true),
Editeur = table.Column<string>(type: "TEXT", nullable: true),
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false),
AjoutePar = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Revues", x => x.Id);
});
migrationBuilder.CreateTable(
name: "NumerosRevue",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RevueId = table.Column<int>(type: "INTEGER", nullable: false),
Numero = table.Column<string>(type: "TEXT", nullable: false),
NumeroNormalise = table.Column<string>(type: "TEXT", nullable: false),
DateParution = table.Column<DateTime>(type: "TEXT", nullable: true),
Note = table.Column<string>(type: "TEXT", nullable: true),
DateAjout = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NumerosRevue", x => x.Id);
table.ForeignKey(
name: "FK_NumerosRevue_Revues_RevueId",
column: x => x.RevueId,
principalTable: "Revues",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_NumerosRevue_RevueId_NumeroNormalise",
table: "NumerosRevue",
columns: new[] { "RevueId", "NumeroNormalise" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Revues_Issn",
table: "Revues",
column: "Issn",
unique: true,
filter: "\"Issn\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Revues_TitreNormalise",
table: "Revues",
column: "TitreNormalise",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NumerosRevue");
migrationBuilder.DropTable(
name: "Revues");
}
}
}
@@ -197,6 +197,40 @@ namespace MaBibli.Api.Data.Migrations
b.ToTable("LivresSouhaites"); b.ToTable("LivresSouhaites");
}); });
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateParution")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RevueId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RevueId", "NumeroNormalise")
.IsUnique();
b.ToTable("NumerosRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -247,6 +281,44 @@ namespace MaBibli.Api.Data.Migrations
b.ToTable("RapprochementsRefuses"); b.ToTable("RapprochementsRefuses");
}); });
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TitreNormalise")
.IsUnique();
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
.IsUnique()
.HasFilter("\"Issn\" IS NOT NULL");
b.ToTable("Revues");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -349,6 +421,17 @@ namespace MaBibli.Api.Data.Migrations
b.Navigation("Livre"); b.Navigation("Livre");
}); });
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
.WithMany("Numeros")
.HasForeignKey("RevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Revue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{ {
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
@@ -395,6 +478,11 @@ namespace MaBibli.Api.Data.Migrations
b.Navigation("Statuts"); b.Navigation("Statuts");
}); });
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Navigation("Numeros");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b => modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{ {
b.Navigation("Elements"); b.Navigation("Elements");
+96
View File
@@ -0,0 +1,96 @@
using MaBibli.Api.Services.Identite;
using MaBibli.Api.Services.Revues;
using MaBibli.Shared.Dtos;
namespace MaBibli.Api.Endpoints;
public static class RevuesEndpoints
{
/// <summary>
/// Revues et magazines : une fiche par revue, les numéros à l'intérieur.
/// </summary>
/// <remarks>
/// <b>Communes au foyer</b>, comme le catalogue : aucun point d'entrée ne prend d'identité,
/// et « ajoutePar » n'est qu'une trace. La lecture est unique — <c>GET /api/revues</c> rend
/// les revues avec leurs numéros, ce qui donne un seul instantané hors-ligne.
/// </remarks>
public static IEndpointRouteBuilder MapRevuesEndpoints(this IEndpointRouteBuilder routes)
{
var groupe = routes.MapGroup("/api/revues").WithTags("Revues");
groupe.MapGet("/", async (IServiceRevues service, CancellationToken ct) =>
Results.Ok(await service.ListerAsync(ct)))
.WithName("ListerRevues")
.WithSummary("Toutes les revues, avec les numéros possédés, du plus récent au plus ancien.")
.Produces<IReadOnlyList<RevueDto>>();
groupe.MapPost("/", async (
EnregistrementRevue saisie,
IServiceRevues service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var resultat = await service.CreerOuRetrouverAsync(
saisie, utilisateurs.Obtenir().Identifiant, ct);
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Ok(resultat.Revue);
})
.WithName("CreerRevue")
.WithSummary("Crée la revue, ou rend celle qui existe déjà (même ISSN, ou même titre).")
.WithDescription(
"« Créer ou retrouver » et non « créer » : scanner le code-barres d'un second "
+ "numéro retombe forcément sur la même revue, et le geste de l'utilisateur est "
+ "toujours « voici un numéro de plus ».")
.Produces<RevueDto>()
.Produces(StatusCodes.Status400BadRequest);
groupe.MapPut("/{id:int}", async (
int id, EnregistrementRevue saisie, IServiceRevues service, CancellationToken ct) =>
Repondre(await service.ModifierAsync(id, saisie, ct)))
.WithName("ModifierRevue")
.Produces<RevueDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapDelete("/{id:int}", async (int id, IServiceRevues service, CancellationToken ct) =>
await service.SupprimerAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithName("SupprimerRevue")
.WithSummary("Supprime la revue et les numéros qu'elle porte : ils n'existent que par elle.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPost("/{id:int}/numeros", async (
int id, AjoutNumeroRevue saisie, IServiceRevues service, CancellationToken ct) =>
Repondre(await service.AjouterNumeroAsync(id, saisie, ct)))
.WithName("AjouterNumeroRevue")
.WithSummary("Ajoute un numéro possédé. Le numéro est obligatoire et unique dans la revue.")
.Produces<RevueDto>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapDelete("/numeros/{numeroId:int}", async (
int numeroId, IServiceRevues service, CancellationToken ct) =>
await service.RetirerNumeroAsync(numeroId, ct)
? Results.NoContent()
: Results.NotFound())
.WithName("RetirerNumeroRevue")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
return routes;
}
private static IResult Repondre(ResultatRevue resultat)
{
if (resultat.EstIntrouvable)
{
return Results.NotFound();
}
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Ok(resultat.Revue);
}
}
+3
View File
@@ -4,6 +4,7 @@ using MaBibli.Api.Services.Catalogue;
using MaBibli.Api.Services.Identite; using MaBibli.Api.Services.Identite;
using MaBibli.Api.Services.Isbn; using MaBibli.Api.Services.Isbn;
using MaBibli.Api.Services.Prets; using MaBibli.Api.Services.Prets;
using MaBibli.Api.Services.Revues;
using MaBibli.Api.Services.Series; using MaBibli.Api.Services.Series;
using MaBibli.Api.Services.Souhaits; using MaBibli.Api.Services.Souhaits;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -21,6 +22,7 @@ builder.Services.AddScoped<IServicePrets, ServicePrets>();
builder.Services.AddScoped<IServiceSouhaits, ServiceSouhaits>(); builder.Services.AddScoped<IServiceSouhaits, ServiceSouhaits>();
builder.Services.AddScoped<IServiceBibliographie, ServiceBibliographie>(); builder.Services.AddScoped<IServiceBibliographie, ServiceBibliographie>();
builder.Services.AddScoped<IServiceSeries, ServiceSeries>(); builder.Services.AddScoped<IServiceSeries, ServiceSeries>();
builder.Services.AddScoped<IServiceRevues, ServiceRevues>();
// Identité : lue dans les en-têtes injectés par SSOwat, avec repli sur un utilisateur simulé // 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). // configuré en développement (il n'y a pas de portail YunoHost en local).
@@ -80,6 +82,7 @@ app.MapAuteursEndpoints();
app.MapPretsEndpoints(); app.MapPretsEndpoints();
app.MapSouhaitsEndpoints(); app.MapSouhaitsEndpoints();
app.MapSeriesEndpoints(); app.MapSeriesEndpoints();
app.MapRevuesEndpoints();
app.MapBibliographieEndpoints(); app.MapBibliographieEndpoints();
app.MapIdentiteEndpoints(); app.MapIdentiteEndpoints();
@@ -0,0 +1,255 @@
using MaBibli.Api.Data;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Revues;
/// <summary>Issue d'une écriture sur une revue : la revue, ou un message pour l'utilisateur.</summary>
public readonly record struct ResultatRevue(RevueDto? Revue, string? Erreur)
{
public static ResultatRevue Ok(RevueDto revue) => new(revue, null);
public static ResultatRevue Invalide(string message) => new(null, message);
public static readonly ResultatRevue Introuvable = new(null, null);
public bool EstIntrouvable => Revue is null && Erreur is null;
}
public interface IServiceRevues
{
Task<IReadOnlyList<RevueDto>> ListerAsync(CancellationToken ct = default);
/// <summary>
/// Crée la revue, ou <b>rend celle qui existe déjà</b> quand l'ISSN ou le titre la désigne.
/// </summary>
Task<ResultatRevue> CreerOuRetrouverAsync(
EnregistrementRevue saisie, string? utilisateur, CancellationToken ct = default);
Task<ResultatRevue> ModifierAsync(
int id, EnregistrementRevue saisie, CancellationToken ct = default);
Task<bool> SupprimerAsync(int id, CancellationToken ct = default);
Task<ResultatRevue> AjouterNumeroAsync(
int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default);
Task<bool> RetirerNumeroAsync(int numeroId, CancellationToken ct = default);
}
/// <summary>
/// Revues et magazines : une fiche par revue, les numéros à l'intérieur.
/// </summary>
/// <remarks>
/// ⚠️ <b>Communes au foyer</b>, comme le catalogue : aucune méthode ne filtre sur un
/// utilisateur, <see cref="Revue.AjoutePar"/> n'étant qu'une trace.
/// <para>
/// Un numéro est <b>recensé, rien de plus</b> — ni prêt, ni statut de lecture (décidé le
/// 2026-08-19). C'est ce qui garde cette table à l'écart de deux mécanismes attachés à
/// <c>Livre</c> par clé étrangère.
/// </para>
/// </remarks>
public sealed class ServiceRevues(MaBibliDbContext db) : IServiceRevues
{
public async Task<IReadOnlyList<RevueDto>> ListerAsync(CancellationToken ct = default)
{
var revues = await db.Revues
.AsNoTracking()
.Include(r => r.Numeros)
.OrderBy(r => r.TitreNormalise)
.ToListAsync(ct);
return revues.Select(Projeter).ToList();
}
public async Task<ResultatRevue> CreerOuRetrouverAsync(
EnregistrementRevue saisie, string? utilisateur, CancellationToken ct = default)
{
var titre = saisie.Titre?.Trim();
if (string.IsNullOrWhiteSpace(titre))
{
return ResultatRevue.Invalide("Le titre de la revue est obligatoire.");
}
var issn = Vide(saisie.Issn);
var normalise = NormalisationTexte.Normaliser(titre);
// ⚠️ « Créer OU retrouver » et non « créer » : le flux du code-barres 977 rescanne
// forcément la même revue à chaque numéro. Refuser le doublon obligerait l'utilisateur
// à comprendre qu'il doit aller chercher la fiche lui-même — alors que le geste est
// toujours le même : « voici un numéro de plus ».
var existante = await db.Revues
.Include(r => r.Numeros)
.FirstOrDefaultAsync(
r => (issn != null && r.Issn == issn) || r.TitreNormalise == normalise, ct);
if (existante is not null)
{
// Un ISSN appris après coup complète la fiche créée à la main : c'est ce qui
// permettra de la retrouver au prochain scan.
if (existante.Issn is null && issn is not null)
{
existante.Issn = issn;
await db.SaveChangesAsync(ct);
}
return ResultatRevue.Ok(Projeter(existante));
}
var revue = new Revue
{
Titre = titre,
Issn = issn,
Editeur = Vide(saisie.Editeur),
DateAjout = DateTime.UtcNow,
AjoutePar = utilisateur,
};
revue.RecalculerFormes();
db.Revues.Add(revue);
await db.SaveChangesAsync(ct);
return ResultatRevue.Ok(Projeter(revue));
}
public async Task<ResultatRevue> ModifierAsync(
int id, EnregistrementRevue saisie, CancellationToken ct = default)
{
var revue = await db.Revues.Include(r => r.Numeros).FirstOrDefaultAsync(r => r.Id == id, ct);
if (revue is null)
{
return ResultatRevue.Introuvable;
}
var titre = saisie.Titre?.Trim();
if (string.IsNullOrWhiteSpace(titre))
{
return ResultatRevue.Invalide("Le titre de la revue est obligatoire.");
}
var normalise = NormalisationTexte.Normaliser(titre);
if (await db.Revues.AnyAsync(r => r.Id != id && r.TitreNormalise == normalise, ct))
{
return ResultatRevue.Invalide($"La revue « {titre} » existe déjà.");
}
var issn = Vide(saisie.Issn);
if (issn is not null && await db.Revues.AnyAsync(r => r.Id != id && r.Issn == issn, ct))
{
return ResultatRevue.Invalide($"L'ISSN {issn} est déjà celui d'une autre revue.");
}
revue.Titre = titre;
revue.Issn = issn;
revue.Editeur = Vide(saisie.Editeur);
revue.RecalculerFormes();
await db.SaveChangesAsync(ct);
return ResultatRevue.Ok(Projeter(revue));
}
public async Task<bool> SupprimerAsync(int id, CancellationToken ct = default)
{
var revue = await db.Revues.FirstOrDefaultAsync(r => r.Id == id, ct);
if (revue is null)
{
return false;
}
// Les numéros partent en cascade : ils n'existent que par leur revue, contrairement aux
// livres d'une série, qui vivent leur vie au catalogue.
db.Revues.Remove(revue);
await db.SaveChangesAsync(ct);
return true;
}
public async Task<ResultatRevue> AjouterNumeroAsync(
int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default)
{
var revue = await db.Revues.Include(r => r.Numeros).FirstOrDefaultAsync(r => r.Id == revueId, ct);
if (revue is null)
{
return ResultatRevue.Introuvable;
}
var numero = saisie.Numero?.Trim();
if (string.IsNullOrWhiteSpace(numero))
{
return ResultatRevue.Invalide("Le numéro est obligatoire.");
}
var normalise = NormalisationTexte.Normaliser(numero);
if (revue.Numeros.Any(n => n.NumeroNormalise == normalise))
{
return ResultatRevue.Invalide($"Le numéro {numero} est déjà dans votre collection.");
}
if (saisie.DateParution is { } parution && parution.Date > DateTime.UtcNow.Date.AddDays(1))
{
// Comme pour les prêts : une date future est une faute de frappe, pas une intention.
// Un jour de marge, les revues datées du mois suivant étant courantes en kiosque.
return ResultatRevue.Invalide("La date de parution ne peut pas être dans le futur.");
}
var ligne = new NumeroRevue
{
RevueId = revueId,
Numero = numero,
DateParution = saisie.DateParution,
Note = Vide(saisie.Note),
DateAjout = DateTime.UtcNow,
};
ligne.RecalculerFormes();
db.NumerosRevue.Add(ligne);
await db.SaveChangesAsync(ct);
var relue = await db.Revues.AsNoTracking().Include(r => r.Numeros)
.FirstAsync(r => r.Id == revueId, ct);
return ResultatRevue.Ok(Projeter(relue));
}
public async Task<bool> RetirerNumeroAsync(int numeroId, CancellationToken ct = default)
{
var numero = await db.NumerosRevue.FirstOrDefaultAsync(n => n.Id == numeroId, ct);
if (numero is null)
{
return false;
}
db.NumerosRevue.Remove(numero);
await db.SaveChangesAsync(ct);
return true;
}
private static string? Vide(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
private static RevueDto Projeter(Revue revue) => new()
{
Id = revue.Id,
Titre = revue.Titre,
Issn = revue.Issn,
Editeur = revue.Editeur,
AjoutePar = revue.AjoutePar,
// Du plus récent au plus ancien : sur un magazine, c'est le dernier reçu qu'on cherche,
// à l'inverse d'une série dont on suit l'ordre de lecture. Les numéros sans date de
// parution ferment la liste plutôt que de passer pour les plus vieux.
Numeros = revue.Numeros
.OrderByDescending(n => n.DateParution.HasValue)
.ThenByDescending(n => n.DateParution)
.ThenByDescending(n => n.NumeroNormalise, StringComparer.Ordinal)
.Select(n => new NumeroRevueDto
{
Id = n.Id,
Numero = n.Numero,
DateParution = n.DateParution,
Note = n.Note,
})
.ToList(),
};
}
+3 -2
View File
@@ -44,7 +44,7 @@
</main> </main>
@* @*
Navigation permanente, en bas de l'écran : cinq onglets, toujours au même endroit. Navigation permanente, en bas de l'écran : six onglets, toujours au même endroit.
Elle remplace les listes de liens que chaque page portait dans sa barre d'actions — cinq 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 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. figurait pas partout. Les barres d'actions restantes ne portent plus que des ACTIONS.
@@ -53,13 +53,14 @@
qu'un rang d'onglets sous le bandeau. Les actions de page se placent juste au-dessus 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). (variable --mb-onglets-hauteur, partagée avec .actions-flottantes).
Les onglets restent actifs hors-ligne : les cinq écrans se consultent depuis leurs Les onglets restent actifs hors-ligne : les six écrans se consultent depuis leurs
instantanés. Ce sont les écritures qui se désactivent, pas la navigation. instantanés. Ce sont les écritures qui se désactivent, pas la navigation.
*@ *@
<nav class="onglets" aria-label="Navigation principale"> <nav class="onglets" aria-label="Navigation principale">
<NavLink class="onglet" href="" Match="NavLinkMatch.All">Catalogue</NavLink> <NavLink class="onglet" href="" Match="NavLinkMatch.All">Catalogue</NavLink>
<NavLink class="onglet" href="auteurs">Auteurs</NavLink> <NavLink class="onglet" href="auteurs">Auteurs</NavLink>
<NavLink class="onglet" href="series">Séries</NavLink> <NavLink class="onglet" href="series">Séries</NavLink>
<NavLink class="onglet" href="revues">Revues</NavLink>
<NavLink class="onglet" href="prets">Prêts</NavLink> <NavLink class="onglet" href="prets">Prêts</NavLink>
<NavLink class="onglet" href="souhaits">Envies</NavLink> <NavLink class="onglet" href="souhaits">Envies</NavLink>
</nav> </nav>
+69 -15
View File
@@ -86,6 +86,20 @@
@if (_messagePeriodique is not null) @if (_messagePeriodique is not null)
{ {
<p class="message-avertissement" role="status">@_messagePeriodique</p> <p class="message-avertissement" role="status">@_messagePeriodique</p>
@* Une revue a désormais sa place : sa fiche, où l'on ajoute le numéro qu'on vient de
scanner. Avant, ce code retombait sur la saisie manuelle d'un LIVRE — ce qu'un magazine
n'est pas. *@
@if (_periodique is not null)
{
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enregistrement || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="OuvrirLaRevueAsync">
@(_enregistrement ? "Ouverture…" : "Ouvrir la fiche de cette revue")
</button>
</div>
}
} }
@foreach (var avertissement in _avertissements) @foreach (var avertissement in _avertissements)
@@ -183,6 +197,7 @@
private bool _focusAFaire = true; private bool _focusAFaire = true;
private string? _messagePeriodique; private string? _messagePeriodique;
private PeriodiqueDetecte? _periodique;
private bool _chargement; private bool _chargement;
private bool _enregistrement; private bool _enregistrement;
private string? _erreur; private string? _erreur;
@@ -232,6 +247,7 @@
{ {
_erreur = null; _erreur = null;
_messagePeriodique = null; _messagePeriodique = null;
_periodique = null;
_avertissements = []; _avertissements = [];
_etape = Etape.Scan; _etape = Etape.Scan;
} }
@@ -258,6 +274,7 @@
_chargement = true; _chargement = true;
_erreur = null; _erreur = null;
_messagePeriodique = null; _messagePeriodique = null;
_periodique = null;
_avertissements = []; _avertissements = [];
_etape = Etape.Recherche; _etape = Etape.Recherche;
@@ -314,33 +331,69 @@
} }
/// <summary> /// <summary>
/// Dit ce qu'est le code scanné, puis bascule sur la saisie manuelle. /// Dit ce qu'est le code scanné, et propose la fiche de revue correspondante.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// MaBibli ne gère pas les périodiques : ni numéro, ni date de parution. Le titre est /// ⚠️ <b>Ne remplit surtout pas le formulaire d'un livre</b>, ce qu'il faisait tant que les
/// pré-rempli, mais <b>pas l'ISBN</b> — le code-barres d'une revue n'en est pas un, et le /// revues n'avaient pas de modèle : un magazine n'est pas un livre, et ranger un code
/// ranger dans <c>Livre.Isbn</c> ferait échouer tout lookup ultérieur sur cette fiche. /// <c>977</c> dans <c>Livre.Isbn</c> ferait échouer tout lookup ultérieur sur la fiche.
/// <para>
/// Le <b>numéro</b> se saisit ensuite à la main : les deux chiffres de parution du
/// code-barres ne sont pas un numéro fiable, et l'add-on EAN-2 qui le porterait vraiment
/// n'a pas pu être vérifié sur un magazine réel.
/// </para>
/// </remarks> /// </remarks>
private void DecrireLeMagazine(PeriodiqueDetecte revue) private void DecrireLeMagazine(PeriodiqueDetecte revue)
{ {
var nom = revue.Titre is null ? "un magazine" : $"le magazine « {revue.Titre} »"; var nom = revue.Titre is null ? "un magazine" : $"le magazine « {revue.Titre} »";
_periodique = revue;
_messagePeriodique = _messagePeriodique =
$"Ce code-barres désigne {nom} (ISSN {revue.Issn}), pas un livre : " $"Ce code-barres désigne {nom} (ISSN {revue.Issn}), pas un livre : "
+ "il commence par 977, réservé aux publications en série. " + "il commence par 977, réservé aux publications en série. "
+ "MaBibli ne gère pas les revues — vous pouvez tout de même créer une fiche à la main, " + "Ouvrez sa fiche pour y ajouter le numéro que vous venez de scanner.";
+ "sans ISBN.";
_saisie = new EnregistrementLivre
{
Titre = revue.Titre ?? string.Empty,
Editeur = revue.Editeur,
Format = Format.Physique,
Statut = Statut.ALire,
};
_erreur = null; _erreur = null;
_etape = Etape.Formulaire; RevenirALaSaisie();
}
/// <summary>
/// Ouvre la fiche de la revue scannée, en la créant si elle n'existe pas encore.
/// </summary>
/// <remarks>
/// Le serveur fait « créer ou retrouver » : scanner le numéro suivant du même magazine
/// retombe sur la même fiche, ce qui est exactement le geste attendu.
/// <para>
/// Sans titre connu de la BnF, on n'invente rien : l'ISSN sert de nom provisoire, que
/// l'utilisateur corrigera sur la fiche.
/// </para>
/// </remarks>
private async Task OuvrirLaRevueAsync()
{
if (_periodique is not { } revue)
{
return;
}
_enregistrement = true;
_erreur = null;
var resultat = await Api.CreerRevueAsync(new EnregistrementRevue
{
Titre = revue.Titre ?? $"Revue ISSN {revue.Issn}",
Issn = revue.Issn,
Editeur = revue.Editeur,
});
_enregistrement = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Navigation.NavigateTo($"revues/{resultat.Valeur!.Id}");
} }
/// <summary>Ramène l'écran à la saisie, champ ISBN de nouveau actif pour la douchette.</summary> /// <summary>Ramène l'écran à la saisie, champ ISBN de nouveau actif pour la douchette.</summary>
@@ -371,6 +424,7 @@
{ {
_erreur = null; _erreur = null;
_messagePeriodique = null; _messagePeriodique = null;
_periodique = null;
_erreurFormulaire = null; _erreurFormulaire = null;
_doublons = null; _doublons = null;
_candidats = []; _candidats = [];
+302
View File
@@ -0,0 +1,302 @@
@page "/revues/{Id:int}"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
@inject EtatReseau Reseau
@implements IDisposable
<PageTitle>MaBibli — @(_revue?.Titre ?? "revue")</PageTitle>
@if (_revue is null)
{
<p class="message-discret" role="status">
@(_chargement ? "Chargement…" : "Cette revue n'est pas dans les données enregistrées sur cet appareil.")
</p>
}
else
{
<h1 class="titre-page">@_revue.Titre</h1>
<p class="carte-details">
@if (!string.IsNullOrWhiteSpace(_revue.Issn))
{
<span class="code-isbn">ISSN @_revue.Issn</span>
}
@if (!string.IsNullOrWhiteSpace(_revue.Editeur))
{
<span>@_revue.Editeur</span>
}
</p>
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
<h2 class="titre-section">Numéros possédés</h2>
@if (_revue.Numeros.Count == 0)
{
<p class="message-discret">
Aucun numéro recensé pour l'instant.
</p>
}
else
{
@* Du plus récent au plus ancien : devant un kiosque, la question est « ai-je le
dernier ? », pas « par quoi ai-je commencé ». C'est l'inverse d'une série, qui suit
son ordre de lecture. *@
<ul class="liste-numeros">
@foreach (var numero in _revue.Numeros)
{
<li class="carte-numero" @key="numero.Id">
<div class="carte-corps">
<p class="carte-titre">N° @numero.Numero</p>
<p class="carte-details">
@if (numero.DateParution is { } parution)
{
<span>@parution.ToLocalTime().ToString("MMMM yyyy")</span>
}
else
{
<span>Date de parution inconnue</span>
}
</p>
@if (!string.IsNullOrWhiteSpace(numero.Note))
{
<p class="souhait-note">@numero.Note</p>
}
</div>
<button type="button" class="bouton bouton-compact bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
aria-label="@($"Retirer le numéro {numero.Numero}")"
@onclick="() => RetirerAsync(numero)">
Retirer
</button>
</li>
}
</ul>
}
<h2 class="titre-section">Ajouter un numéro</h2>
@*
⚠️ Le numéro se saisit à la main, et ce n'est pas un oubli : les deux chiffres de
parution du code-barres ne sont PAS un numéro fiable (CLAUDE.md), et l'add-on EAN-2 qui
le porterait vraiment n'a pas pu être vérifié sur un magazine réel.
*@
<div class="champs-numero">
<label class="champ">
<span class="champ-libelle">Numéro</span>
<input class="champ-saisie" type="text" placeholder="43" @bind="_numero"
@bind:event="oninput" @onkeydown="SurTouche" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Parution <span class="champ-aide">(facultatif)</span></span>
<input class="champ-saisie" type="date" @bind="_parution" />
</label>
<label class="champ">
<span class="champ-libelle">Note <span class="champ-aide">(facultatif)</span></span>
<input class="champ-saisie" type="text" @bind="_note" @bind:event="oninput"
placeholder="dossier sur l'eau" autocomplete="off" />
</label>
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_numero))"
title="@MotifBlocage"
@onclick="AjouterAsync">
Ajouter ce numéro
</button>
</div>
<h2 class="titre-section">La revue</h2>
<label class="champ">
<span class="champ-libelle">Nom</span>
<input class="champ-saisie" type="text" @bind="_titre" @bind:event="oninput" />
</label>
<label class="champ">
<span class="champ-libelle">ISSN <span class="champ-aide">(identifiant de la revue)</span></span>
<input class="champ-saisie" type="text" @bind="_issn" @bind:event="oninput"
placeholder="2466-6718" autocomplete="off" />
</label>
<label class="champ">
<span class="champ-libelle">Éditeur</span>
<input class="champ-saisie" type="text" @bind="_editeur" @bind:event="oninput" />
</label>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titre))"
title="@MotifBlocage"
@onclick="EnregistrerAsync">
Enregistrer
</button>
<button type="button" class="bouton bouton-discret bouton-danger"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
@onclick="SupprimerAsync">
@(_confirmeSuppression ? "Confirmer la suppression" : "Supprimer la revue")
</button>
</div>
@if (_confirmeSuppression)
{
<p class="message-avertissement" role="status">
Les @_revue.Numeros.Count numéro(s) recensés disparaîtront avec elle : ils n'existent
que par leur revue.
</p>
}
}
@code {
[Parameter] public int Id { get; set; }
private RevueDto? _revue;
private bool _chargement = true;
private bool _enCours;
private string? _erreur;
private bool _confirmeSuppression;
private string _titre = string.Empty;
private string? _issn;
private string? _editeur;
private string _numero = string.Empty;
private DateTime? _parution;
private string? _note;
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
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;
_revue = (await Api.ListerRevuesAsync()).FirstOrDefault(r => r.Id == Id);
_chargement = false;
if (_revue is not null)
{
_titre = _revue.Titre;
_issn = _revue.Issn;
_editeur = _revue.Editeur;
}
}
private void Appliquer(RevueDto revue)
{
_revue = revue;
_titre = revue.Titre;
_issn = revue.Issn;
_editeur = revue.Editeur;
}
private async Task SurTouche(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_numero) && Reseau.EnLigne)
{
await AjouterAsync();
}
}
private async Task AjouterAsync()
{
_enCours = true;
_erreur = null;
// Comme pour les prêts : le <input type="date"> produit une date SANS fuseau. Elle est
// déclarée locale avant conversion, sans quoi la parution se décalerait d'un jour pour
// la moitié du globe.
var parution = _parution is { } date
? DateTime.SpecifyKind(date, DateTimeKind.Local).ToUniversalTime()
: (DateTime?)null;
var resultat = await Api.AjouterNumeroAsync(Id, new AjoutNumeroRevue
{
Numero = _numero,
DateParution = parution,
Note = _note,
});
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Appliquer(resultat.Valeur!);
_numero = string.Empty;
_parution = null;
_note = null;
}
private async Task RetirerAsync(NumeroRevueDto numero)
{
_enCours = true;
_erreur = await Api.RetirerNumeroAsync(numero.Id);
_enCours = false;
if (_erreur is null)
{
await ChargerAsync();
}
}
private async Task EnregistrerAsync()
{
_enCours = true;
_erreur = null;
var resultat = await Api.ModifierRevueAsync(
Id, new EnregistrementRevue { Titre = _titre, Issn = _issn, Editeur = _editeur });
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Appliquer(resultat.Valeur!);
}
private async Task SupprimerAsync()
{
if (!_confirmeSuppression)
{
_confirmeSuppression = true;
return;
}
_enCours = true;
_erreur = await Api.SupprimerRevueAsync(Id);
_enCours = false;
if (_erreur is null)
{
Navigation.NavigateTo("revues");
}
}
}
+134
View File
@@ -0,0 +1,134 @@
@page "/revues"
@inject ServiceLivresApi Api
@inject EtatReseau Reseau
@implements IDisposable
<PageTitle>MaBibli — revues</PageTitle>
<h1 class="titre-page">Revues</h1>
<p class="message-discret">
Une fiche par revue, les numéros à l'intérieur — douze numéros d'un magazine ne font pas
douze fiches. Les revues ne sont pas au catalogue : ce ne sont pas des livres.
</p>
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
<div class="barre-recherche">
<input class="champ-saisie" type="text" placeholder="Nom d'une revue…"
@bind="_nouvelle" @bind:event="oninput" @onkeydown="SurTouche"
aria-label="Nom de la nouvelle revue" autocomplete="off" />
</div>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_nouvelle))"
title="@MotifBlocage"
@onclick="CreerAsync">
Ajouter la revue
</button>
</div>
@if (_revues is null)
{
<p class="message-discret" role="status">Chargement…</p>
}
else if (_revues.Count == 0)
{
<p class="message-discret">
Aucune revue. Scannez le code-barres d'un magazine, ou saisissez son nom ci-dessus :
le code d'une revue commence par 977 et porte un ISSN, pas un ISBN.
</p>
}
else
{
<ul class="liste-series">
@foreach (var revue in _revues)
{
<li class="carte-serie" @key="revue.Id">
<a class="carte-titre" href="@($"revues/{revue.Id}")">@revue.Titre</a>
<p class="carte-details">
<span>
@(revue.Numeros.Count == 0
? "Aucun numéro"
: $"{revue.Numeros.Count} numéro{(revue.Numeros.Count > 1 ? "s" : "")}")
</span>
@if (!string.IsNullOrWhiteSpace(revue.Issn))
{
<span class="code-isbn">ISSN @revue.Issn</span>
}
@if (!string.IsNullOrWhiteSpace(revue.Editeur))
{
<span>@revue.Editeur</span>
}
</p>
@* Le dernier numéro reçu répond seul à « est-ce que je l'ai déjà ? », qui est la
question qu'on se pose devant un kiosque. *@
@if (revue.Numeros.FirstOrDefault() is { } dernier)
{
<p class="carte-details">Dernier : n° @dernier.Numero @Quand(dernier)</p>
}
</li>
}
</ul>
}
@code {
private IReadOnlyList<RevueDto>? _revues;
private string _nouvelle = string.Empty;
private bool _enCours;
private string? _erreur;
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
private static string Quand(NumeroRevueDto numero) =>
numero.DateParution is { } date ? $"({date.ToLocalTime():MM/yyyy})" : string.Empty;
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() => _revues = await Api.ListerRevuesAsync();
private async Task SurTouche(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouvelle) && Reseau.EnLigne)
{
await CreerAsync();
}
}
private async Task CreerAsync()
{
_enCours = true;
_erreur = null;
var resultat = await Api.CreerRevueAsync(new EnregistrementRevue { Titre = _nouvelle });
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
_nouvelle = string.Empty;
await ChargerAsync();
}
}
@@ -48,6 +48,15 @@ public static class ClesCache
/// </para> /// </para>
/// </remarks> /// </remarks>
public const string Series = "series"; public const string Series = "series";
/// <summary>
/// Les revues, avec les numéros possédés. Communes au foyer, comme les séries.
/// </summary>
/// <remarks>
/// Sa raison d'être est la même que celle de la liste d'envies : savoir <b>si l'on a déjà
/// ce numéro</b> se demande devant le présentoir d'un kiosque, là où le réseau manque.
/// </remarks>
public const string Revues = "revues";
} }
/// <summary>Un instantané relu du cache, avec la date de la synchronisation qui l'a produit.</summary> /// <summary>Un instantané relu du cache, avec la date de la synchronisation qui l'a produit.</summary>
@@ -48,6 +48,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
await ListerPretsEnCoursAsync(ct); await ListerPretsEnCoursAsync(ct);
await ListerSouhaitsAsync(ct); await ListerSouhaitsAsync(ct);
await ListerSeriesAsync(ct); await ListerSeriesAsync(ct);
await ListerRevuesAsync(ct);
await ObtenirUtilisateurAsync(ct); await ObtenirUtilisateurAsync(ct);
return reseau.EnLigne; return reseau.EnLigne;
} }
@@ -518,6 +519,7 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
await ListerAuteursAsync(); await ListerAuteursAsync();
await ListerSouhaitsAsync(); await ListerSouhaitsAsync();
await ListerSeriesAsync(); await ListerSeriesAsync();
await ListerRevuesAsync();
} }
catch (Exception) catch (Exception)
{ {
@@ -807,6 +809,53 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
public Task<string?> SupprimerSerieAsync(int id, CancellationToken ct = default) => public Task<string?> SupprimerSerieAsync(int id, CancellationToken ct = default) =>
SupprimerAsync($"api/series/{id}", ct); SupprimerAsync($"api/series/{id}", ct);
// ─────────────────────────────────────────────────────────────────────────
// Revues et magazines. Communes au foyer ; une revue n'est pas un livre et ne passe donc
// jamais par le catalogue.
// ─────────────────────────────────────────────────────────────────────────
public async Task<IReadOnlyList<RevueDto>> ListerRevuesAsync(CancellationToken ct = default)
{
var (ok, revues) = await EssayerAsync(
() => http.GetFromJsonAsync<List<RevueDto>>("api/revues", Json, ct));
if (ok)
{
await MemoriserAsync(ClesCache.Revues, revues ?? []);
return revues ?? [];
}
var instantane = await LireCacheAsync<List<RevueDto>>(ClesCache.Revues);
return instantane?.Donnees ?? [];
}
/// <summary>
/// Crée la revue, ou rend celle qui existe déjà — même ISSN, ou même titre.
/// </summary>
/// <remarks>
/// C'est ce que réclame le scan d'un code-barres <c>977</c> : le second numéro d'un
/// magazine retombe forcément sur la même revue, et l'utilisateur ne fait jamais qu'un
/// geste, « voici un numéro de plus ».
/// </remarks>
public async Task<ResultatApi<RevueDto>> CreerRevueAsync(
EnregistrementRevue saisie, CancellationToken ct = default) =>
await EcrireAsync<RevueDto>(() => http.PostAsJsonAsync("api/revues", saisie, Json, ct), ct);
public async Task<ResultatApi<RevueDto>> ModifierRevueAsync(
int id, EnregistrementRevue saisie, CancellationToken ct = default) =>
await EcrireAsync<RevueDto>(() => http.PutAsJsonAsync($"api/revues/{id}", saisie, Json, ct), ct);
public async Task<ResultatApi<RevueDto>> AjouterNumeroAsync(
int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default) =>
await EcrireAsync<RevueDto>(
() => http.PostAsJsonAsync($"api/revues/{revueId}/numeros", saisie, Json, ct), ct);
public Task<string?> RetirerNumeroAsync(int numeroId, CancellationToken ct = default) =>
SupprimerAsync($"api/revues/numeros/{numeroId}", ct);
public Task<string?> SupprimerRevueAsync(int id, CancellationToken ct = default) =>
SupprimerAsync($"api/revues/{id}", ct);
/// <summary>Suppression générique : refus hors-ligne, 204 attendu, instantanés rafraîchis.</summary> /// <summary>Suppression générique : refus hors-ligne, 204 attendu, instantanés rafraîchis.</summary>
private async Task<string?> SupprimerAsync(string url, CancellationToken ct) private async Task<string?> SupprimerAsync(string url, CancellationToken ct)
{ {
+29
View File
@@ -1015,3 +1015,32 @@ body {
font-size: 0.9rem; font-size: 0.9rem;
color: var(--mb-texte-doux); color: var(--mb-texte-doux);
} }
/* --- Revues et numéros --- */
.liste-numeros {
list-style: none;
margin: 0.5rem 0;
padding: 0;
}
.carte-numero {
display: flex;
gap: 0.6rem;
align-items: flex-start;
justify-content: space-between;
padding: 0.6rem 0;
border-bottom: 1px solid var(--mb-bord);
}
/* Numéro, date et note tiennent sur une ligne dès qu'il y a la place, et se replient sinon :
la saisie d'un numéro est répétitive, on ne veut pas la faire défiler. */
.champs-numero {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.champs-numero .champ {
flex: 1 1 8rem;
}
+8 -3
View File
@@ -5,10 +5,15 @@ namespace MaBibli.Shared.Dtos;
/// (préfixe <c>977</c>) et non d'un livre. /// (préfixe <c>977</c>) et non d'un livre.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Le modèle de données ne gère <b>pas</b> les périodiques : ni numéro, ni date de parution. /// Cet objet <b>décrit un code-barres</b>, il ne catalogue rien : il dit à l'utilisateur ce
/// Cet objet n'existe donc pas pour cataloguer une revue, mais pour <b>dire à l'utilisateur ce /// qu'il vient de scanner au lieu de lui répondre « aucun résultat » — ce qui l'enverrait
/// qu'il vient de scanner</b> au lieu de lui répondre « aucun résultat » — ce qui l'enverrait
/// chercher un défaut là où il n'y en a pas. /// chercher un défaut là où il n'y en a pas.
/// <para>
/// Depuis le 2026-08-19, les revues ont leur modèle (<see cref="Entites.Revue"/>) : l'écran
/// enchaîne donc sur la fiche de la revue, où s'ajoute le numéro scanné. ⚠️ Le <b>numéro</b> ne
/// figure pas ici : les deux chiffres de parution du code-barres ne sont pas fiables, et
/// l'add-on EAN-2 qui le porterait vraiment n'a pas été vérifié sur un magazine réel.
/// </para>
/// </remarks> /// </remarks>
public record PeriodiqueDetecte public record PeriodiqueDetecte
{ {
+58
View File
@@ -0,0 +1,58 @@
namespace MaBibli.Shared.Dtos;
/// <summary>
/// Une revue et les numéros qu'on en possède.
/// </summary>
/// <remarks>
/// Comme pour les séries, <b>une seule lecture rend tout</b> : la liste des revues sert aussi
/// le détail de chacune, ce qui donne un unique instantané hors-ligne à tenir à jour.
/// </remarks>
public record RevueDto
{
public required int Id { get; init; }
public required string Titre { get; init; }
/// <summary>ISSN à tiret, quand il est connu. Identifiant de la <b>revue</b>, jamais un ISBN.</summary>
public string? Issn { get; init; }
public string? Editeur { get; init; }
public string? AjoutePar { get; init; }
/// <summary>Numéros possédés, du plus récent au plus ancien.</summary>
public IReadOnlyList<NumeroRevueDto> Numeros { get; init; } = [];
}
/// <summary>Un numéro possédé. Recensé seulement : ni prêt, ni statut de lecture.</summary>
public record NumeroRevueDto
{
public required int Id { get; init; }
public required string Numero { get; init; }
public DateTime? DateParution { get; init; }
public string? Note { get; init; }
}
/// <summary>Charge utile de création ou de modification d'une revue.</summary>
public record EnregistrementRevue
{
public string Titre { get; set; } = string.Empty;
public string? Issn { get; set; }
public string? Editeur { get; set; }
}
/// <summary>Charge utile d'ajout d'un numéro.</summary>
public record AjoutNumeroRevue
{
/// <summary>Obligatoire : deux numéros sans numéro seraient indiscernables.</summary>
public string Numero { get; set; } = string.Empty;
public DateTime? DateParution { get; set; }
public string? Note { get; set; }
}
+103
View File
@@ -0,0 +1,103 @@
using MaBibli.Shared.Textes;
namespace MaBibli.Shared.Entites;
/// <summary>
/// Une revue — magazine, périodique —, et non l'un de ses numéros.
/// </summary>
/// <remarks>
/// <b>Pourquoi une table à part et non une ligne de <c>Livres</c> ?</b> Même raisonnement que
/// pour <see cref="LivreSouhaite"/> et <see cref="Serie"/>, et il vaut d'être répété : logée
/// dans <c>Livres</c>, une revue entrerait <b>mécaniquement</b> dans le catalogue, ses
/// compteurs, la détection de doublons, les séries et les bibliographies par auteur — et il
/// faudrait écrire « et qui n'est pas une revue » à chaque lecture. Un invariant qu'on réécrit
/// partout finit par être oublié quelque part.
/// <para>
/// <b>Une fiche par revue, les numéros à l'intérieur.</b> C'est ce qui évite le défaut redouté
/// dès le premier lot : douze numéros d'un même magazine ne font pas douze fiches identiques,
/// puisqu'ils partagent la fiche de leur revue.
/// </para>
/// <para>
/// ⚠️ L'<see cref="Issn"/> est un identifiant de <b>revue</b>, jamais un ISBN. Ranger un code
/// <c>977</c> dans <c>Livre.Isbn</c> ferait échouer tout lookup ultérieur sur cette fiche —
/// c'est précisément ce que cette table évite.
/// </para>
/// </remarks>
public class Revue
{
public int Id { get; set; }
public string Titre { get; set; } = string.Empty;
/// <summary>Titre mis à plat. Index <b>unique</b> : une revue, une fiche.</summary>
public string TitreNormalise { get; set; } = string.Empty;
/// <summary>
/// ISSN sous sa forme canonique à tiret (<c>2466-6718</c>), quand on le connaît.
/// </summary>
/// <remarks>
/// Facultatif : une revue se catalogue à la main sans que personne n'ait scanné son
/// code-barres. Son unicité est portée par un index <b>partiel</b> — deux <c>NULL</c> sont
/// distincts pour SQLite, une unicité simple laisserait donc passer autant de revues sans
/// ISSN qu'on veut, ce qui est justement le comportement voulu.
/// </remarks>
public string? Issn { get; set; }
public string? Editeur { get; set; }
public DateTime DateAjout { get; set; }
/// <summary>Trace de saisie. <b>Pas une frontière</b> : les revues sont communes au foyer.</summary>
public string? AjoutePar { get; set; }
public List<NumeroRevue> Numeros { get; set; } = [];
public void RecalculerFormes()
{
Titre = Titre.Trim();
TitreNormalise = NormalisationTexte.Normaliser(Titre);
Issn = string.IsNullOrWhiteSpace(Issn) ? null : Issn.Trim();
}
}
/// <summary>
/// Un numéro possédé d'une revue.
/// </summary>
/// <remarks>
/// <b>Décidé le 2026-08-19 : un numéro est recensé, rien de plus.</b> Ni prêt, ni statut de
/// lecture — ces deux mécanismes sont attachés à <see cref="Livre"/> par clé étrangère, et les
/// rouvrir demanderait une seconde table de prêts ou une parenté commune, pour un usage non
/// confirmé. Le choix est <b>réversible</b> : les ajouter plus tard ne détruit rien de ce qui
/// est enregistré ici.
/// </remarks>
public class NumeroRevue
{
public int Id { get; set; }
public int RevueId { get; set; }
public Revue? Revue { get; set; }
/// <summary>
/// Le numéro tel qu'il est imprimé (« 43 », « hors-série 7 »). <b>Obligatoire</b> :
/// sans lui, deux numéros de la même revue seraient indiscernables.
/// </summary>
public string Numero { get; set; } = string.Empty;
/// <summary>Forme mise à plat, porteuse de l'unicité au sein de la revue.</summary>
public string NumeroNormalise { get; set; } = string.Empty;
/// <summary>Date de parution, quand elle est connue. En UTC comme toutes les dates.</summary>
public DateTime? DateParution { get; set; }
/// <summary>Note libre : « dossier sur l'eau », « prêté à Paul », « acheté en gare ».</summary>
public string? Note { get; set; }
public DateTime DateAjout { get; set; }
public void RecalculerFormes()
{
Numero = Numero.Trim();
NumeroNormalise = NormalisationTexte.Normaliser(Numero);
}
}
+180
View File
@@ -0,0 +1,180 @@
using MaBibli.Api.Data;
using MaBibli.Api.Services.Revues;
using MaBibli.Shared.Dtos;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Tests;
/// <summary>
/// Revues et numéros, sur une base SQLite <b>en mémoire</b> : l'unicité d'un ISSN passe par un
/// index partiel, qui n'existe qu'en base.
/// </summary>
/// <remarks>
/// L'invariant central : <b>une fiche par revue</b>, les numéros à l'intérieur. C'est ce qui
/// évite douze fiches identiques pour douze numéros d'un même magazine.
/// </remarks>
public class ServiceRevuesTests : IDisposable
{
private readonly SqliteConnection _connexion;
private readonly MaBibliDbContext _db;
private readonly ServiceRevues _revues;
private const string Mathieu = "mathieu";
public ServiceRevuesTests()
{
_connexion = new SqliteConnection("Data Source=:memory:");
_connexion.Open();
var options = new DbContextOptionsBuilder<MaBibliDbContext>()
.UseSqlite(_connexion)
.Options;
_db = new MaBibliDbContext(options);
_db.Database.EnsureCreated();
_revues = new ServiceRevues(_db);
}
private async Task<RevueDto> RevueAsync(string titre = "Médor", string? issn = "2466-6718")
{
var resultat = await _revues.CreerOuRetrouverAsync(
new EnregistrementRevue { Titre = titre, Issn = issn }, Mathieu);
Assert.Null(resultat.Erreur);
return resultat.Revue!;
}
[Fact]
public async Task Rescanner_la_meme_revue_ne_cree_pas_une_seconde_fiche()
{
var premiere = await RevueAsync();
// Le second numéro du même magazine porte le même ISSN : c'est le cas NORMAL du flux
// « 977 », et il ne doit surtout pas produire une fiche de plus.
var seconde = await RevueAsync("Médor (Bruxelles)");
Assert.Equal(premiere.Id, seconde.Id);
Assert.Single(await _revues.ListerAsync());
}
[Fact]
public async Task Une_revue_saisie_a_la_main_apprend_son_issn_au_premier_scan()
{
var aLaMain = await RevueAsync("Médor", issn: null);
var scannee = await RevueAsync("Médor");
Assert.Equal(aLaMain.Id, scannee.Id);
Assert.Equal("2466-6718", scannee.Issn);
}
[Fact]
public async Task Les_numeros_vivent_dans_la_revue_et_ne_font_pas_de_fiches()
{
var revue = await RevueAsync();
await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "43",
DateParution = new DateTime(2024, 6, 1, 0, 0, 0, DateTimeKind.Utc),
});
var apres = (await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "44",
DateParution = new DateTime(2024, 9, 1, 0, 0, 0, DateTimeKind.Utc),
})).Revue!;
Assert.Single(await _revues.ListerAsync());
// Du plus récent au plus ancien : sur un magazine, c'est le dernier reçu qu'on cherche.
Assert.Equal(["44", "43"], apres.Numeros.Select(n => n.Numero));
}
[Fact]
public async Task Un_numero_sans_numero_est_refuse()
{
var revue = await RevueAsync();
var resultat = await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = " " });
Assert.NotNull(resultat.Erreur);
}
[Fact]
public async Task Le_meme_numero_ne_sajoute_pas_deux_fois()
{
var revue = await RevueAsync();
await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = "43" });
var doublon = await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = "43" });
Assert.NotNull(doublon.Erreur);
}
[Fact]
public async Task Un_numero_a_paraitre_est_refuse()
{
var revue = await RevueAsync();
var resultat = await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "99",
DateParution = DateTime.UtcNow.AddMonths(2),
});
Assert.NotNull(resultat.Erreur);
}
[Fact]
public async Task Les_numeros_sans_date_ferment_la_liste()
{
var revue = await RevueAsync();
await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = "hors-série 1" });
var apres = (await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "43",
DateParution = new DateTime(2024, 6, 1, 0, 0, 0, DateTimeKind.Utc),
})).Revue!;
// Une date inconnue ne doit pas faire passer un numéro pour le plus ancien.
Assert.Equal(["43", "hors-série 1"], apres.Numeros.Select(n => n.Numero));
}
[Fact]
public async Task Supprimer_la_revue_emporte_ses_numeros()
{
var revue = await RevueAsync();
await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = "43" });
Assert.True(await _revues.SupprimerAsync(revue.Id));
// Contrairement aux livres d'une série, un numéro n'existe que par sa revue.
Assert.Empty(await _db.NumerosRevue.ToListAsync());
}
[Fact]
public async Task Deux_revues_ne_partagent_ni_titre_ni_issn()
{
await RevueAsync();
var autre = await RevueAsync("Socialter", issn: "2270-4426");
var titrePris = await _revues.ModifierAsync(
autre.Id, new EnregistrementRevue { Titre = "médor" });
var issnPris = await _revues.ModifierAsync(
autre.Id, new EnregistrementRevue { Titre = "Socialter", Issn = "2466-6718" });
Assert.NotNull(titrePris.Erreur);
Assert.NotNull(issnPris.Erreur);
}
public void Dispose()
{
_db.Dispose();
_connexion.Dispose();
GC.SuppressFinalize(this);
}
}