Donne aux numéros de revue leur couverture et leur une, et un tiret à l'ISSN

L'ISSN est désormais rangé sous sa forme à tiret, et pas seulement affiché ainsi.
C'est le seul endroit où le projet s'écarte de « la valeur stockée reste nue »,
et il y a une raison : le code-barres produit déjà un ISSN à tiret, et la BnF
interroge `bib.issn` avec le tiret. Un ISSN tapé « 24666718 » ne se rapprochait
donc de rien, et le scan suivant créait une seconde fiche à côté. La canonisation
a lieu avant la recherche, pas seulement à l'écriture. Les champs de saisie, eux,
gardent la valeur tapée : découper à la frappe se battrait avec le curseur.

`ServiceRenormalisation` rattrape les ISSN existants — par un passage dédié, le
mécanisme générique confondant tous les NULL en une seule clé, alors que
l'unicité est ici un index partiel. Une fiche dont la forme canonique est déjà
prise garde la sienne : le serveur doit démarrer.

La couverture d'un numéro est une URL collée, jamais une photo : aucune source ne
peut la fournir, l'ISSN désignant la revue et non la parution, et le projet ne
stocke aucun fichier. Le garde du relais de couvertures a donc été étendu aux
numéros — l'oublier n'aurait produit aucune erreur visible, seulement une image
présente en ligne et absente hors-ligne.

Les articles à la une vivent dans une table à part et non dans `Theme` : un thème
est un vocabulaire qu'on réutilise, un titre d'article est unique à sa parution.
Rien ne se partage, donc pas de n-n — une simple clé étrangère vers le numéro.
Ils se saisissent au point-virgule seul : « Ukraine, deux ans après » serait
coupé en deux par la virgule.

Un PUT sur le numéro était indispensable : on note un numéro le jour où on le
range et on en recopie le sommaire plus tard. Sans lui, couverture et une
n'auraient existé qu'à la création, c'est-à-dire jamais.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-21 11:25:29 +02:00
co-authored by Claude Opus 5
parent b8a5122b6f
commit 1d35dc06f4
22 changed files with 2293 additions and 672 deletions
+24
View File
@@ -46,6 +46,12 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
public DbSet<NumeroRevue> NumerosRevue => Set<NumeroRevue>();
/// <summary>
/// Articles à la une d'un numéro. Table à part de <see cref="Themes"/> : un titre d'article
/// est unique à sa parution et ne se réutilise jamais, là où un thème est un vocabulaire.
/// </summary>
public DbSet<ArticleUne> ArticlesUne => Set<ArticleUne>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -259,6 +265,24 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
numero.HasIndex(n => new { n.RevueId, n.NumeroNormalise }).IsUnique();
});
modelBuilder.Entity<ArticleUne>(article =>
{
article.Property(a => a.Titre).IsRequired();
article.Property(a => a.TitreNormalise).IsRequired();
// Cascade, comme les numéros sous leur revue : un article à la une n'existe que par
// la parution qui le porte. Rien de comparable à ElementSerie, dont la place doit
// survivre au livre.
article.HasOne(a => a.NumeroRevue)
.WithMany(n => n.Articles)
.HasForeignKey(a => a.NumeroRevueId)
.OnDelete(DeleteBehavior.Cascade);
// Le même article ne s'annonce pas deux fois à la une du même numéro. L'unicité est
// bornée au numéro : deux parutions peuvent parfaitement titrer pareil.
article.HasIndex(a => new { a.NumeroRevueId, a.TitreNormalise }).IsUnique();
});
modelBuilder.Entity<Pret>(pret =>
{
pret.Property(p => p.Emprunteur).IsRequired();
@@ -0,0 +1,641 @@
// <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("20260821084601_ImagesEtUnesDesNumeros")]
partial class ImagesEtUnesDesNumeros
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("NumeroRevueId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NumeroRevueId", "TitreNormalise")
.IsUnique();
b.ToTable("ArticlesUne");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CleRegroupement")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CleRegroupement")
.IsUnique();
b.HasIndex("NomNormalise");
b.ToTable("Auteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.BibliographieMasquee", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur", "AuteurId");
b.HasIndex(new[] { "AuteurId", "Utilisateur", "TitreNormalise" }, "IX_BibliographiesMasquees_Auteur_Utilisateur_Titre")
.IsUnique();
b.ToTable("BibliographiesMasquees");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("SerieId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LivreId");
b.HasIndex(new[] { "SerieId" }, "IX_ElementsSerie_SerieId");
b.HasIndex(new[] { "SerieId", "LivreId" }, "IX_ElementsSerie_SerieId_LivreId")
.IsUnique()
.HasFilter("\"LivreId\" IS NOT NULL");
b.ToTable("ElementsSerie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("TypeDocument")
.HasColumnType("INTEGER");
b.Property<string>("UrlNotice")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Isbn");
b.HasIndex("TitreNormalise");
b.ToTable("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "AuteurId");
b.HasIndex("AuteurId");
b.ToTable("LivreAuteurs");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreSouhaite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Annee")
.HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("AuteurNormalise")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int>("Rang")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex(new[] { "Utilisateur" }, "IX_LivresSouhaites_Utilisateur");
b.HasIndex(new[] { "Utilisateur", "TitreNormalise", "AuteurNormalise" }, "IX_LivresSouhaites_Utilisateur_Oeuvre")
.IsUnique();
b.ToTable("LivresSouhaites");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
{
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("ThemeId")
.HasColumnType("INTEGER");
b.HasKey("LivreId", "ThemeId");
b.HasIndex("ThemeId");
b.ToTable("LivreThemes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateParution")
.HasColumnType("TEXT");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<string>("Numero")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NumeroNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RevueId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RevueId", "NumeroNormalise")
.IsUnique();
b.ToTable("NumerosRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DatePret")
.HasColumnType("TEXT");
b.Property<DateTime?>("DateRetour")
.HasColumnType("TEXT");
b.Property<string>("Emprunteur")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId");
b.HasIndex(new[] { "LivreId" }, "IX_Prets_LivreId_EnCours")
.IsUnique()
.HasFilter("\"DateRetour\" IS NULL");
b.ToTable("Prets");
});
modelBuilder.Entity("MaBibli.Shared.Entites.RapprochementRefuse", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AuteurAId")
.HasColumnType("INTEGER");
b.Property<int>("AuteurBId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AuteurAId", "AuteurBId")
.IsUnique();
b.ToTable("RapprochementsRefuses");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<string>("Editeur")
.HasColumnType("TEXT");
b.Property<string>("Issn")
.HasColumnType("TEXT");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TitreNormalise")
.IsUnique();
b.HasIndex(new[] { "Issn" }, "IX_Revues_Issn")
.IsUnique()
.HasFilter("\"Issn\" IS NOT NULL");
b.ToTable("Revues");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAjout")
.HasColumnType("TEXT");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("SerieParenteId")
.HasColumnType("INTEGER");
b.Property<string>("Titre")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SerieParenteId");
b.HasIndex("TitreNormalise")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateMaj")
.HasColumnType("TEXT");
b.Property<int>("LivreId")
.HasColumnType("INTEGER");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("Utilisateur")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Utilisateur");
b.HasIndex("LivreId", "Utilisateur")
.IsUnique();
b.ToTable("StatutsLecture");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Nom")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("NomNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NomNormalise")
.IsUnique();
b.ToTable("Themes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ArticleUne", b =>
{
b.HasOne("MaBibli.Shared.Entites.NumeroRevue", "NumeroRevue")
.WithMany("Articles")
.HasForeignKey("NumeroRevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("NumeroRevue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.ElementSerie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany()
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MaBibli.Shared.Entites.Serie", "Serie")
.WithMany("Elements")
.HasForeignKey("SerieId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Serie");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreAuteur", b =>
{
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
.WithMany("Livres")
.HasForeignKey("AuteurId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Auteurs")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Auteur");
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.LivreTheme", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Themes")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Theme", "Theme")
.WithMany("Livres")
.HasForeignKey("ThemeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Livre");
b.Navigation("Theme");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.HasOne("MaBibli.Shared.Entites.Revue", "Revue")
.WithMany("Numeros")
.HasForeignKey("RevueId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Revue");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Prets")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.HasOne("MaBibli.Shared.Entites.Serie", "SerieParente")
.WithMany("SousSeries")
.HasForeignKey("SerieParenteId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SerieParente");
});
modelBuilder.Entity("MaBibli.Shared.Entites.StatutLecture", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Statuts")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Auteur", b =>
{
b.Navigation("Livres");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{
b.Navigation("Auteurs");
b.Navigation("Prets");
b.Navigation("Statuts");
b.Navigation("Themes");
});
modelBuilder.Entity("MaBibli.Shared.Entites.NumeroRevue", b =>
{
b.Navigation("Articles");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Revue", b =>
{
b.Navigation("Numeros");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Serie", b =>
{
b.Navigation("Elements");
b.Navigation("SousSeries");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Theme", b =>
{
b.Navigation("Livres");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <inheritdoc />
public partial class ImagesEtUnesDesNumeros : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CoverUrl",
table: "NumerosRevue",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "ArticlesUne",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
NumeroRevueId = table.Column<int>(type: "INTEGER", nullable: false),
Titre = table.Column<string>(type: "TEXT", nullable: false),
TitreNormalise = table.Column<string>(type: "TEXT", nullable: false),
Position = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ArticlesUne", x => x.Id);
table.ForeignKey(
name: "FK_ArticlesUne_NumerosRevue_NumeroRevueId",
column: x => x.NumeroRevueId,
principalTable: "NumerosRevue",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ArticlesUne_NumeroRevueId_TitreNormalise",
table: "ArticlesUne",
columns: new[] { "NumeroRevueId", "TitreNormalise" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ArticlesUne");
migrationBuilder.DropColumn(
name: "CoverUrl",
table: "NumerosRevue");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
@@ -89,6 +90,19 @@ public sealed class ServiceRenormalisation(MaBibliDbContext db, ILogger<ServiceR
n => n.RecalculerFormes(),
n => $"le numéro {n.Numero}");
// ⚠️ L'ISSN ne peut PAS passer par Renormaliser : sa clé est nullable — la plupart des
// revues n'en ont pas — et un jeu de clés confondrait tous ces NULL en une seule.
// Son unicité est d'ailleurs un index PARTIEL, précisément pour cette raison.
corriges += CanoniserLesIssn(revues);
var articles = await db.ArticlesUne.OrderBy(a => a.Id).ToListAsync(ct);
corriges += Renormaliser(
articles,
a => Cle(a.NumeroRevueId.ToString(), a.TitreNormalise),
a => Cle(a.NumeroRevueId.ToString(), NormalisationTexte.Normaliser(a.Titre)),
a => a.RecalculerFormes(),
a => $"l'article « {a.Titre} »");
var themes = await db.Themes.OrderBy(t => t.Id).ToListAsync(ct);
corriges += Renormaliser(
themes,
@@ -155,6 +169,53 @@ public sealed class ServiceRenormalisation(MaBibliDbContext db, ILogger<ServiceR
return corriges;
}
/// <summary>
/// Range les ISSN existants sous leur forme à tiret, sans jamais créer de collision.
/// </summary>
/// <remarks>
/// Rattrape les revues saisies à la main avant que la canonisation n'existe : « 24666718 »
/// devient « 2466-6718 », donc rapprochable du code-barres et de <c>bib.issn</c>.
/// <para>
/// ⚠️ Une revue dont l'ISSN canonique est déjà celui d'une autre <b>garde le sien</b> : la
/// fusion de deux fiches n'est pas une opération de démarrage, et l'unicité partielle
/// ferait échouer le lancement du serveur.
/// </para>
/// </remarks>
private int CanoniserLesIssn(IReadOnlyList<Revue> revues)
{
var porteuses = revues.Where(r => r.Issn is not null).ToList();
var occupes = new HashSet<string>(porteuses.Select(r => r.Issn!), StringComparer.Ordinal);
var corriges = 0;
foreach (var revue in porteuses)
{
var actuel = revue.Issn!;
var voulu = FormatageIssn.Canonique(actuel);
if (voulu is null || voulu == actuel)
{
continue;
}
occupes.Remove(actuel);
if (!occupes.Add(voulu))
{
occupes.Add(actuel);
logger.LogWarning(
"Renormalisation : la revue « {Titre} » garde l'ISSN {Issn}, sa forme à tiret est déjà prise.",
revue.Titre,
actuel);
continue;
}
revue.Issn = voulu;
corriges++;
}
return corriges;
}
/// <summary>Clé composite comparable, les composants ne pouvant pas contenir de NUL.</summary>
private static string Cle(params string?[] composants) => string.Join('\0', composants);
+12
View File
@@ -70,6 +70,18 @@ public static class RevuesEndpoints
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPut("/numeros/{numeroId:int}", async (
int numeroId, AjoutNumeroRevue saisie, IServiceRevues service, CancellationToken ct) =>
Repondre(await service.ModifierNumeroAsync(numeroId, saisie, ct)))
.WithName("ModifierNumeroRevue")
.WithSummary("Modifie un numéro : numéro, parution, note, couverture et articles à la une.")
.WithDescription(
"La couverture d'un numéro et sa une ne viennent d'aucune source — l'ISSN "
+ "désigne la revue, pas la parution — et se saisissent donc après coup.")
.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)
@@ -35,7 +35,8 @@ public interface IServiceCouvertures
/// </para>
/// <list type="number">
/// <item>
/// <b>L'URL doit déjà exister en base</b> comme couverture d'un livre ou d'une envie. Le proxy
/// <b>L'URL doit déjà exister en base</b> comme couverture d'un livre, d'une envie ou d'un
/// numéro de revue. Le proxy
/// ne va donc jamais chercher une URL choisie au moment de l'appel : seulement une valeur que
/// l'application détient déjà.
/// </item>
@@ -115,9 +116,16 @@ public sealed class ServiceCouvertures(HttpClient http, MaBibliDbContext db) : I
/// L'URL est-elle déjà enregistrée comme couverture ? Comparaison <b>exacte</b> : c'est la
/// valeur stockée qui autorise, pas une ressemblance.
/// </summary>
/// <remarks>
/// ⚠️ <b>Les numéros de revue en font partie</b> depuis qu'ils portent une couverture. Les
/// oublier ici aurait produit exactement le défaut que ce service existe pour corriger :
/// une image qui s'affiche en ligne et jamais hors-ligne, l'appel de mise en cache étant
/// refusé par le relais.
/// </remarks>
private async Task<bool> EstConnueAsync(string url, CancellationToken ct) =>
await db.Livres.AnyAsync(l => l.CoverUrl == url, ct)
|| await db.LivresSouhaites.AnyAsync(s => s.CoverUrl == url, ct);
|| await db.LivresSouhaites.AnyAsync(s => s.CoverUrl == url, ct)
|| await db.NumerosRevue.AnyAsync(n => n.CoverUrl == url, ct);
/// <summary>
/// Lit au plus <see cref="TailleMaximale"/> octets, et rend <c>null</c> si le corps dépasse.
+114 -7
View File
@@ -1,6 +1,7 @@
using MaBibli.Api.Data;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
@@ -36,6 +37,18 @@ public interface IServiceRevues
Task<ResultatRevue> AjouterNumeroAsync(
int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default);
/// <summary>
/// Modifie un numéro déjà recensé : son numéro, sa parution, sa note, sa couverture et ses
/// articles à la une.
/// </summary>
/// <remarks>
/// Sans ce point d'entrée, la couverture et les articles ne se seraient saisis qu'à la
/// création — c'est-à-dire jamais, puisqu'on ajoute un numéro le jour où on l'achète et
/// qu'on en recopie la une plus tard.
/// </remarks>
Task<ResultatRevue> ModifierNumeroAsync(
int numeroId, AjoutNumeroRevue saisie, CancellationToken ct = default);
Task<bool> RetirerNumeroAsync(int numeroId, CancellationToken ct = default);
}
@@ -57,7 +70,7 @@ public sealed class ServiceRevues(MaBibliDbContext db) : IServiceRevues
{
var revues = await db.Revues
.AsNoTracking()
.Include(r => r.Numeros)
.Include(r => r.Numeros).ThenInclude(n => n.Articles)
.OrderBy(r => r.TitreNormalise)
.ToListAsync(ct);
@@ -73,7 +86,10 @@ public sealed class ServiceRevues(MaBibliDbContext db) : IServiceRevues
return ResultatRevue.Invalide("Le titre de la revue est obligatoire.");
}
var issn = Vide(saisie.Issn);
// ⚠️ Canonisé AVANT la recherche, pas seulement à l'écriture : « 24666718 » et
// « 2466-6718 » désignent la même revue, et sans cela le scan suivant en créerait une
// seconde à côté de celle saisie à la main.
var issn = FormatageIssn.Canonique(saisie.Issn);
var normalise = NormalisationTexte.Normaliser(titre);
// ⚠️ « Créer OU retrouver » et non « créer » : le flux du code-barres 977 rescanne
@@ -81,7 +97,7 @@ public sealed class ServiceRevues(MaBibliDbContext db) : IServiceRevues
// à 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)
.Include(r => r.Numeros).ThenInclude(n => n.Articles)
.FirstOrDefaultAsync(
r => (issn != null && r.Issn == issn) || r.TitreNormalise == normalise, ct);
@@ -117,7 +133,7 @@ public sealed class ServiceRevues(MaBibliDbContext db) : IServiceRevues
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);
var revue = await db.Revues.Include(r => r.Numeros).ThenInclude(n => n.Articles).FirstOrDefaultAsync(r => r.Id == id, ct);
if (revue is null)
{
return ResultatRevue.Introuvable;
@@ -135,7 +151,7 @@ public sealed class ServiceRevues(MaBibliDbContext db) : IServiceRevues
return ResultatRevue.Invalide($"La revue « {titre} » existe déjà.");
}
var issn = Vide(saisie.Issn);
var issn = FormatageIssn.Canonique(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.");
@@ -168,7 +184,7 @@ public sealed class ServiceRevues(MaBibliDbContext db) : IServiceRevues
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);
var revue = await db.Revues.Include(r => r.Numeros).ThenInclude(n => n.Articles).FirstOrDefaultAsync(r => r.Id == revueId, ct);
if (revue is null)
{
return ResultatRevue.Introuvable;
@@ -199,19 +215,104 @@ public sealed class ServiceRevues(MaBibliDbContext db) : IServiceRevues
Numero = numero,
DateParution = saisie.DateParution,
Note = Vide(saisie.Note),
CoverUrl = Vide(saisie.CoverUrl),
DateAjout = DateTime.UtcNow,
};
ligne.RecalculerFormes();
RemplacerArticles(ligne, saisie.Articles);
db.NumerosRevue.Add(ligne);
await db.SaveChangesAsync(ct);
var relue = await db.Revues.AsNoTracking().Include(r => r.Numeros)
var relue = await db.Revues.AsNoTracking().Include(r => r.Numeros).ThenInclude(n => n.Articles)
.FirstAsync(r => r.Id == revueId, ct);
return ResultatRevue.Ok(Projeter(relue));
}
public async Task<ResultatRevue> ModifierNumeroAsync(
int numeroId, AjoutNumeroRevue saisie, CancellationToken ct = default)
{
var ligne = await db.NumerosRevue
.Include(n => n.Articles)
.FirstOrDefaultAsync(n => n.Id == numeroId, ct);
if (ligne 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 (await db.NumerosRevue.AnyAsync(
n => n.Id != numeroId && n.RevueId == ligne.RevueId && n.NumeroNormalise == normalise, ct))
{
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))
{
return ResultatRevue.Invalide("La date de parution ne peut pas être dans le futur.");
}
ligne.Numero = numero;
ligne.DateParution = saisie.DateParution;
ligne.Note = Vide(saisie.Note);
ligne.CoverUrl = Vide(saisie.CoverUrl);
ligne.RecalculerFormes();
RemplacerArticles(ligne, saisie.Articles);
await db.SaveChangesAsync(ct);
var relue = await db.Revues.AsNoTracking().Include(r => r.Numeros).ThenInclude(n => n.Articles)
.FirstAsync(r => r.Id == ligne.RevueId, ct);
return ResultatRevue.Ok(Projeter(relue));
}
/// <summary>
/// Remplace les articles à la une du numéro par ceux de la saisie.
/// </summary>
/// <remarks>
/// ⚠️ Remplacement et non fusion : la ligne de saisie <b>est</b> la liste. Un titre effacé
/// du champ doit disparaître, comme pour les thèmes d'un livre.
/// <para>
/// Le dédoublonnage a déjà eu lieu à la saisie (<c>ListeSaisie</c>), mais il est refait ici
/// sur la forme normalisée : c'est l'unicité <c>(NumeroRevueId, TitreNormalise)</c> qui est
/// en jeu, et un appel direct à l'API n'est pas passé par le champ texte.
/// </para>
/// </remarks>
private void RemplacerArticles(NumeroRevue numero, IReadOnlyList<string>? titres)
{
if (numero.Articles.Count > 0)
{
db.ArticlesUne.RemoveRange(numero.Articles);
numero.Articles.Clear();
}
var vus = new HashSet<string>(StringComparer.Ordinal);
var position = 0;
foreach (var titre in titres ?? [])
{
var article = new ArticleUne { NumeroRevue = numero, Titre = titre ?? string.Empty };
article.RecalculerFormes();
if (article.TitreNormalise.Length == 0 || !vus.Add(article.TitreNormalise))
{
continue;
}
article.Position = position++;
numero.Articles.Add(article);
}
}
public async Task<bool> RetirerNumeroAsync(int numeroId, CancellationToken ct = default)
{
var numero = await db.NumerosRevue.FirstOrDefaultAsync(n => n.Id == numeroId, ct);
@@ -249,6 +350,12 @@ public sealed class ServiceRevues(MaBibliDbContext db) : IServiceRevues
Numero = n.Numero,
DateParution = n.DateParution,
Note = n.Note,
CoverUrl = n.CoverUrl,
Articles = n.Articles
.OrderBy(a => a.Position)
.ThenBy(a => a.Id)
.Select(a => a.Titre)
.ToList(),
})
.ToList(),
};
@@ -0,0 +1,82 @@
@*
Les champs d'un numéro de revue, partagés par l'ajout et la modification.
Un seul exemplaire, et pas deux blocs jumeaux : le formulaire d'ajout et celui de
modification doivent proposer exactement les mêmes champs, sinon une couverture ou une une
saisies à la création ne seraient plus modifiables ensuite — ce qui est précisément le cas
d'usage (on ajoute un numéro le jour où on l'achète, on en recopie la une plus tard).
*@
<div class="champs-numero">
<label class="champ">
<span class="champ-libelle">Numéro</span>
<input class="champ-saisie" type="text" placeholder="43" @bind="Saisie.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="Saisie.Note" @bind:event="oninput"
placeholder="dossier sur l'eau" autocomplete="off" />
</label>
</div>
@*
⚠️ Séparateur : le POINT-VIRGULE, et pas la virgule des thèmes de livres. Un titre d'article
en contient souvent (« Ukraine, deux ans après »), et la virgule le couperait en deux.
*@
<label class="champ">
<span class="champ-libelle">
À la une <span class="champ-aide">(titres séparés par des points-virgules)</span>
</span>
<input class="champ-saisie" type="text" @bind="Saisie.ArticlesTexte" @bind:event="oninput"
placeholder="Ukraine, deux ans après ; Le retour du train de nuit" autocomplete="off" />
</label>
@*
⚠️ Une URL, pas une photo. Aucune source ne donne la couverture d'un NUMÉRO : l'ISSN désigne
la revue, pas la parution. Et le projet ne stocke aucun fichier — « ebooks : fiches
uniquement » vaut ici aussi.
*@
<label class="champ">
<span class="champ-libelle">
Couverture <span class="champ-aide">(adresse d'une image, facultatif)</span>
</span>
<input class="champ-saisie" type="url" @bind="Saisie.CoverUrl" @bind:event="oninput"
placeholder="https://…" autocomplete="off" />
</label>
@code {
[Parameter, EditorRequired] public AjoutNumeroRevue Saisie { get; set; } = default!;
/// <summary>
/// Date affichée par le champ, en <b>local</b>.
/// </summary>
/// <remarks>
/// ⚠️ Le <c>&lt;input type="date"&gt;</c> produit une date sans fuseau. Elle est déclarée
/// locale ici et convertie en UTC, comme toutes les dates du projet : sans cela la parution
/// se décale d'un jour pour la moitié du globe. La conversion se fait dans le composant
/// pour que les deux écrans qui s'en servent ne puissent pas l'oublier chacun de leur côté.
/// </remarks>
private DateTime? Parution
{
get => Saisie.DateParution?.ToLocalTime().Date;
set => Saisie.DateParution = value is { } date
? DateTime.SpecifyKind(date, DateTimeKind.Local).ToUniversalTime()
: null;
}
[Parameter] public EventCallback<KeyboardEventArgs> OnEntree { get; set; }
private async Task SurTouche(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
await OnEntree.InvokeAsync(e);
}
}
}
+1 -1
View File
@@ -39,7 +39,7 @@
<label class="champ">
<span class="champ-libelle">
ISSN<span class="champ-aide"> (facultatif — huit chiffres, imprimé près du code-barres)</span>
ISSN<span class="champ-aide"> (facultatif — huit chiffres, imprimé près du code-barres ; le tiret est posé à l'enregistrement)</span>
</span>
<input class="champ-saisie" type="text" @bind="_issn" @bind:event="oninput"
placeholder="2466-6718" autocomplete="off" />
+150 -68
View File
@@ -19,7 +19,9 @@ else
<p class="carte-details">
@if (!string.IsNullOrWhiteSpace(_revue.Issn))
{
<span class="code-isbn">ISSN @_revue.Issn</span>
@* K2 : un ISSN se coupe TOUJOURS au même endroit, contrairement aux tranches d'un
ISBN. Aucune table à embarquer, et aucune coupure ne peut être fausse. *@
<span class="code-isbn">ISSN @FormatageIssn.Afficher(_revue.Issn)</span>
}
@if (!string.IsNullOrWhiteSpace(_revue.Editeur))
{
@@ -49,32 +51,83 @@ else
@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>
@if (_edition == numero.Id)
{
<div class="carte-corps">
<ChampsNumeroRevue Saisie="_saisieEdition" OnEntree="EnregistrerNumeroAsync" />
<p class="carte-details">
@if (numero.DateParution is { } parution)
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisieEdition.Numero))"
title="@MotifBlocage"
@onclick="EnregistrerNumeroAsync">
Enregistrer ce numéro
</button>
<button type="button" class="bouton bouton-discret"
disabled="@_enCours" @onclick="() => _edition = null">
Annuler
</button>
</div>
</div>
}
else
{
@* La couverture d'un numéro ne vient d'aucune source — l'ISSN désigne la
revue, pas la parution : sans URL collée, c'est le substitut à
initiale qui s'affiche, et il n'est pas cliquable. *@
<Couverture Url="@numero.CoverUrl" Titre="@($"N° {numero.Numero}")"
Classe="couverture-petite" Agrandissable="true" />
<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>
@* Les articles à la une décrivent CE numéro, pas la revue : ils
changent à chaque parution. D'où une étiquette par titre, sur la
fiche du numéro et nulle part ailleurs. *@
@if (numero.Articles.Count > 0)
{
<span>@parution.ToLocalTime().ToString("MMMM yyyy")</span>
<p class="carte-details">
@foreach (var article in numero.Articles)
{
<span class="etiquette etiquette-une">@article</span>
}
</p>
}
else
@if (!string.IsNullOrWhiteSpace(numero.Note))
{
<span>Date de parution inconnue</span>
<p class="souhait-note">@numero.Note</p>
}
</p>
</div>
@if (!string.IsNullOrWhiteSpace(numero.Note))
{
<p class="souhait-note">@numero.Note</p>
}
</div>
<div class="actions-tome">
<button type="button" class="bouton bouton-compact"
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
aria-label="@($"Modifier le numéro {numero.Numero}")"
@onclick="() => OuvrirEdition(numero)">
Modifier
</button>
<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>
<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>
</div>
}
</li>
}
</ul>
@@ -96,28 +149,11 @@ else
</p>
}
<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>
<ChampsNumeroRevue Saisie="_saisie" OnEntree="AjouterAsync" />
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_numero))"
disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_saisie.Numero))"
title="@MotifBlocage"
@onclick="AjouterAsync">
Ajouter ce numéro
@@ -132,7 +168,9 @@ else
</label>
<label class="champ">
<span class="champ-libelle">ISSN <span class="champ-aide">(identifiant de la revue)</span></span>
<span class="champ-libelle">
ISSN <span class="champ-aide">(identifiant de la revue ; le tiret est posé à l'enregistrement)</span>
</span>
<input class="champ-saisie" type="text" @bind="_issn" @bind:event="oninput"
placeholder="2466-6718" autocomplete="off" />
</label>
@@ -194,9 +232,12 @@ else
private string? _issn;
private string? _editeur;
private string _numero = string.Empty;
private DateTime? _parution;
private string? _note;
/// <summary>Saisie du numéro qu'on ajoute.</summary>
private AjoutNumeroRevue _saisie = new();
/// <summary>Numéro en cours de modification, et sa saisie — <c>null</c> = aucun.</summary>
private int? _edition;
private AjoutNumeroRevue _saisieEdition = new();
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
@@ -228,7 +269,7 @@ else
if (!_numeroPropose && !string.IsNullOrWhiteSpace(NumeroScanne))
{
_numeroPropose = true;
_numero = NumeroScanne.Trim();
_saisie.Numero = NumeroScanne.Trim();
}
}
@@ -240,32 +281,19 @@ else
_editeur = revue.Editeur;
}
private async Task SurTouche(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_numero) && Reseau.EnLigne)
{
await AjouterAsync();
}
}
private async Task AjouterAsync()
{
if (string.IsNullOrWhiteSpace(_saisie.Numero) || !Reseau.EnLigne || _enCours)
{
return;
}
_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,
});
// La conversion de la date en UTC vit dans ChampsNumeroRevue : les deux formulaires qui
// s'en servent ne peuvent donc pas l'oublier chacun de leur côté.
var resultat = await Api.AjouterNumeroAsync(Id, _saisie);
_enCours = false;
@@ -276,9 +304,56 @@ else
}
Appliquer(resultat.Valeur!);
_numero = string.Empty;
_parution = null;
_note = null;
_saisie = new AjoutNumeroRevue();
}
/// <summary>
/// Ouvre la modification d'un numéro, en recopiant ce qu'il porte déjà.
/// </summary>
/// <remarks>
/// La couverture et les articles à la une <b>ne peuvent pas</b> être connus à l'achat : on
/// note un numéro quand on le range, et on en recopie la une plus tard. Sans cet écran, ces
/// deux champs n'auraient existé qu'à la création, c'est-à-dire jamais.
/// </remarks>
private void OuvrirEdition(NumeroRevueDto numero)
{
_erreur = null;
_edition = numero.Id;
_saisieEdition = new AjoutNumeroRevue
{
Numero = numero.Numero,
DateParution = numero.DateParution,
Note = numero.Note,
CoverUrl = numero.CoverUrl,
Articles = [.. numero.Articles],
};
}
private async Task EnregistrerNumeroAsync()
{
if (_edition is not { } numeroId
|| string.IsNullOrWhiteSpace(_saisieEdition.Numero)
|| !Reseau.EnLigne
|| _enCours)
{
return;
}
_enCours = true;
_erreur = null;
var resultat = await Api.ModifierNumeroAsync(numeroId, _saisieEdition);
_enCours = false;
if (!resultat.EstOk)
{
_erreur = resultat.Erreur;
return;
}
Appliquer(resultat.Valeur!);
_edition = null;
}
private async Task RetirerAsync(NumeroRevueDto numero)
@@ -287,6 +362,13 @@ else
_erreur = await Api.RetirerNumeroAsync(numero.Id);
_enCours = false;
// Le numéro retiré était peut-être celui qu'on modifiait : un formulaire ouvert sur une
// ligne disparue n'aurait plus rien à enregistrer.
if (_edition == numero.Id)
{
_edition = null;
}
if (_erreur is null)
{
await ChargerAsync();
+1 -1
View File
@@ -41,7 +41,7 @@ else
@if (!string.IsNullOrWhiteSpace(revue.Issn))
{
<span class="code-isbn">ISSN @revue.Issn</span>
<span class="code-isbn">ISSN @FormatageIssn.Afficher(revue.Issn)</span>
}
@if (!string.IsNullOrWhiteSpace(revue.Editeur))
@@ -1012,6 +1012,18 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
await EcrireAsync<RevueDto>(
() => http.PostAsJsonAsync($"api/revues/{revueId}/numeros", saisie, Json, ct), ct);
/// <summary>
/// Modifie un numéro déjà recensé, couverture et articles à la une compris.
/// </summary>
/// <remarks>
/// Ces deux-là ne viennent d'aucune source — l'ISSN désigne la revue, pas la parution — et
/// se saisissent donc après coup, une fois le magazine en main.
/// </remarks>
public async Task<ResultatApi<RevueDto>> ModifierNumeroAsync(
int numeroId, AjoutNumeroRevue saisie, CancellationToken ct = default) =>
await EcrireAsync<RevueDto>(
() => http.PutAsJsonAsync($"api/revues/numeros/{numeroId}", saisie, Json, ct), ct);
public Task<string?> RetirerNumeroAsync(int numeroId, CancellationToken ct = default) =>
SupprimerAsync($"api/revues/numeros/{numeroId}", ct);
+12
View File
@@ -1423,6 +1423,18 @@ body {
}
}
/* --- Articles à la une d'un numéro (lot O2) ---------------------------
Une couleur à part, parce que ce n'est pas un thème : un titre d'article
est unique à sa parution et ne se réutilise jamais, là où un thème est un
vocabulaire commun au catalogue.
------------------------------------------------------------------- */
.etiquette-une {
background: #eef2f7;
color: #24405e;
font-style: italic;
}
/* --- Créer depuis une liste : champ + bouton sur une ligne (lot M1) ----
⚠️ min-width: 0 sur le champ est ce qui fait tenir la ligne à 320 px : sans
lui, un élément de formulaire refuse de rétrécir sous sa largeur intrinsèque
+34 -1
View File
@@ -1,3 +1,6 @@
using System.Text.Json.Serialization;
using MaBibli.Shared.Textes;
namespace MaBibli.Shared.Dtos;
/// <summary>
@@ -34,6 +37,12 @@ public record NumeroRevueDto
public DateTime? DateParution { get; init; }
public string? Note { get; init; }
/// <summary>Couverture du numéro, quand une URL a été collée. Aucune source ne la fournit.</summary>
public string? CoverUrl { get; init; }
/// <summary>Articles annoncés à la une, dans l'ordre de saisie.</summary>
public IReadOnlyList<string> Articles { get; init; } = [];
}
/// <summary>Charge utile de création ou de modification d'une revue.</summary>
@@ -46,7 +55,7 @@ public record EnregistrementRevue
public string? Editeur { get; set; }
}
/// <summary>Charge utile d'ajout d'un numéro.</summary>
/// <summary>Charge utile d'ajout ou de modification d'un numéro.</summary>
public record AjoutNumeroRevue
{
/// <summary>Obligatoire : deux numéros sans numéro seraient indiscernables.</summary>
@@ -55,4 +64,28 @@ public record AjoutNumeroRevue
public DateTime? DateParution { get; set; }
public string? Note { get; set; }
/// <summary>
/// URL d'une image de couverture. <b>Aucune source ne peut la fournir</b> — l'ISSN désigne
/// la revue, pas la parution : elle est collée à la main, ou absente.
/// </summary>
public string? CoverUrl { get; set; }
/// <summary>Articles à la une, dans l'ordre de la couverture.</summary>
public List<string> Articles { get; set; } = [];
/// <summary>
/// Vue « une seule ligne » des <see cref="Articles"/>, pour un champ de saisie simple.
/// </summary>
/// <remarks>
/// ⚠️ Séparateur : le <b>point-virgule</b> seul, contrairement aux thèmes d'un livre. Un
/// titre d'article contient souvent une virgule (« Ukraine, deux ans après »), qui le
/// couperait en deux.
/// </remarks>
[JsonIgnore]
public string? ArticlesTexte
{
get => Articles.Count == 0 ? null : ListeSaisie.Joindre(Articles);
set => Articles = ListeSaisie.Decouper(value, virguleAussi: false);
}
}
+51
View File
@@ -0,0 +1,51 @@
using MaBibli.Shared.Textes;
namespace MaBibli.Shared.Entites;
/// <summary>
/// Un article annoncé <b>à la une</b> d'un numéro de revue.
/// </summary>
/// <remarks>
/// <b>Sur le NUMÉRO, jamais sur la revue</b> (tranché avec l'utilisateur le 2026-08-20) : les
/// articles à la une changent à chaque parution. Posés sur <see cref="Revue"/>, ils décriraient
/// une ligne éditoriale et non un sommaire.
/// <para>
/// <b>Pourquoi une table à part et non <see cref="Theme"/> ?</b> Un thème est un vocabulaire
/// qu'on <i>réutilise</i> — « dark fantasy » revient sur dix livres —, alors qu'un titre
/// d'article est unique à sa parution et ne se réutilisera jamais. Rangés dans
/// <c>Themes</c>, « Ukraine : le front d'hiver » et ses semblables rempliraient de bruit un
/// vocabulaire qui sert le catalogue.
/// </para>
/// <para>
/// ⚠️ Conséquence directe : <b>pas de n-n</b>. Une simple clé étrangère vers le numéro suffit,
/// puisque rien ne se partage. C'est ce qui distingue cette table de <c>LivreTheme</c>.
/// </para>
/// <para>
/// ⚠️ La saisie se fait au <b>point-virgule</b>, et surtout pas à la virgule : un titre
/// d'article en contient souvent (« Ukraine, deux ans après »), et elle le couperait en deux.
/// Voir <see cref="ListeSaisie"/>.
/// </para>
/// </remarks>
public class ArticleUne
{
public int Id { get; set; }
public int NumeroRevueId { get; set; }
public NumeroRevue? NumeroRevue { get; set; }
/// <summary>Le titre tel qu'il est imprimé en couverture.</summary>
public string Titre { get; set; } = string.Empty;
/// <summary>Forme mise à plat, porteuse de l'unicité au sein du numéro.</summary>
public string TitreNormalise { get; set; } = string.Empty;
/// <summary>Rang d'affichage : l'ordre de saisie est celui de la couverture.</summary>
public int Position { get; set; }
public void RecalculerFormes()
{
Titre = Titre.Trim();
TitreNormalise = NormalisationTexte.Normaliser(Titre);
}
}
+25 -1
View File
@@ -1,3 +1,4 @@
using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
namespace MaBibli.Shared.Entites;
@@ -56,7 +57,10 @@ public class Revue
{
Titre = Titre.Trim();
TitreNormalise = NormalisationTexte.Normaliser(Titre);
Issn = string.IsNullOrWhiteSpace(Issn) ? null : Issn.Trim();
// ⚠️ L'ISSN est rangé sous sa forme À TIRET, et ce n'est pas de la cosmétique : c'est
// celle que produit le code-barres (CodePeriodique.IssnDepuis) et celle qu'interroge
// bib.issn à la BnF. Un ISSN tapé « 24666718 » ne se serait rapproché de rien.
Issn = FormatageIssn.Canonique(Issn);
}
}
@@ -93,11 +97,31 @@ public class NumeroRevue
/// <summary>Note libre : « dossier sur l'eau », « prêté à Paul », « acheté en gare ».</summary>
public string? Note { get; set; }
/// <summary>
/// URL d'une image de couverture, collée à la main.
/// </summary>
/// <remarks>
/// ⚠️ <b>Aucune source ne peut la fournir</b>, contrairement aux livres : l'ISSN désigne la
/// <i>revue</i>, pas la parution, et OpenLibrary n'a donc rien à en dire. C'est pourquoi ce
/// champ n'est alimenté que par une URL saisie — et pourquoi l'écran n'en propose aucune.
/// <para>
/// ⚠️ Une image de numéro doit être <b>connue du relais</b>
/// (<c>GET /api/couvertures</c>) au même titre que celle d'un livre ou d'une envie, sans
/// quoi elle s'afficherait en ligne et jamais hors-ligne — exactement le défaut corrigé le
/// 2026-08-20 pour les hébergeurs sans CORS.
/// </para>
/// </remarks>
public string? CoverUrl { get; set; }
/// <summary>Articles annoncés à la une, dans l'ordre de saisie.</summary>
public List<ArticleUne> Articles { get; set; } = [];
public DateTime DateAjout { get; set; }
public void RecalculerFormes()
{
Numero = Numero.Trim();
NumeroNormalise = NormalisationTexte.Normaliser(Numero);
CoverUrl = string.IsNullOrWhiteSpace(CoverUrl) ? null : CoverUrl.Trim();
}
}
+93
View File
@@ -0,0 +1,93 @@
namespace MaBibli.Shared.Isbn;
/// <summary>
/// Mise en forme d'un ISSN : quatre chiffres, un tiret, quatre caractères (<c>2466-6718</c>).
/// </summary>
/// <remarks>
/// <b>Bien plus simple que <see cref="FormatageIsbn"/></b>, et ce n'est pas un hasard : un ISSN
/// se coupe <i>toujours</i> au même endroit, alors que les tranches d'un ISBN dépendent du
/// groupe linguistique puis de l'éditeur. Il n'y a donc aucune table à embarquer, et aucune
/// coupure ne peut être fausse.
/// <para>
/// ⚠️ <b>La forme à tiret est la forme canonique, y compris en base.</b> C'est le seul point où
/// ce fichier s'écarte de la règle « la valeur stockée reste nue » tenue pour l'ISBN, et il y a
/// une raison précise : <see cref="CodePeriodique.IssnDepuis"/> produit déjà un ISSN à tiret
/// depuis le code-barres, et la BnF interroge <c>bib.issn</c> avec le tiret. Un ISSN saisi à la
/// main sans tiret ne se serait donc rapproché de rien — ni de la revue déjà créée par un scan,
/// ni d'une notice BnF.
/// </para>
/// <para>
/// Les champs de <b>saisie</b> gardent en revanche ce que l'utilisateur tape : la canonisation a
/// lieu à la validation, jamais à la frappe, sans quoi le tiret se battrait avec le curseur.
/// </para>
/// </remarks>
public static class FormatageIssn
{
/// <summary>Nombre de caractères d'un ISSN, tiret non compris.</summary>
private const int Longueur = 8;
/// <summary>
/// Les huit caractères de l'ISSN, sans tiret ni espace, ou <c>null</c> si la saisie n'en
/// est pas un.
/// </summary>
/// <remarks>
/// La dernière position peut valoir <c>X</c> : c'est une clé modulo 11, comme celle d'un
/// ISBN-10. Elle n'est <b>pas vérifiée</b> ici — un ISSN mal recopié doit pouvoir être
/// enregistré et corrigé, pas refusé au motif d'un chiffre.
/// </remarks>
public static string? Normaliser(string? saisie)
{
if (string.IsNullOrWhiteSpace(saisie))
{
return null;
}
Span<char> tampon = stackalloc char[Longueur];
var ecrits = 0;
foreach (var caractere in saisie)
{
if (char.IsWhiteSpace(caractere) || caractere is '-' or '')
{
continue;
}
if (ecrits == Longueur)
{
return null;
}
var majuscule = char.ToUpperInvariant(caractere);
var valide = char.IsAsciiDigit(majuscule) || (majuscule == 'X' && ecrits == Longueur - 1);
if (!valide)
{
return null;
}
tampon[ecrits++] = majuscule;
}
return ecrits == Longueur ? new string(tampon) : null;
}
/// <summary>
/// Forme d'affichage à tiret. Une saisie qui n'est pas un ISSN est rendue <b>telle quelle</b> :
/// un code mal recopié doit se voir mal recopié, pas déguisé par un tiret.
/// </summary>
public static string? Afficher(string? valeur)
{
if (Normaliser(valeur) is not { } nu)
{
return valeur;
}
return $"{nu[..4]}-{nu[4..]}";
}
/// <summary>
/// Forme à ranger en base : le tiret quand c'est un ISSN, la saisie élaguée sinon.
/// </summary>
public static string? Canonique(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? null : Afficher(valeur.Trim());
}
+57
View File
@@ -0,0 +1,57 @@
using MaBibli.Shared.Isbn;
namespace MaBibli.Tests;
/// <summary>
/// Mise en forme d'un ISSN (lot K2).
/// </summary>
/// <remarks>
/// Le contraste avec <see cref="FormatageIsbnTests"/> est tout l'intérêt de ce fichier : un
/// ISSN se coupe <b>toujours</b> au même endroit, il n'y a donc aucune table à embarquer et
/// aucune coupure ne peut être fausse.
/// </remarks>
public class FormatageIssnTests
{
[Theory]
[InlineData("24666718", "2466-6718")]
[InlineData("2466-6718", "2466-6718")]
[InlineData(" 2466 6718 ", "2466-6718")]
[InlineData("0317847X", "0317-847X")]
[InlineData("0317847x", "0317-847X")]
public void Un_issn_prend_son_tiret(string saisie, string attendu) =>
Assert.Equal(attendu, FormatageIssn.Afficher(saisie));
/// <summary>
/// Ce qui n'est pas un ISSN ressort <b>intact</b> : un code mal recopié doit se voir mal
/// recopié, pas déguisé par un tiret. C'est la règle déjà tenue pour l'ISBN.
/// </summary>
[Theory]
[InlineData("2466671")] // sept caractères
[InlineData("246667181")] // neuf
[InlineData("2466-671A")] // une lettre qui n'est pas la clé
[InlineData("X4666718")] // le X n'est admis qu'en dernière position
public void Ce_qui_nest_pas_un_issn_nest_pas_deguise(string saisie)
{
Assert.Null(FormatageIssn.Normaliser(saisie));
Assert.Equal(saisie, FormatageIssn.Afficher(saisie));
}
[Fact]
public void Le_vide_reste_vide()
{
Assert.Null(FormatageIssn.Canonique(null));
Assert.Null(FormatageIssn.Canonique(" "));
}
/// <summary>
/// ⚠️ L'invariant qui donne son intérêt à la canonisation : la forme rangée en base est
/// celle que produit le code-barres, donc un ISSN tapé à la main s'y rapproche.
/// </summary>
[Fact]
public void La_forme_canonique_est_celle_du_code_barres()
{
var depuisLeCodeBarres = CodePeriodique.IssnDepuis("9772466671438");
Assert.Equal(depuisLeCodeBarres, FormatageIssn.Canonique("24666718"));
}
}
+28
View File
@@ -128,6 +128,34 @@ public class ServiceCouverturesTests : IDisposable
Assert.True(handler.AEteAppele);
}
/// <summary>
/// ⚠️ Le cas qu'on oublie : un numéro de revue porte lui aussi une couverture depuis le lot
/// O1. L'omettre du bornage n'aurait produit aucune erreur visible — seulement une image
/// qui s'affiche en ligne et jamais hors-ligne, la mise en cache étant refusée par le
/// relais. C'est exactement le défaut corrigé le 2026-08-20 pour les hébergeurs sans CORS.
/// </summary>
[Fact]
public async Task Une_couverture_de_NUMERO_de_revue_autorise_aussi_le_relais()
{
const string urlNumero = "https://editions-exemple.fr/medor-43.jpg";
var revue = new Revue { Titre = "Médor", Issn = "2466-6718" };
revue.RecalculerFormes();
var numero = new NumeroRevue { Revue = revue, Numero = "43", CoverUrl = urlNumero };
numero.RecalculerFormes();
_db.NumerosRevue.Add(numero);
await _db.SaveChangesAsync();
var handler = new HandlerQuiRefuse();
var service = new ServiceCouvertures(new HttpClient(handler), _db);
await service.ObtenirAsync(urlNumero, default);
Assert.True(handler.AEteAppele);
}
public void Dispose()
{
_db.Dispose();
@@ -269,6 +269,58 @@ public class ServiceRenormalisationTests : IDisposable
Assert.Equal("coeur", _db.Revues.Single().TitreNormalise);
}
/// <summary>
/// Les ISSN saisis avant le lot K2 prennent leur tiret au démarrage suivant, ce qui les rend
/// enfin rapprochables d'un code-barres et de <c>bib.issn</c>.
/// </summary>
[Fact]
public async Task Les_issn_existants_prennent_leur_tiret()
{
_db.Revues.Add(new Revue { Titre = "Médor", TitreNormalise = "medor", Issn = "24666718" });
await _db.SaveChangesAsync();
await ExecuterAsync();
Assert.Equal("2466-6718", _db.Revues.Single().Issn);
}
/// <summary>
/// ⚠️ L'unicité de l'ISSN est un index <b>partiel</b> en base : deux fiches que la
/// canonisation rendrait identiques feraient échouer l'écriture — <i>au démarrage</i>, donc
/// avant que l'application ne réponde. La seconde garde donc sa forme, et rien ne meurt.
/// </summary>
[Fact]
public async Task Deux_issn_qui_se_rejoignent_ne_tuent_pas_le_demarrage()
{
_db.Revues.Add(new Revue { Titre = "Médor", TitreNormalise = "medor", Issn = "2466-6718" });
_db.Revues.Add(new Revue { Titre = "Doublon", TitreNormalise = "doublon", Issn = "24666718" });
await _db.SaveChangesAsync();
await ExecuterAsync();
var issn = _db.Revues.OrderBy(r => r.Id).Select(r => r.Issn).ToList();
// Les deux lignes survivent : mal normalisée vaut infiniment mieux que supprimée.
Assert.Equal(["2466-6718", "24666718"], issn);
}
/// <summary>
/// Une revue <b>sans</b> ISSN n'entre pas dans le jeu des clés : sans quoi la deuxième
/// bloquerait la première, tous les <c>NULL</c> se confondant en une seule valeur.
/// </summary>
[Fact]
public async Task Les_revues_sans_issn_ne_se_bloquent_pas_entre_elles()
{
_db.Revues.Add(new Revue { Titre = "Une", TitreNormalise = "une" });
_db.Revues.Add(new Revue { Titre = "Deux", TitreNormalise = "deux" });
_db.Revues.Add(new Revue { Titre = "Trois", TitreNormalise = "trois", Issn = "24666718" });
await _db.SaveChangesAsync();
await ExecuterAsync();
Assert.Equal("2466-6718", _db.Revues.Single(r => r.Titre == "Trois").Issn);
}
public void Dispose()
{
_db.Dispose();
+136
View File
@@ -171,6 +171,142 @@ public class ServiceRevuesTests : IDisposable
Assert.NotNull(issnPris.Erreur);
}
/// <summary>
/// ⚠️ Le cas qui a motivé la canonisation (lot K2) : une revue saisie à la main, ISSN tapé
/// sans tiret, doit être <b>retrouvée</b> par le scan — qui produit toujours la forme à
/// tiret. Sans elle, le code-barres créait une seconde fiche à côté de la première.
/// </summary>
[Fact]
public async Task Un_issn_sans_tiret_designe_la_meme_revue_que_le_code_barres()
{
var aLaMain = await RevueAsync("Médor", issn: "24666718");
Assert.Equal("2466-6718", aLaMain.Issn);
var scanne = await RevueAsync("Médor (Bruxelles)", issn: "2466-6718");
Assert.Equal(aLaMain.Id, scanne.Id);
Assert.Single(await _revues.ListerAsync());
}
[Fact]
public async Task Un_numero_porte_sa_couverture_et_ses_articles_a_la_une()
{
var revue = await RevueAsync();
var apres = await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "43",
CoverUrl = "https://exemple.fr/medor-43.jpg",
Articles = ["Ukraine, deux ans après", "Le retour du train de nuit"],
});
var numero = Assert.Single(apres.Revue!.Numeros);
Assert.Equal("https://exemple.fr/medor-43.jpg", numero.CoverUrl);
Assert.Equal(["Ukraine, deux ans après", "Le retour du train de nuit"], numero.Articles);
}
/// <summary>
/// La une se recopie <b>après coup</b> : on note un numéro le jour où on le range, on en
/// recopie le sommaire plus tard. Sans modification, ces champs n'auraient existé qu'à la
/// création, c'est-à-dire jamais.
/// </summary>
[Fact]
public async Task Un_numero_apprend_sa_une_apres_coup()
{
var revue = await RevueAsync();
var cree = (await _revues.AjouterNumeroAsync(
revue.Id, new AjoutNumeroRevue { Numero = "43" })).Revue!;
var numeroId = cree.Numeros[0].Id;
var apres = await _revues.ModifierNumeroAsync(numeroId, new AjoutNumeroRevue
{
Numero = "43",
Articles = ["Le retour du train de nuit"],
});
Assert.Equal(["Le retour du train de nuit"], apres.Revue!.Numeros[0].Articles);
}
/// <summary>
/// ⚠️ Remplacement et non fusion : la ligne de saisie <b>est</b> la liste. Un titre effacé
/// du champ doit disparaître, comme pour les thèmes d'un livre.
/// </summary>
[Fact]
public async Task Reecrire_la_une_remplace_lancienne()
{
var revue = await RevueAsync();
var cree = (await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "43",
Articles = ["Premier", "Second"],
})).Revue!;
var apres = await _revues.ModifierNumeroAsync(
cree.Numeros[0].Id, new AjoutNumeroRevue { Numero = "43", Articles = ["Troisième"] });
Assert.Equal(["Troisième"], apres.Revue!.Numeros[0].Articles);
Assert.Single(await _db.ArticlesUne.ToListAsync());
}
[Fact]
public async Task Retirer_le_numero_emporte_sa_une()
{
var revue = await RevueAsync();
var cree = (await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "43",
Articles = ["Ukraine, deux ans après"],
})).Revue!;
Assert.True(await _revues.RetirerNumeroAsync(cree.Numeros[0].Id));
// Cascade : un article à la une n'existe que par la parution qui le porte.
Assert.Empty(await _db.ArticlesUne.ToListAsync());
}
/// <summary>
/// L'unicité <c>(NumeroRevueId, TitreNormalise)</c> vit en base : un doublon envoyé
/// directement à l'API ferait échouer l'écriture si le service ne l'écartait pas.
/// </summary>
[Fact]
public async Task Le_meme_article_ne_sannonce_pas_deux_fois()
{
var revue = await RevueAsync();
var apres = await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "43",
Articles = ["Ukraine, deux ans après", "ukraine, DEUX ans après", " "],
});
Assert.Equal(["Ukraine, deux ans après"], apres.Revue!.Numeros[0].Articles);
}
[Fact]
public async Task Deux_numeros_peuvent_titrer_pareil()
{
var revue = await RevueAsync();
await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "43",
Articles = ["Le dossier de l'année"],
});
var apres = await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "44",
Articles = ["Le dossier de l'année"],
});
// L'unicité est bornée au numéro : deux parutions peuvent parfaitement titrer pareil.
Assert.Null(apres.Erreur);
Assert.Equal(2, (await _db.ArticlesUne.ToListAsync()).Count);
}
public void Dispose()
{
_db.Dispose();