Sortir le statut de lecture et l'auteur de la table Livre

Deux changements de modele en une seule migration, plus la recherche qui
en depend.

Le statut de lecture devient personnel. Il etait une colonne de Livre,
donc partage par tout le foyer, alors que deux membres lisent le meme
exemplaire a des rythmes differents. Il vit desormais dans une table
(LivreId, Utilisateur, Statut) avec unicite sur le couple. L'absence de
ligne vaut « non commence » : sur une bibliotheque de foyer la plupart
des couples n'ont aucun statut, et les materialiser tous multiplierait
les lignes par le nombre de comptes pour n'exprimer qu'un vide. Rien
n'est donc ecrit a la creation d'un livre.

Piege rencontre : un Dictionary<int, Statut> renvoyait la valeur 0 de
l'enumeration — « À lire » — pour un livre sans ligne, rendant « non
commence » indiscernable d'un choix explicite. Le dictionnaire est
desormais typé Statut?.

L'auteur devient une table. Deux formes normalisees y cohabitent, et ce
n'est pas une redondance : NomNormalise garde l'ordre de saisie pour la
recherche en sous-chaine, CleRegroupement trie les mots et porte l'index
unique, donc l'invariant « un auteur, une fiche ». Les initiales
echappent a la cle et sont traitees en memoire, sur une table qui compte
au plus quelques centaines de lignes.

Un livre peut avoir plusieurs auteurs — le lookup ISBN en renvoie quatre
pour Introduction to Algorithms — d'ou la table de liaison, avec une
position qui conserve l'ordre de la couverture.

Les rapprochements ambigus ne sont jamais appliques seuls : l'API les
liste, l'utilisateur accepte ou refuse, et les refus sont memorises pour
que la suggestion ne revienne pas. Le couple refuse est range par
identifiant croissant, donc un refus vaut dans les deux sens.

Reprise des donnees existantes. L'ancien statut, commun, est rattache a
AjoutePar — seule personne que la base associe au livre. Les statuts des
livres sans AjoutePar sont perdus : les attribuer serait une invention.
Les trois valeurs sont reprises telles quelles, « À lire » compris, parce
que c'est ce que l'ancienne interface affichait. L'ancien champ auteur
devient une fiche par valeur distincte.

La migration ne peut pas tout faire : lower() de SQLite ne retire pas les
accents, donc « Émile Zola » et « emile zola » y restent deux fiches.
ServiceRenormalisation finit le travail en C# au demarrage, reunit ces
variantes, applique aussi la regle des initiales — sans quoi une base
heritee resterait eclatee la ou une saisie neuve aurait ete reunie
d'emblee — et garde le nom d'affichage le plus presentable. Il est
idempotent, et sert de filet si les regles de normalisation changent.

L'ordre de la migration compte : les colonnes condamnees sont recopiees
dans une table de transit avant d'etre supprimees, parce que supprimer
une colonne sous SQLite reconstruit la table.

Verifie sur une base a l'ancien schema contenant 9 livres, 2 prets et
trois variantes de Zola : prets intacts, statuts rattaches, les trois
Zola reunis sous « Émile Zola », « P.F. Hamilton » absorbe par
« Peter F. Hamilton », « Hamilton » seul laisse en suggestion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-18 02:22:46 +02:00
co-authored by Claude Opus 5
parent 4a8bc98f50
commit b594a4b2c1
32 changed files with 2856 additions and 164 deletions
+67
View File
@@ -7,6 +7,14 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
{ {
public DbSet<Livre> Livres => Set<Livre>(); public DbSet<Livre> Livres => Set<Livre>();
public DbSet<Auteur> Auteurs => Set<Auteur>();
public DbSet<LivreAuteur> LivreAuteurs => Set<LivreAuteur>();
public DbSet<StatutLecture> StatutsLecture => Set<StatutLecture>();
public DbSet<RapprochementRefuse> RapprochementsRefuses => Set<RapprochementRefuse>();
public DbSet<Pret> Prets => Set<Pret>(); public DbSet<Pret> Prets => Set<Pret>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -16,13 +24,72 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
modelBuilder.Entity<Livre>(livre => modelBuilder.Entity<Livre>(livre =>
{ {
livre.Property(l => l.Titre).IsRequired(); livre.Property(l => l.Titre).IsRequired();
livre.Property(l => l.TitreNormalise).IsRequired();
livre.HasIndex(l => l.Isbn); livre.HasIndex(l => l.Isbn);
// Le tri et la recherche passent par là : sans index, chaque frappe balaie la table.
livre.HasIndex(l => l.TitreNormalise);
livre.HasMany(l => l.Prets) livre.HasMany(l => l.Prets)
.WithOne(p => p.Livre) .WithOne(p => p.Livre)
.HasForeignKey(p => p.LivreId) .HasForeignKey(p => p.LivreId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity<Auteur>(auteur =>
{
auteur.Property(a => a.Nom).IsRequired();
auteur.Property(a => a.NomNormalise).IsRequired();
auteur.Property(a => a.CleRegroupement).IsRequired();
// Unicité de la clé de regroupement : « Zola, Émile » et « Émile Zola » produisent
// la même clé et ne peuvent donc pas coexister. C'est la garantie structurelle du
// regroupement — le code applicatif peut avoir un trou, la base non.
auteur.HasIndex(a => a.CleRegroupement).IsUnique();
auteur.HasIndex(a => a.NomNormalise);
});
modelBuilder.Entity<LivreAuteur>(lien =>
{
lien.HasKey(la => new { la.LivreId, la.AuteurId });
lien.HasOne(la => la.Livre)
.WithMany(l => l.Auteurs)
.HasForeignKey(la => la.LivreId)
.OnDelete(DeleteBehavior.Cascade);
// Restrict : supprimer un auteur encore rattaché à des livres les priverait de leur
// auteur en silence. On passe par une fusion, ou par la suppression des livres.
lien.HasOne(la => la.Auteur)
.WithMany(a => a.Livres)
.HasForeignKey(la => la.AuteurId)
.OnDelete(DeleteBehavior.Restrict);
lien.HasIndex(la => la.AuteurId);
});
modelBuilder.Entity<StatutLecture>(statut =>
{
statut.Property(s => s.Utilisateur).IsRequired();
statut.HasOne(s => s.Livre)
.WithMany(l => l.Statuts)
.HasForeignKey(s => s.LivreId)
.OnDelete(DeleteBehavior.Cascade);
// Une personne, un statut par livre. L'unicité est ce qui rend l'écriture idempotente :
// on lit la ligne, on la met à jour, et il ne peut jamais y en avoir deux à départager.
statut.HasIndex(s => new { s.LivreId, s.Utilisateur }).IsUnique();
statut.HasIndex(s => s.Utilisateur);
});
modelBuilder.Entity<RapprochementRefuse>(refus =>
{
// Le couple est rangé (petit identifiant d'abord) à l'écriture : l'unicité vaut donc
// dans les deux sens, et un refus ne peut pas être enregistré deux fois.
refus.HasIndex(r => new { r.AuteurAId, r.AuteurBId }).IsUnique();
});
modelBuilder.Entity<Pret>(pret => modelBuilder.Entity<Pret>(pret =>
{ {
pret.Property(p => p.Emprunteur).IsRequired(); pret.Property(p => p.Emprunteur).IsRequired();
@@ -0,0 +1,242 @@
// <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("20260818000335_StatutPersonnelEtTableAuteurs")]
partial class StatutPersonnelEtTableAuteurs
{
/// <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.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.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.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("LivreId");
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.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.LivreAuteur", b =>
{
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
.WithMany("Livres")
.HasForeignKey("AuteurId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Auteurs")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Auteur");
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Prets")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.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");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,278 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MaBibli.Api.Data.Migrations
{
/// <summary>
/// Sort le statut de lecture et l'auteur de la table <c>Livres</c>.
/// </summary>
/// <remarks>
/// <b>Ce que deviennent les données existantes.</b>
/// <list type="bullet">
/// <item>
/// <c>Livres.Statut</c> était commun à tout le foyer. Il est <b>rattaché à
/// <c>AjoutePar</c></b>, la seule personne que la base associe au livre. Les statuts des
/// livres sans <c>AjoutePar</c> (saisis sans identité) sont <b>perdus</b> : les attribuer à
/// quelqu'un serait une invention, et les garder pour tout le monde serait exactement le
/// défaut que cette migration corrige. Les trois valeurs sont reprises telles quelles, « À
/// lire » compris : c'est ce que l'ancienne interface affichait, et le taire changerait ce
/// que l'utilisateur voit.
/// </item>
/// <item>
/// <c>Livres.Auteur</c> devient une ligne de <c>Auteurs</c> par valeur distincte, plus un lien
/// en position 0. Le regroupement se fait ici sur une simple mise en minuscules — SQLite ne
/// sait pas retirer les accents. La normalisation complète, et la fusion des variantes qu'elle
/// révèle, sont faites en C# au démarrage suivant (voir <c>ServiceRenormalisation</c>).
/// </item>
/// </list>
/// L'ordre des opérations compte : tout est recopié <b>avant</b> que les anciennes colonnes
/// ne soient supprimées.
/// </remarks>
public partial class StatutPersonnelEtTableAuteurs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Mise à l'abri du contenu des deux colonnes condamnées. Supprimer une colonne sous
// SQLite reconstruit la table : on le fait pendant que Livres n'a pas encore
// d'enfants à préserver, et on recopie ensuite depuis cette table de transit.
migrationBuilder.Sql(
"""
CREATE TABLE _RepriseLivres AS
SELECT Id, Auteur, Statut FROM Livres;
""");
migrationBuilder.DropColumn(
name: "Auteur",
table: "Livres");
migrationBuilder.DropColumn(
name: "Statut",
table: "Livres");
migrationBuilder.AddColumn<string>(
name: "TitreNormalise",
table: "Livres",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.CreateTable(
name: "Auteurs",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Nom = table.Column<string>(type: "TEXT", nullable: false),
NomNormalise = table.Column<string>(type: "TEXT", nullable: false),
CleRegroupement = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Auteurs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RapprochementsRefuses",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
AuteurAId = table.Column<int>(type: "INTEGER", nullable: false),
AuteurBId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RapprochementsRefuses", x => x.Id);
});
migrationBuilder.CreateTable(
name: "StatutsLecture",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
Utilisateur = table.Column<string>(type: "TEXT", nullable: false),
Statut = table.Column<int>(type: "INTEGER", nullable: false),
DateMaj = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_StatutsLecture", x => x.Id);
table.ForeignKey(
name: "FK_StatutsLecture_Livres_LivreId",
column: x => x.LivreId,
principalTable: "Livres",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LivreAuteurs",
columns: table => new
{
LivreId = table.Column<int>(type: "INTEGER", nullable: false),
AuteurId = table.Column<int>(type: "INTEGER", nullable: false),
Position = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LivreAuteurs", x => new { x.LivreId, x.AuteurId });
table.ForeignKey(
name: "FK_LivreAuteurs_Auteurs_AuteurId",
column: x => x.AuteurId,
principalTable: "Auteurs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_LivreAuteurs_Livres_LivreId",
column: x => x.LivreId,
principalTable: "Livres",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Livres_TitreNormalise",
table: "Livres",
column: "TitreNormalise");
migrationBuilder.CreateIndex(
name: "IX_Auteurs_CleRegroupement",
table: "Auteurs",
column: "CleRegroupement",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Auteurs_NomNormalise",
table: "Auteurs",
column: "NomNormalise");
migrationBuilder.CreateIndex(
name: "IX_LivreAuteurs_AuteurId",
table: "LivreAuteurs",
column: "AuteurId");
migrationBuilder.CreateIndex(
name: "IX_RapprochementsRefuses_AuteurAId_AuteurBId",
table: "RapprochementsRefuses",
columns: new[] { "AuteurAId", "AuteurBId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_StatutsLecture_LivreId_Utilisateur",
table: "StatutsLecture",
columns: new[] { "LivreId", "Utilisateur" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_StatutsLecture_Utilisateur",
table: "StatutsLecture",
column: "Utilisateur");
// ── Reprise des données depuis la table de transit ──────────────────────────────
// Le titre normalisé n'est qu'approché ici (lower() de SQLite ne touche pas aux
// accents) ; le passage C# du démarrage le recalculera correctement.
migrationBuilder.Sql("UPDATE Livres SET TitreNormalise = lower(trim(Titre));");
// Une fiche auteur par valeur distincte, à la casse près. Le nom d'affichage retenu
// est le premier dans l'ordre alphabétique : arbitraire, mais déterministe, et
// l'utilisateur peut le corriger depuis n'importe quel livre.
migrationBuilder.Sql(
"""
INSERT INTO Auteurs (Nom, NomNormalise, CleRegroupement)
SELECT min(trim(Auteur)), lower(trim(Auteur)), lower(trim(Auteur))
FROM _RepriseLivres
WHERE Auteur IS NOT NULL AND trim(Auteur) <> ''
GROUP BY lower(trim(Auteur));
""");
migrationBuilder.Sql(
"""
INSERT INTO LivreAuteurs (LivreId, AuteurId, Position)
SELECT r.Id, a.Id, 0
FROM _RepriseLivres r
JOIN Auteurs a ON a.CleRegroupement = lower(trim(r.Auteur))
JOIN Livres l ON l.Id = r.Id
WHERE r.Auteur IS NOT NULL AND trim(r.Auteur) <> '';
""");
// Le statut commun devient celui de la personne qui a saisi le livre. Sans AjoutePar,
// il n'y a personne à qui l'attribuer : la ligne n'est pas créée.
migrationBuilder.Sql(
"""
INSERT INTO StatutsLecture (LivreId, Utilisateur, Statut, DateMaj)
SELECT l.Id, trim(l.AjoutePar), r.Statut, l.DateAjout
FROM Livres l
JOIN _RepriseLivres r ON r.Id = l.Id
WHERE l.AjoutePar IS NOT NULL AND trim(l.AjoutePar) <> '';
""");
migrationBuilder.Sql("DROP TABLE _RepriseLivres;");
}
/// <inheritdoc />
/// <summary>
/// Retour en arrière. <b>Une partie de l'information est perdue par nature</b> : l'ancien
/// modèle n'a qu'une case pour un auteur et une pour un statut. On y remet le premier
/// auteur et le statut de <c>AjoutePar</c> ; les co-auteurs et les statuts des autres
/// membres du foyer disparaissent.
/// </summary>
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Auteur",
table: "Livres",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Statut",
table: "Livres",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.Sql(
"""
UPDATE Livres SET Auteur = (
SELECT a.Nom FROM LivreAuteurs la
JOIN Auteurs a ON a.Id = la.AuteurId
WHERE la.LivreId = Livres.Id
ORDER BY la.Position LIMIT 1);
""");
migrationBuilder.Sql(
"""
UPDATE Livres SET Statut = coalesce((
SELECT s.Statut FROM StatutsLecture s
WHERE s.LivreId = Livres.Id AND s.Utilisateur = trim(Livres.AjoutePar)
LIMIT 1), 0);
""");
migrationBuilder.DropTable(
name: "LivreAuteurs");
migrationBuilder.DropTable(
name: "RapprochementsRefuses");
migrationBuilder.DropTable(
name: "StatutsLecture");
migrationBuilder.DropTable(
name: "Auteurs");
migrationBuilder.DropIndex(
name: "IX_Livres_TitreNormalise",
table: "Livres");
migrationBuilder.DropColumn(
name: "TitreNormalise",
table: "Livres");
}
}
}
@@ -17,6 +17,34 @@ namespace MaBibli.Api.Data.Migrations
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); 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.Livre", b => modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -26,9 +54,6 @@ namespace MaBibli.Api.Data.Migrations
b.Property<string>("AjoutePar") b.Property<string>("AjoutePar")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl") b.Property<string>("CoverUrl")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -44,20 +69,41 @@ namespace MaBibli.Api.Data.Migrations
b.Property<string>("Isbn") b.Property<string>("Isbn")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
b.Property<string>("Titre") b.Property<string>("Titre")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("TitreNormalise")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Isbn"); b.HasIndex("Isbn");
b.HasIndex("TitreNormalise");
b.ToTable("Livres"); 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.Pret", b => modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -84,6 +130,74 @@ namespace MaBibli.Api.Data.Migrations
b.ToTable("Prets"); 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.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.LivreAuteur", b =>
{
b.HasOne("MaBibli.Shared.Entites.Auteur", "Auteur")
.WithMany("Livres")
.HasForeignKey("AuteurId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
.WithMany("Auteurs")
.HasForeignKey("LivreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Auteur");
b.Navigation("Livre");
});
modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b => modelBuilder.Entity("MaBibli.Shared.Entites.Pret", b =>
{ {
b.HasOne("MaBibli.Shared.Entites.Livre", "Livre") b.HasOne("MaBibli.Shared.Entites.Livre", "Livre")
@@ -95,9 +209,29 @@ namespace MaBibli.Api.Data.Migrations
b.Navigation("Livre"); b.Navigation("Livre");
}); });
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 => modelBuilder.Entity("MaBibli.Shared.Entites.Livre", b =>
{ {
b.Navigation("Auteurs");
b.Navigation("Prets"); b.Navigation("Prets");
b.Navigation("Statuts");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
+205
View File
@@ -0,0 +1,205 @@
using MaBibli.Shared.Entites;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Data;
/// <summary>
/// Remet en cohérence les colonnes normalisées, et réunit les auteurs que cette normalisation
/// révèle identiques.
/// </summary>
/// <remarks>
/// <b>Pourquoi ce passage existe.</b> La migration a recopié les anciennes données avec les
/// moyens du bord : <c>lower()</c> de SQLite ne retire pas les accents, donc « Émile Zola » et
/// « emile zola » y sont restés deux auteurs distincts et les titres normalisés sont approchés.
/// Seul C# sait appliquer les vraies règles.
/// <para>
/// Le passage est <b>idempotent</b> et ne touche que ce qui diffère : il ne coûte rien au
/// démarrage suivant. Il sert aussi de filet si les règles de normalisation changent un jour —
/// le prochain démarrage rattrape la base sans migration à écrire.
/// </para>
/// </remarks>
public sealed class ServiceRenormalisation(MaBibliDbContext db, ILogger<ServiceRenormalisation> logger)
{
public async Task ExecuterAsync(CancellationToken ct = default)
{
var titres = await RenormaliserTitresAsync(ct);
var (auteursCorriges, auteursFusionnes) = await RenormaliserAuteursAsync(ct);
if (titres + auteursCorriges + auteursFusionnes > 0)
{
logger.LogInformation(
"Renormalisation : {Titres} titre(s), {Auteurs} auteur(s) corrigés, {Fusions} fusionné(s).",
titres, auteursCorriges, auteursFusionnes);
}
}
private async Task<int> RenormaliserTitresAsync(CancellationToken ct)
{
var livres = await db.Livres.ToListAsync(ct);
var corriges = 0;
foreach (var livre in livres)
{
var attendu = NormalisationTexte.Normaliser(livre.Titre);
if (livre.TitreNormalise == attendu)
{
continue;
}
livre.TitreNormalise = attendu;
corriges++;
}
if (corriges > 0)
{
await db.SaveChangesAsync(ct);
}
return corriges;
}
private async Task<(int Corriges, int Fusionnes)> RenormaliserAuteursAsync(CancellationToken ct)
{
var auteurs = await db.Auteurs.Include(a => a.Livres).OrderBy(a => a.Id).ToListAsync(ct);
var corriges = 0;
var fusionnes = 0;
// Premier tour : la clé de regroupement, qui couvre casse, accents et ordre des mots.
// Le premier auteur rencontré pour une clé donnée est celui qui reste ; les suivants
// sont absorbés. L'ordre par identifiant rend le résultat reproductible.
var parCle = new Dictionary<string, Auteur>(StringComparer.Ordinal);
var conserves = new List<Auteur>();
foreach (var auteur in auteurs)
{
var cle = RapprochementAuteurs.Cle(auteur.Nom);
var nomNormalise = NormalisationTexte.Normaliser(auteur.Nom);
if (parCle.TryGetValue(cle, out var conserve))
{
// Deux fiches que la migration n'a pas pu rapprocher (accents) et que les vraies
// règles réunissent. C'est une variante sûre : la fusion est automatique.
Absorber(conserve, auteur);
fusionnes++;
continue;
}
parCle[cle] = auteur;
conserves.Add(auteur);
if (auteur.CleRegroupement == cle && auteur.NomNormalise == nomNormalise)
{
continue;
}
auteur.CleRegroupement = cle;
auteur.NomNormalise = nomNormalise;
corriges++;
}
// Second tour : les initiales abrégées, que la clé ne sait pas capturer
// (« P.F. Hamilton » et « Peter F. Hamilton »). Sans ce tour, une base héritée
// resterait éclatée là où une saisie neuve aurait été réunie d'emblée — deux
// comportements pour une même règle.
foreach (var auteur in conserves.ToList())
{
if (db.Entry(auteur).State == EntityState.Deleted)
{
continue;
}
var jumeau = conserves.FirstOrDefault(autre =>
!ReferenceEquals(autre, auteur)
&& db.Entry(autre).State != EntityState.Deleted
&& RapprochementAuteurs.SontLeMemeAuteur(auteur.Nom, autre.Nom));
if (jumeau is null)
{
continue;
}
// Le nom le plus complet l'emporte : « Peter F. Hamilton » plutôt que « P.F. ».
var (conserve, absorbe) =
NomsDeveloppes(auteur.Nom) >= NomsDeveloppes(jumeau.Nom)
? (auteur, jumeau)
: (jumeau, auteur);
Absorber(conserve, absorbe);
conserves.Remove(absorbe);
fusionnes++;
}
if (corriges > 0 || fusionnes > 0)
{
await db.SaveChangesAsync(ct);
}
return (corriges, fusionnes);
}
/// <summary>Reporte les livres de <paramref name="absorbe"/> sur <paramref name="conserve"/>.</summary>
private void Absorber(Auteur conserve, Auteur absorbe)
{
// La fiche conservée est celle au plus petit identifiant, or cet ordre est un hasard de
// la migration. On rattrape sur le nom d'affichage : sans cet arbitrage, la bibliothèque
// perdrait ses accents au profit des saisies bâclées.
if (QualiteAffichage(absorbe.Nom) > QualiteAffichage(conserve.Nom))
{
conserve.Nom = absorbe.Nom;
conserve.NomNormalise = NormalisationTexte.Normaliser(absorbe.Nom);
conserve.CleRegroupement = RapprochementAuteurs.Cle(absorbe.Nom);
}
var dejaLies = conserve.Livres.Select(l => l.LivreId).ToHashSet();
foreach (var lien in absorbe.Livres.ToList())
{
db.LivreAuteurs.Remove(lien);
// Un livre déjà signé par la fiche conservée ne doit pas recevoir un second lien :
// la clé primaire est (LivreId, AuteurId).
if (dejaLies.Add(lien.LivreId))
{
db.LivreAuteurs.Add(new LivreAuteur
{
LivreId = lien.LivreId,
AuteurId = conserve.Id,
Position = lien.Position,
});
}
}
db.Auteurs.Remove(absorbe);
}
/// <summary>
/// Note grossière d'un nom en tant que <b>nom d'affichage</b>, pour départager des variantes
/// équivalentes.
/// </summary>
/// <remarks>
/// Trois signaux, dans cet ordre d'importance : la forme inversée « ZOLA, Émile » est une
/// convention de catalogue, pas quelque chose qu'on veut lire dans une liste ; les capitales
/// intégrales sont une saisie négligée ; les accents, eux, sont de l'information qu'on ne
/// veut pas perdre. Le résultat vise « Émile Zola ».
/// </remarks>
private static int QualiteAffichage(string nom)
{
var note = nom.Count(c => char.IsLetter(c) && c > 127) * 2;
if (nom.Contains(','))
{
note -= 3;
}
note -= nom
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Count(mot => mot.Length > 1 && mot.Where(char.IsLetter).All(char.IsUpper));
return note;
}
/// <summary>Nombre de mots qui ne sont pas de simples initiales.</summary>
private static int NomsDeveloppes(string nom) =>
NormalisationTexte.Mots(nom).Count(mot => mot.Length > 1);
}
+89 -9
View File
@@ -15,6 +15,11 @@ public static class LivresEndpoints
/// <b>Aucune lecture ne filtre sur <c>AjoutePar</c></b> : la bibliothèque est commune. /// <b>Aucune lecture ne filtre sur <c>AjoutePar</c></b> : la bibliothèque est commune.
/// Ce champ n'est même pas acceptable en paramètre de requête, précisément pour qu'on ne /// Ce champ n'est même pas acceptable en paramètre de requête, précisément pour qu'on ne
/// puisse pas s'en servir comme d'un cloisonnement par inadvertance. /// puisse pas s'en servir comme d'un cloisonnement par inadvertance.
/// <para>
/// Le statut de lecture, lui, est <b>personnel</b> : il est toujours celui de l'appelant,
/// déduit des en-têtes SSOwat. Aucun point d'entrée n'accepte un nom d'utilisateur — on ne
/// consulte ni ne modifie la lecture de quelqu'un d'autre.
/// </para>
/// </remarks> /// </remarks>
public static IEndpointRouteBuilder MapLivresEndpoints(this IEndpointRouteBuilder routes) public static IEndpointRouteBuilder MapLivresEndpoints(this IEndpointRouteBuilder routes)
{ {
@@ -24,7 +29,9 @@ public static class LivresEndpoints
string? recherche, string? recherche,
Format? format, Format? format,
Statut? statut, Statut? statut,
int? auteurId,
IServiceCatalogue service, IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) => CancellationToken ct) =>
{ {
var criteres = new CritereLivres var criteres = new CritereLivres
@@ -32,20 +39,28 @@ public static class LivresEndpoints
Recherche = recherche, Recherche = recherche,
Format = format, Format = format,
Statut = statut, Statut = statut,
AuteurId = auteurId,
}; };
return Results.Ok(await service.ListerAsync(criteres, ct)); var utilisateur = utilisateurs.Obtenir().Identifiant;
return Results.Ok(await service.ListerAsync(criteres, utilisateur, ct));
}) })
.WithName("ListerLivres") .WithName("ListerLivres")
.WithSummary("Liste le catalogue, filtrable par format et statut, cherchable sur titre et auteur.") .WithSummary("Liste le catalogue, filtrable par format, statut et auteur ; cherchable sur titre et auteurs.")
.WithDescription( .WithDescription(
"Catalogue unique : physiques et numériques cohabitent, distingués par leur format. " "Catalogue unique : physiques et numériques cohabitent, distingués par leur format. "
+ "La bibliothèque est commune — la liste n'est jamais restreinte à l'utilisateur courant.") + "La bibliothèque est commune — la liste n'est jamais restreinte à l'utilisateur courant. "
+ "Le filtre « statut » porte en revanche sur le statut de lecture de l'appelant, "
+ "qui lui est propre. La recherche ignore la casse et les accents.")
.Produces<IReadOnlyList<LivreDto>>(); .Produces<IReadOnlyList<LivreDto>>();
groupe.MapGet("/{id:int}", async (int id, IServiceCatalogue service, CancellationToken ct) => groupe.MapGet("/{id:int}", async (
int id,
IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{ {
var livre = await service.ObtenirAsync(id, ct); var livre = await service.ObtenirAsync(id, utilisateurs.Obtenir().Identifiant, ct);
return livre is null ? Results.NotFound() : Results.Ok(livre); return livre is null ? Results.NotFound() : Results.Ok(livre);
}) })
.WithName("ObtenirLivre") .WithName("ObtenirLivre")
@@ -75,9 +90,11 @@ public static class LivresEndpoints
int id, int id,
EnregistrementLivre saisie, EnregistrementLivre saisie,
IServiceCatalogue service, IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) => CancellationToken ct) =>
{ {
var resultat = await service.ModifierAsync(id, saisie, ct); var resultat = await service.ModifierAsync(
id, saisie, utilisateurs.Obtenir().Identifiant, ct);
if (resultat.EstIntrouvable) if (resultat.EstIntrouvable)
{ {
@@ -89,7 +106,7 @@ public static class LivresEndpoints
: Results.Ok(resultat.Livre); : Results.Ok(resultat.Livre);
}) })
.WithName("ModifierLivre") .WithName("ModifierLivre")
.WithSummary("Modifie un livre. « AjoutePar » et « DateAjout » restent inchangés.") .WithSummary("Modifie un livre. « AjoutePar » et « DateAjout » restent inchangés ; le statut posé est celui de l'appelant.")
.Produces<LivreDto>() .Produces<LivreDto>()
.Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
@@ -98,13 +115,17 @@ public static class LivresEndpoints
int id, int id,
ChangementStatut changement, ChangementStatut changement,
IServiceCatalogue service, IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) => CancellationToken ct) =>
{ {
var livre = await service.ChangerStatutAsync(id, changement.Statut, ct); var livre = await service.ChangerStatutAsync(
id, changement.Statut, utilisateurs.Obtenir().Identifiant, ct);
return livre is null ? Results.NotFound() : Results.Ok(livre); return livre is null ? Results.NotFound() : Results.Ok(livre);
}) })
.WithName("ChangerStatutLivre") .WithName("ChangerStatutLivre")
.WithSummary("Change le seul statut de lecture — l'action la plus fréquente.") .WithSummary("Change le seul statut de lecture de l'appelant — l'action la plus fréquente.")
.WithDescription("Un statut nul remet le livre à « non commencé » : la ligne est supprimée.")
.Produces<LivreDto>() .Produces<LivreDto>()
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
@@ -120,6 +141,65 @@ public static class LivresEndpoints
return routes; return routes;
} }
/// <summary>
/// Consultation des auteurs et arbitrage des rapprochements ambigus.
/// </summary>
/// <remarks>
/// Les variantes sûres sont réunies automatiquement à l'écriture d'un livre et n'atteignent
/// jamais ces points d'entrée. Ce qui arrive ici est ce que la machine <b>refuse</b> de
/// trancher : un patronyme seul face à un nom complet, où seul l'utilisateur sait s'il
/// s'agit d'une même personne.
/// </remarks>
public static IEndpointRouteBuilder MapAuteursEndpoints(this IEndpointRouteBuilder routes)
{
var groupe = routes.MapGroup("/api/auteurs").WithTags("Auteurs");
groupe.MapGet("/", async (IServiceAuteurs service, CancellationToken ct) =>
Results.Ok(await service.ListerAsync(ct)))
.WithName("ListerAuteurs")
.WithSummary("Tous les auteurs du catalogue, avec leur nombre de livres.")
.Produces<IReadOnlyList<AuteurDto>>();
groupe.MapGet("/{id:int}", async (int id, IServiceAuteurs service, CancellationToken ct) =>
{
var auteur = await service.ObtenirAsync(id, ct);
return auteur is null ? Results.NotFound() : Results.Ok(auteur);
})
.WithName("ObtenirAuteur")
.Produces<AuteurDto>()
.Produces(StatusCodes.Status404NotFound);
groupe.MapGet("/rapprochements", async (IServiceAuteurs service, CancellationToken ct) =>
Results.Ok(await service.SuggestionsAsync(ct)))
.WithName("ListerRapprochements")
.WithSummary("Rapprochements ambigus proposés, refus déjà écartés.")
.Produces<IReadOnlyList<SuggestionRapprochement>>();
groupe.MapPost("/rapprochements/accepter", async (
DecisionRapprochement decision, IServiceAuteurs service, CancellationToken ct) =>
{
var fusionne = await service.FusionnerAsync(decision.SourceId, decision.CibleId, ct);
return fusionne ? Results.NoContent() : Results.NotFound();
})
.WithName("AccepterRapprochement")
.WithSummary("Fusionne les deux auteurs : « source » disparaît au profit de « cible ».")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
groupe.MapPost("/rapprochements/refuser", async (
DecisionRapprochement decision, IServiceAuteurs service, CancellationToken ct) =>
{
var refuse = await service.RefuserAsync(decision.SourceId, decision.CibleId, ct);
return refuse ? Results.NoContent() : Results.NotFound();
})
.WithName("RefuserRapprochement")
.WithSummary("Mémorise que ces deux auteurs sont bien deux personnes : la suggestion ne reviendra plus.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
return routes;
}
/// <summary>Expose l'identité de la personne connectée, pour affichage côté client.</summary> /// <summary>Expose l'identité de la personne connectée, pour affichage côté client.</summary>
public static IEndpointRouteBuilder MapIdentiteEndpoints(this IEndpointRouteBuilder routes) public static IEndpointRouteBuilder MapIdentiteEndpoints(this IEndpointRouteBuilder routes)
{ {
+11 -1
View File
@@ -12,6 +12,7 @@ builder.Services.AddOpenApi();
builder.Services.AddDbContext<MaBibliDbContext>(options => builder.Services.AddDbContext<MaBibliDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("MaBibli"))); options.UseSqlite(builder.Configuration.GetConnectionString("MaBibli")));
builder.Services.AddScoped<IServiceAuteurs, ServiceAuteurs>();
builder.Services.AddScoped<IServiceCatalogue, ServiceCatalogue>(); builder.Services.AddScoped<IServiceCatalogue, ServiceCatalogue>();
// 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é
@@ -45,7 +46,15 @@ var app = builder.Build();
// Une seule base SQLite, migrée au démarrage : pas d'étape manuelle à l'installation YunoHost. // Une seule base SQLite, migrée au démarrage : pas d'étape manuelle à l'installation YunoHost.
using (var portee = app.Services.CreateScope()) using (var portee = app.Services.CreateScope())
{ {
portee.ServiceProvider.GetRequiredService<MaBibliDbContext>().Database.Migrate(); var db = portee.ServiceProvider.GetRequiredService<MaBibliDbContext>();
db.Database.Migrate();
// Les colonnes normalisées (sans accents) ne peuvent pas être calculées en SQL : la
// migration ne les remplit qu'approximativement, ce passage les remet d'aplomb et réunit
// les auteurs que la normalisation révèle identiques. Idempotent, donc sans coût ensuite.
await new ServiceRenormalisation(
db, portee.ServiceProvider.GetRequiredService<ILogger<ServiceRenormalisation>>())
.ExecuterAsync();
} }
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -60,6 +69,7 @@ app.UseStaticFiles();
app.MapIsbnEndpoints(); app.MapIsbnEndpoints();
app.MapLivresEndpoints(); app.MapLivresEndpoints();
app.MapAuteursEndpoints();
app.MapIdentiteEndpoints(); app.MapIdentiteEndpoints();
app.MapFallbackToFile("index.html"); app.MapFallbackToFile("index.html");
@@ -0,0 +1,291 @@
using MaBibli.Api.Data;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Catalogue;
public interface IServiceAuteurs
{
/// <summary>
/// Convertit des noms saisis en fiches auteur, en réutilisant l'existant quand c'est sûr.
/// Les nouvelles fiches sont ajoutées au contexte mais <b>pas</b> enregistrées.
/// </summary>
Task<IReadOnlyList<Auteur>> ResoudreAsync(IEnumerable<string> noms, CancellationToken ct = default);
Task<IReadOnlyList<AuteurDto>> ListerAsync(CancellationToken ct = default);
Task<AuteurDto?> ObtenirAsync(int id, CancellationToken ct = default);
/// <summary>Rapprochements ambigus à soumettre à l'utilisateur, refus déjà écartés.</summary>
Task<IReadOnlyList<SuggestionRapprochement>> SuggestionsAsync(CancellationToken ct = default);
/// <summary>Fusionne <c>source</c> dans <c>cible</c>. <c>source</c> disparaît.</summary>
Task<bool> FusionnerAsync(int sourceId, int cibleId, CancellationToken ct = default);
/// <summary>Mémorise que ces deux auteurs sont bien deux personnes différentes.</summary>
Task<bool> RefuserAsync(int sourceId, int cibleId, CancellationToken ct = default);
/// <summary>Supprime les auteurs qui ne sont plus rattachés à aucun livre.</summary>
Task SupprimerOrphelinsAsync(CancellationToken ct = default);
}
/// <summary>
/// Gestion de la table des auteurs : résolution des noms, regroupement et fusions.
/// </summary>
/// <remarks>
/// <b>Tout se joue sur une table volontairement petite.</b> Une bibliothèque de foyer compte
/// quelques centaines d'auteurs : les comparaisons mot à mot (initiales, ordre inversé) sont
/// faites en mémoire, parce qu'elles ne s'expriment pas en SQL. Le premier essai — la clé de
/// regroupement — reste, lui, un simple index en base et couvre l'écrasante majorité des cas.
/// </remarks>
public sealed class ServiceAuteurs(MaBibliDbContext db) : IServiceAuteurs
{
public async Task<IReadOnlyList<Auteur>> ResoudreAsync(
IEnumerable<string> noms, CancellationToken ct = default)
{
var resolus = new List<Auteur>();
foreach (var brut in noms)
{
var nom = brut?.Trim();
if (string.IsNullOrEmpty(nom))
{
continue;
}
var cle = RapprochementAuteurs.Cle(nom);
if (cle.Length == 0)
{
continue; // Une saisie sans la moindre lettre ne désigne pas un auteur.
}
// Déjà résolu dans ce même enregistrement : « Zola » saisi deux fois ne doit pas
// produire deux liens vers le même auteur (la clé primaire du lien s'y opposerait).
if (resolus.Any(a => a.CleRegroupement == cle))
{
continue;
}
var auteur = await TrouverAsync(nom, cle, ct);
if (auteur is null)
{
auteur = new Auteur { Nom = nom };
auteur.RecalculerFormes();
db.Auteurs.Add(auteur);
}
resolus.Add(auteur);
}
return resolus;
}
/// <summary>
/// Cherche l'auteur existant qui correspond <b>sûrement</b> à ce nom, ou <c>null</c>.
/// </summary>
private async Task<Auteur?> TrouverAsync(string nom, string cle, CancellationToken ct)
{
// 1. Clé identique : même mots normalisés, ordre indifférent. Résolu par l'index unique.
// Le Local est consulté aussi, sinon deux auteurs identiques créés dans la même
// requête violeraient l'unicité au SaveChanges.
var parCle = db.Auteurs.Local.FirstOrDefault(a => a.CleRegroupement == cle)
?? await db.Auteurs.FirstOrDefaultAsync(a => a.CleRegroupement == cle, ct);
if (parCle is not null)
{
return parCle;
}
// 2. Variantes que la clé ne capture pas : les initiales abrégées. Il faut aligner les
// mots un à un, ce qui n'a pas d'équivalent SQL — d'où le balayage en mémoire.
var candidats = await db.Auteurs.ToListAsync(ct);
candidats.AddRange(db.Auteurs.Local.Where(a => a.Id == 0));
var surs = candidats
.Where(a => RapprochementAuteurs.SontLeMemeAuteur(nom, a.Nom))
.OrderBy(a => a.Id)
.ToList();
// Plusieurs correspondances sûres signifient que la table contient déjà des doublons
// (ils se seraient rapprochés entre eux). On prend la plus ancienne plutôt que d'en
// créer une troisième : l'utilisateur les réunira depuis l'écran des auteurs.
return surs.FirstOrDefault();
}
public async Task<IReadOnlyList<AuteurDto>> ListerAsync(CancellationToken ct = default) =>
await db.Auteurs
.AsNoTracking()
.OrderBy(a => a.NomNormalise)
.Select(a => new AuteurDto
{
Id = a.Id,
Nom = a.Nom,
NombreDeLivres = a.Livres.Count,
})
.ToListAsync(ct);
public async Task<AuteurDto?> ObtenirAsync(int id, CancellationToken ct = default) =>
await db.Auteurs
.AsNoTracking()
.Where(a => a.Id == id)
.Select(a => new AuteurDto
{
Id = a.Id,
Nom = a.Nom,
NombreDeLivres = a.Livres.Count,
})
.FirstOrDefaultAsync(ct);
public async Task<IReadOnlyList<SuggestionRapprochement>> SuggestionsAsync(
CancellationToken ct = default)
{
var auteurs = await db.Auteurs
.AsNoTracking()
.Select(a => new { a.Id, a.Nom, Livres = a.Livres.Count })
.OrderBy(a => a.Id)
.ToListAsync(ct);
var refuses = await db.RapprochementsRefuses
.AsNoTracking()
.Select(r => new { r.AuteurAId, r.AuteurBId })
.ToListAsync(ct);
var ecartes = refuses.Select(r => (r.AuteurAId, r.AuteurBId)).ToHashSet();
var suggestions = new List<SuggestionRapprochement>();
// Comparaison de toutes les paires : quadratique, mais sur quelques centaines d'auteurs
// c'est instantané, et le résultat n'est calculé qu'à l'ouverture de l'écran des auteurs.
for (var i = 0; i < auteurs.Count; i++)
{
for (var j = i + 1; j < auteurs.Count; j++)
{
var a = auteurs[i];
var b = auteurs[j];
if (RapprochementAuteurs.Comparer(a.Nom, b.Nom) != NiveauRapprochement.Suggere)
{
continue;
}
if (ecartes.Contains(Ordonner(a.Id, b.Id)))
{
continue; // L'utilisateur a déjà dit non : ne pas le lui redemander.
}
// La fusion fait disparaître la source : c'est le nom le moins complet qui
// s'efface, pour ne pas perdre « Peter F. Hamilton » au profit de « Hamilton ».
var (source, cible) =
NormalisationTexte.Mots(a.Nom).Length <= NormalisationTexte.Mots(b.Nom).Length
? (a, b)
: (b, a);
suggestions.Add(new SuggestionRapprochement
{
Source = new AuteurDto { Id = source.Id, Nom = source.Nom, NombreDeLivres = source.Livres },
Cible = new AuteurDto { Id = cible.Id, Nom = cible.Nom, NombreDeLivres = cible.Livres },
});
}
}
return suggestions;
}
public async Task<bool> FusionnerAsync(int sourceId, int cibleId, CancellationToken ct = default)
{
if (sourceId == cibleId)
{
return false;
}
var source = await db.Auteurs.Include(a => a.Livres).FirstOrDefaultAsync(a => a.Id == sourceId, ct);
var cible = await db.Auteurs.Include(a => a.Livres).FirstOrDefaultAsync(a => a.Id == cibleId, ct);
if (source is null || cible is null)
{
return false;
}
var dejaLies = cible.Livres.Select(l => l.LivreId).ToHashSet();
foreach (var lien in source.Livres.ToList())
{
// Un livre co-signé par les deux fiches ne doit pas donner deux liens identiques :
// la clé primaire du lien est (LivreId, AuteurId).
if (!dejaLies.Add(lien.LivreId))
{
db.LivreAuteurs.Remove(lien);
continue;
}
db.LivreAuteurs.Remove(lien);
db.LivreAuteurs.Add(new LivreAuteur
{
LivreId = lien.LivreId,
AuteurId = cible.Id,
Position = lien.Position,
});
}
// Les refus qui portaient sur la fiche absorbée n'ont plus d'objet.
var refusObsoletes = await db.RapprochementsRefuses
.Where(r => r.AuteurAId == sourceId || r.AuteurBId == sourceId)
.ToListAsync(ct);
db.RapprochementsRefuses.RemoveRange(refusObsoletes);
db.Auteurs.Remove(source);
await db.SaveChangesAsync(ct);
return true;
}
public async Task<bool> RefuserAsync(int sourceId, int cibleId, CancellationToken ct = default)
{
if (sourceId == cibleId)
{
return false;
}
var (a, b) = Ordonner(sourceId, cibleId);
var existants = await db.Auteurs.CountAsync(x => x.Id == a || x.Id == b, ct);
if (existants != 2)
{
return false;
}
var deja = await db.RapprochementsRefuses.AnyAsync(r => r.AuteurAId == a && r.AuteurBId == b, ct);
if (!deja)
{
db.RapprochementsRefuses.Add(new RapprochementRefuse { AuteurAId = a, AuteurBId = b });
await db.SaveChangesAsync(ct);
}
return true;
}
public async Task SupprimerOrphelinsAsync(CancellationToken ct = default)
{
// Après une édition ou une suppression de livre, une fiche auteur peut n'avoir plus aucun
// livre. La garder polluerait l'écran des auteurs et les suggestions de rapprochement.
var orphelins = await db.Auteurs.Where(a => a.Livres.Count == 0).ToListAsync(ct);
if (orphelins.Count == 0)
{
return;
}
var ids = orphelins.Select(a => a.Id).ToList();
var refus = await db.RapprochementsRefuses
.Where(r => ids.Contains(r.AuteurAId) || ids.Contains(r.AuteurBId))
.ToListAsync(ct);
db.RapprochementsRefuses.RemoveRange(refus);
db.Auteurs.RemoveRange(orphelins);
await db.SaveChangesAsync(ct);
}
/// <summary>Couple rangé, pour qu'un refus vaille dans les deux sens.</summary>
private static (int, int) Ordonner(int x, int y) => x < y ? (x, y) : (y, x);
}
@@ -22,15 +22,19 @@ public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur)
public interface IServiceCatalogue public interface IServiceCatalogue
{ {
Task<IReadOnlyList<LivreDto>> ListerAsync(CritereLivres criteres, CancellationToken ct = default); Task<IReadOnlyList<LivreDto>> ListerAsync(
CritereLivres criteres, string? utilisateur, CancellationToken ct = default);
Task<LivreDto?> ObtenirAsync(int id, CancellationToken ct = default); Task<LivreDto?> ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default);
Task<ResultatEcriture> CreerAsync(EnregistrementLivre saisie, string? ajoutePar, CancellationToken ct = default); Task<ResultatEcriture> CreerAsync(
EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
Task<ResultatEcriture> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default); Task<ResultatEcriture> ModifierAsync(
int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
Task<LivreDto?> ChangerStatutAsync(int id, Statut statut, CancellationToken ct = default); Task<LivreDto?> ChangerStatutAsync(
int id, Statut? statut, string? utilisateur, CancellationToken ct = default);
Task<bool> SupprimerAsync(int id, CancellationToken ct = default); Task<bool> SupprimerAsync(int id, CancellationToken ct = default);
} }
@@ -39,27 +43,48 @@ public interface IServiceCatalogue
/// CRUD du catalogue de livres — physiques et numériques confondus, distingués par /// CRUD du catalogue de livres — physiques et numériques confondus, distingués par
/// <see cref="Format"/>. Il n'y a qu'un seul catalogue. /// <see cref="Format"/>. Il n'y a qu'un seul catalogue.
/// </summary> /// </summary>
public sealed class ServiceCatalogue(MaBibliDbContext db) : IServiceCatalogue /// <remarks>
/// <b>Le paramètre <c>utilisateur</c> ne restreint jamais la liste des livres.</b> Il sert
/// uniquement à savoir de quel statut de lecture on parle : la bibliothèque reste commune, seule
/// la lecture est personnelle.
/// </remarks>
public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteurs) : IServiceCatalogue
{ {
public async Task<IReadOnlyList<LivreDto>> ListerAsync( public async Task<IReadOnlyList<LivreDto>> ListerAsync(
CritereLivres criteres, CancellationToken ct = default) CritereLivres criteres, string? utilisateur, CancellationToken ct = default)
{ {
// AsNoTracking : lecture pure. Et surtout : AUCUN filtre sur AjoutePar, // AsNoTracking : lecture pure. Et surtout : AUCUN filtre sur AjoutePar,
// la bibliothèque est commune à tout le foyer (CLAUDE.md). // la bibliothèque est commune à tout le foyer (CLAUDE.md).
var requete = FiltreLivres.Appliquer(db.Livres.AsNoTracking(), criteres); var requete = FiltreLivres.Appliquer(
db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur),
criteres,
utilisateur);
var livres = await requete.ToListAsync(ct); var livres = await requete.ToListAsync(ct);
return livres.Select(Projeter).ToList();
var statuts = await StatutsAsync(livres.Select(l => l.Id).ToList(), utilisateur, ct);
return livres.Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id))).ToList();
} }
public async Task<LivreDto?> ObtenirAsync(int id, CancellationToken ct = default) public async Task<LivreDto?> ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default)
{ {
var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id, ct); var livre = await db.Livres
return livre is null ? null : Projeter(livre); .AsNoTracking()
.Include(l => l.Auteurs).ThenInclude(la => la.Auteur)
.FirstOrDefaultAsync(l => l.Id == id, ct);
if (livre is null)
{
return null;
}
var statuts = await StatutsAsync([id], utilisateur, ct);
return Projeter(livre, statuts.GetValueOrDefault(id));
} }
public async Task<ResultatEcriture> CreerAsync( public async Task<ResultatEcriture> CreerAsync(
EnregistrementLivre saisie, string? ajoutePar, CancellationToken ct = default) EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default)
{ {
if (Valider(saisie, out var isbn, out var erreur) is false) if (Valider(saisie, out var isbn, out var erreur) is false)
{ {
@@ -69,28 +94,33 @@ public sealed class ServiceCatalogue(MaBibliDbContext db) : IServiceCatalogue
var livre = new Livre var livre = new Livre
{ {
Isbn = isbn, Isbn = isbn,
Titre = saisie.Titre.Trim(), Titre = saisie.Titre,
Auteur = Vide(saisie.Auteur),
Editeur = Vide(saisie.Editeur), Editeur = Vide(saisie.Editeur),
Format = saisie.Format, Format = saisie.Format,
Statut = saisie.Statut,
CoverUrl = Vide(saisie.CoverUrl), CoverUrl = Vide(saisie.CoverUrl),
DateAjout = DateTime.UtcNow, DateAjout = DateTime.UtcNow,
// Renseigné par le serveur à partir de l'utilisateur authentifié, jamais par le client. // Renseigné par le serveur à partir de l'utilisateur authentifié, jamais par le client.
AjoutePar = ajoutePar, AjoutePar = utilisateur,
}; };
livre.RecalculerFormes();
db.Livres.Add(livre); db.Livres.Add(livre);
await RattacherAuteursAsync(livre, saisie.Auteurs, ct);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return ResultatEcriture.Ok(Projeter(livre)); // Le statut n'est écrit que si l'utilisateur en a explicitement posé un : un livre sans
// ligne est « non commencé », et c'est le cas de départ le plus fréquent.
await AppliquerStatutAsync(livre.Id, saisie.Statut, utilisateur, ct);
return ResultatEcriture.Ok((await ObtenirAsync(livre.Id, utilisateur, ct))!);
} }
public async Task<ResultatEcriture> ModifierAsync( public async Task<ResultatEcriture> ModifierAsync(
int id, EnregistrementLivre saisie, CancellationToken ct = default) int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default)
{ {
var livre = await db.Livres.FirstOrDefaultAsync(l => l.Id == id, ct); var livre = await db.Livres.Include(l => l.Auteurs).FirstOrDefaultAsync(l => l.Id == id, ct);
if (livre is null) if (livre is null)
{ {
return ResultatEcriture.Introuvable; return ResultatEcriture.Introuvable;
@@ -102,46 +132,140 @@ public sealed class ServiceCatalogue(MaBibliDbContext db) : IServiceCatalogue
} }
livre.Isbn = isbn; livre.Isbn = isbn;
livre.Titre = saisie.Titre.Trim(); livre.Titre = saisie.Titre;
livre.Auteur = Vide(saisie.Auteur);
livre.Editeur = Vide(saisie.Editeur); livre.Editeur = Vide(saisie.Editeur);
livre.Format = saisie.Format; livre.Format = saisie.Format;
livre.Statut = saisie.Statut;
livre.CoverUrl = Vide(saisie.CoverUrl); livre.CoverUrl = Vide(saisie.CoverUrl);
livre.RecalculerFormes();
// DateAjout et AjoutePar ne sont jamais réécrits : ce sont des traces de la saisie // DateAjout et AjoutePar ne sont jamais réécrits : ce sont des traces de la saisie
// d'origine, pas des champs éditables. // d'origine, pas des champs éditables.
await RattacherAuteursAsync(livre, saisie.Auteurs, ct);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return ResultatEcriture.Ok(Projeter(livre));
// Le statut modifié est celui de la personne qui édite, pas de celle qui a saisi le livre.
await AppliquerStatutAsync(livre.Id, saisie.Statut, utilisateur, ct);
await auteurs.SupprimerOrphelinsAsync(ct);
return ResultatEcriture.Ok((await ObtenirAsync(livre.Id, utilisateur, ct))!);
} }
public async Task<LivreDto?> ChangerStatutAsync(int id, Statut statut, CancellationToken ct = default) public async Task<LivreDto?> ChangerStatutAsync(
int id, Statut? statut, string? utilisateur, CancellationToken ct = default)
{ {
var livre = await db.Livres.FirstOrDefaultAsync(l => l.Id == id, ct); if (!await db.Livres.AnyAsync(l => l.Id == id, ct))
if (livre is null)
{ {
return null; return null;
} }
livre.Statut = statut; await AppliquerStatutAsync(id, statut, utilisateur, ct);
await db.SaveChangesAsync(ct); return await ObtenirAsync(id, utilisateur, ct);
return Projeter(livre);
} }
public async Task<bool> SupprimerAsync(int id, CancellationToken ct = default) public async Task<bool> SupprimerAsync(int id, CancellationToken ct = default)
{ {
var livre = await db.Livres.FirstOrDefaultAsync(l => l.Id == id, ct); var livre = await db.Livres.Include(l => l.Auteurs).FirstOrDefaultAsync(l => l.Id == id, ct);
if (livre is null) if (livre is null)
{ {
return false; return false;
} }
// Les liens vers les auteurs partent en cascade, mais les fiches auteur restent : elles
// sont peut-être partagées avec d'autres livres. Le nettoyage d'après tranche.
db.Livres.Remove(livre); db.Livres.Remove(livre);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
await auteurs.SupprimerOrphelinsAsync(ct);
return true; return true;
} }
/// <summary>Statut de <paramref name="utilisateur"/> pour chacun des livres demandés.</summary>
private async Task<Dictionary<int, Statut?>> StatutsAsync(
IReadOnlyList<int> livreIds, string? utilisateur, CancellationToken ct)
{
// Sans identité, personne n'a de statut : on ne montre surtout pas celui d'un autre.
if (utilisateur is null || livreIds.Count == 0)
{
return [];
}
// Le dictionnaire est volontairement typé « Statut? » : sans ça, l'absence de ligne
// ressortirait comme la valeur 0 de l'énumération — c'est-à-dire « À lire » — et
// « non commencé » deviendrait indiscernable d'un choix explicite.
return await db.StatutsLecture
.AsNoTracking()
.Where(s => s.Utilisateur == utilisateur && livreIds.Contains(s.LivreId))
.ToDictionaryAsync(s => s.LivreId, s => (Statut?)s.Statut, ct);
}
/// <summary>
/// Pose, met à jour ou retire le statut de lecture d'une personne pour un livre.
/// </summary>
private async Task AppliquerStatutAsync(
int livreId, Statut? statut, string? utilisateur, CancellationToken ct)
{
if (utilisateur is null)
{
// Aucune identité : on n'invente pas de propriétaire pour ce statut. Le livre reste
// enregistré, simplement sans lecture rattachée à personne.
return;
}
var ligne = await db.StatutsLecture
.FirstOrDefaultAsync(s => s.LivreId == livreId && s.Utilisateur == utilisateur, ct);
if (statut is null)
{
if (ligne is not null)
{
// Retour à « non commencé » : on supprime la ligne au lieu d'inventer une
// quatrième valeur d'énumération qui aurait fallu migrer.
db.StatutsLecture.Remove(ligne);
await db.SaveChangesAsync(ct);
}
return;
}
if (ligne is null)
{
db.StatutsLecture.Add(new StatutLecture
{
LivreId = livreId,
Utilisateur = utilisateur,
Statut = statut.Value,
DateMaj = DateTime.UtcNow,
});
}
else
{
ligne.Statut = statut.Value;
ligne.DateMaj = DateTime.UtcNow;
}
await db.SaveChangesAsync(ct);
}
/// <summary>Remplace les auteurs du livre par ceux de la saisie, en conservant leur ordre.</summary>
private async Task RattacherAuteursAsync(
Livre livre, IEnumerable<string> noms, CancellationToken ct)
{
var resolus = await auteurs.ResoudreAsync(noms, ct);
// Les liens existants sont retirés puis reconstruits : c'est la façon la plus simple de
// gérer à la fois l'ajout, le retrait et le réordonnancement des auteurs.
if (livre.Auteurs.Count > 0)
{
db.LivreAuteurs.RemoveRange(livre.Auteurs);
livre.Auteurs.Clear();
}
for (var i = 0; i < resolus.Count; i++)
{
livre.Auteurs.Add(new LivreAuteur { Auteur = resolus[i], Position = i });
}
}
/// <summary> /// <summary>
/// Contrôles minimaux : un titre, et un ISBN qui tienne debout s'il est fourni. /// Contrôles minimaux : un titre, et un ISBN qui tienne debout s'il est fourni.
/// </summary> /// </summary>
@@ -179,15 +303,19 @@ public sealed class ServiceCatalogue(MaBibliDbContext db) : IServiceCatalogue
private static string? Vide(string? valeur) => private static string? Vide(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim(); string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim();
private static LivreDto Projeter(Livre livre) => new() private static LivreDto Projeter(Livre livre, Statut? statut) => new()
{ {
Id = livre.Id, Id = livre.Id,
Isbn = livre.Isbn, Isbn = livre.Isbn,
Titre = livre.Titre, Titre = livre.Titre,
Auteur = livre.Auteur, Auteurs = livre.Auteurs
.OrderBy(la => la.Position)
.Where(la => la.Auteur is not null)
.Select(la => new AuteurDto { Id = la.Auteur!.Id, Nom = la.Auteur.Nom })
.ToList(),
Editeur = livre.Editeur, Editeur = livre.Editeur,
Format = livre.Format, Format = livre.Format,
Statut = livre.Statut, Statut = statut,
CoverUrl = livre.CoverUrl, CoverUrl = livre.CoverUrl,
DateAjout = livre.DateAjout, DateAjout = livre.DateAjout,
AjoutePar = livre.AjoutePar, AjoutePar = livre.AjoutePar,
+22 -6
View File
@@ -42,7 +42,20 @@ public static class BnfSruParser
continue; continue;
} }
// Plusieurs dc:creator sont possibles : un livre peut avoir plusieurs auteurs.
var auteurs = Tous(data, "creator")
.Select(NettoyageIsbd.Auteur)
.Where(a => a is not null)
.Select(a => a!)
.Distinct()
.ToList();
var titre = NettoyageIsbd.Titre(Premier(data, "title")); var titre = NettoyageIsbd.Titre(Premier(data, "title"));
// Certaines notices recollent le nom de l'auteur en fin de titre sans le séparateur
// ISBD — il faut donc les auteurs nettoyés avant de pouvoir finir le titre.
titre = NettoyageIsbd.RetirerAuteurEnFin(titre, auteurs);
if (titre is null) if (titre is null)
{ {
// Une notice sans titre exploitable n'a aucun intérêt pour un pré-remplissage. // Une notice sans titre exploitable n'a aucun intérêt pour un pré-remplissage.
@@ -52,7 +65,7 @@ public static class BnfSruParser
candidats.Add(new CandidatLivre candidats.Add(new CandidatLivre
{ {
Titre = titre, Titre = titre,
Auteur = NettoyageIsbd.Auteur(Premier(data, "creator")), Auteurs = auteurs,
Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")), Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")),
Annee = Premier(data, "date"), Annee = Premier(data, "date"),
Langue = Premier(data, "language"), Langue = Premier(data, "language"),
@@ -66,9 +79,12 @@ public static class BnfSruParser
return candidats; return candidats;
} }
private static string? Premier(XElement recordData, string nomLocal) private static string? Premier(XElement recordData, string nomLocal) =>
{ Tous(recordData, nomLocal).FirstOrDefault();
var valeur = recordData.Descendants(Dc + nomLocal).FirstOrDefault()?.Value?.Trim();
return string.IsNullOrEmpty(valeur) ? null : valeur; private static IEnumerable<string> Tous(XElement recordData, string nomLocal) =>
} recordData.Descendants(Dc + nomLocal)
.Select(e => e.Value?.Trim())
.Where(v => !string.IsNullOrEmpty(v))
.Select(v => v!);
} }
+10 -4
View File
@@ -62,14 +62,17 @@ public static class OpenLibraryMapper
titre = $"{titre} : {edition.Subtitle.Trim()}"; titre = $"{titre} : {edition.Subtitle.Trim()}";
} }
var auteur = nomsAuteurs.Count > 0 // Les noms résolus par /authors/{id}.json arrivent déjà séparés. En dernier repli la
? string.Join(", ", nomsAuteurs) // mention de responsabilité n'est qu'une phrase : on la garde entière plutôt que de
: NettoyerByStatement(edition.ByStatement); // parier sur ses virgules, qui séparent aussi bien deux auteurs qu'un « Zola, Émile ».
var auteurs = nomsAuteurs.Count > 0
? nomsAuteurs
: Liste(NettoyerByStatement(edition.ByStatement));
return new CandidatLivre return new CandidatLivre
{ {
Titre = titre, Titre = titre,
Auteur = auteur, Auteurs = auteurs,
Editeur = edition.Publishers?.FirstOrDefault()?.Trim().Trim('[', ']'), Editeur = edition.Publishers?.FirstOrDefault()?.Trim().Trim('[', ']'),
Annee = string.IsNullOrWhiteSpace(edition.PublishDate) ? null : edition.PublishDate.Trim(), Annee = string.IsNullOrWhiteSpace(edition.PublishDate) ? null : edition.PublishDate.Trim(),
Langue = edition.Languages? Langue = edition.Languages?
@@ -83,6 +86,9 @@ public static class OpenLibraryMapper
}; };
} }
private static IReadOnlyList<string> Liste(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? [] : [valeur];
/// <summary> /// <summary>
/// Dernier repli quand aucun auteur n'est résolvable : la mention de responsabilité brute, /// Dernier repli quand aucun auteur n'est résolvable : la mention de responsabilité brute,
/// débarrassée de sa ponctuation finale (« Thomas H. Cormen ... [et al.]. »). /// débarrassée de sa ponctuation finale (« Thomas H. Cormen ... [et al.]. »).
@@ -0,0 +1,40 @@
@*
Sélecteur du statut de lecture.
Le statut est PERSONNEL (CLAUDE.md) : ce que montrent ces boutons est la lecture de la
personne connectée, pas celle du foyer. « Non commencé » n'est pas une valeur de plus dans
l'énumération, c'est l'absence de ligne en base — d'où le bouton qui remet la valeur à null.
*@
<div class="segments @(Compact ? "segments-compacts" : null)" role="group" aria-label="Statut de lecture">
<button type="button"
class="segment @(Valeur is null ? "segment-actif" : null)"
disabled="@Desactive"
@onclick="() => Choisir(null)">
Non commencé
</button>
@foreach (var statut in Statuts)
{
<button type="button"
class="segment @Libelles.ClasseStatut(statut) @(Valeur == statut ? "segment-actif" : null)"
disabled="@Desactive"
@onclick="() => Choisir(statut)">
@Libelles.Statut(statut)
</button>
}
</div>
@code {
private static readonly Statut[] Statuts = Enum.GetValues<Statut>();
[Parameter] public Statut? Valeur { get; set; }
[Parameter] public EventCallback<Statut?> ValeurChanged { get; set; }
[Parameter] public bool Desactive { get; set; }
[Parameter] public bool Compact { get; set; }
private Task Choisir(Statut? statut) =>
statut == Valeur ? Task.CompletedTask : ValeurChanged.InvokeAsync(statut);
}
+13 -14
View File
@@ -18,10 +18,17 @@
placeholder="Titre du livre" autocomplete="off" /> placeholder="Titre du livre" autocomplete="off" />
</label> </label>
@*
Un seul champ, plusieurs auteurs : le point-virgule les sépare. La virgule est déjà
prise par la forme bibliographique « Zola, Émile », qu'on doit pouvoir taper telle quelle.
Le serveur rapproche ensuite les variantes sûres d'un auteur déjà connu.
*@
<label class="champ"> <label class="champ">
<span class="champ-libelle">Auteur</span> <span class="champ-libelle">
Auteur<span class="champ-aide"> (plusieurs : séparez par « ; »)</span>
</span>
<input class="champ-saisie" type="text" @bind="Saisie.Auteur" @bind:event="oninput" <input class="champ-saisie" type="text" @bind="Saisie.Auteur" @bind:event="oninput"
placeholder="Prénom Nom" autocomplete="off" /> placeholder="Prénom Nom ; Prénom Nom" autocomplete="off" />
</label> </label>
<label class="champ"> <label class="champ">
@@ -51,17 +58,10 @@
</fieldset> </fieldset>
<fieldset class="champ"> <fieldset class="champ">
<legend class="champ-libelle">Statut de lecture</legend> <legend class="champ-libelle">
<div class="segments"> Statut de lecture<span class="champ-aide"> (le vôtre)</span>
@foreach (var statut in Statuts) </legend>
{ <ChoixStatut Valeur="Saisie.Statut" ValeurChanged="s => Saisie.Statut = s" />
<button type="button"
class="segment @(Saisie.Statut == statut ? "segment-actif" : null)"
@onclick="() => Saisie.Statut = statut">
@Libelles.Statut(statut)
</button>
}
</div>
</fieldset> </fieldset>
<label class="champ"> <label class="champ">
@@ -94,7 +94,6 @@
@code { @code {
private static readonly Format[] Formats = Enum.GetValues<Format>(); private static readonly Format[] Formats = Enum.GetValues<Format>();
private static readonly Statut[] Statuts = Enum.GetValues<Statut>();
[Parameter, EditorRequired] public EnregistrementLivre Saisie { get; set; } = new(); [Parameter, EditorRequired] public EnregistrementLivre Saisie { get; set; } = new();
+129
View File
@@ -0,0 +1,129 @@
@page "/auteurs"
@inject ServiceLivresApi Api
<PageTitle>MaBibli — auteurs</PageTitle>
<h1 class="titre-page">Auteurs</h1>
@if (_erreur is not null)
{
<p class="message-erreur" role="alert">@_erreur</p>
}
@if (_suggestions is { Count: > 0 })
{
@*
Ce qui arrive ici est ce que la machine REFUSE de trancher. Les variantes sûres — casse,
accents, ordre « Nom, Prénom », initiales abrégées — ont déjà été réunies à l'écriture et
n'apparaissent jamais dans cette liste. Restent les cas ambigus, typiquement un patronyme
seul face à un nom complet : deux personnes d'une même famille écrivent aussi des livres.
*@
<section class="rapprochements">
<h2 class="titre-section">Rapprochements possibles</h2>
<p class="message-discret">
Ces noms se ressemblent sans qu'on puisse l'affirmer. Un patronyme seul peut désigner
une autre personne de la même famille — à vous de trancher. Une fusion se défait mal.
</p>
<ul class="liste-rapprochements">
@foreach (var suggestion in _suggestions)
{
<li class="carte-rapprochement" @key="(suggestion.Source.Id, suggestion.Cible.Id)">
<p class="rapprochement-noms">
<strong>@suggestion.Source.Nom</strong>
(@Livres(suggestion.Source.NombreDeLivres))
est-il <strong>@suggestion.Cible.Nom</strong>
(@Livres(suggestion.Cible.NombreDeLivres)) ?
</p>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal" disabled="@_enCours"
@onclick="() => AccepterAsync(suggestion)">
Oui, réunir sous « @suggestion.Cible.Nom »
</button>
<button type="button" class="bouton bouton-discret" disabled="@_enCours"
@onclick="() => RefuserAsync(suggestion)">
Non, deux personnes
</button>
</div>
</li>
}
</ul>
</section>
}
@if (_auteurs is null)
{
<p class="message-discret">Chargement…</p>
}
else if (_auteurs.Count == 0)
{
<p class="message-discret">Aucun auteur : la bibliothèque est vide.</p>
}
else
{
<p class="compteur">@_auteurs.Count auteur@(_auteurs.Count > 1 ? "s" : "")</p>
<ul class="liste-auteurs">
@foreach (var auteur in _auteurs)
{
<li class="carte-auteur-ligne" @key="auteur.Id">
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
<span class="etiquette">@Livres(auteur.NombreDeLivres)</span>
</li>
}
</ul>
}
<nav class="actions-flottantes">
<a class="bouton bouton-discret" href="">Retour au catalogue</a>
</nav>
@code {
private IReadOnlyList<AuteurDto>? _auteurs;
private IReadOnlyList<SuggestionRapprochement>? _suggestions;
private bool _enCours;
private string? _erreur;
protected override Task OnInitializedAsync() => ChargerAsync();
private static string Livres(int nombre) => $"{nombre} livre{(nombre > 1 ? "s" : "")}";
private async Task ChargerAsync()
{
_erreur = null;
try
{
_auteurs = await Api.ListerAuteursAsync();
_suggestions = await Api.ListerRapprochementsAsync();
}
catch (Exception ex)
{
_erreur = $"Impossible de charger les auteurs : {ex.Message}";
}
}
private Task AccepterAsync(SuggestionRapprochement suggestion) =>
DeciderAsync(() => Api.AccepterRapprochementAsync(suggestion.Source.Id, suggestion.Cible.Id));
private Task RefuserAsync(SuggestionRapprochement suggestion) =>
DeciderAsync(() => Api.RefuserRapprochementAsync(suggestion.Source.Id, suggestion.Cible.Id));
private async Task DeciderAsync(Func<Task<string?>> action)
{
_enCours = true;
var erreur = await action();
_enCours = false;
if (erreur is not null)
{
_erreur = erreur;
return;
}
// Une fusion peut en rendre d'autres caduques (l'un des deux noms disparaît) : on relit
// la liste complète plutôt que de retirer la ligne traitée à la main.
await ChargerAsync();
}
}
+52 -18
View File
@@ -6,6 +6,15 @@
<h1 class="titre-page">Catalogue</h1> <h1 class="titre-page">Catalogue</h1>
@if (_auteur is not null)
{
@* Le « cliquer sur un auteur » d'IDEES.md : le catalogue est ici restreint à ses livres. *@
<p class="message-discret">
Livres de <strong>@_auteur.Nom</strong>.
<a href="">Revenir à toute la bibliothèque</a>
</p>
}
<div class="barre-recherche"> <div class="barre-recherche">
<input class="champ-saisie" type="search" placeholder="Titre ou auteur…" <input class="champ-saisie" type="search" placeholder="Titre ou auteur…"
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" /> value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" />
@@ -22,7 +31,9 @@
} }
</div> </div>
<div class="segments" role="group" aria-label="Filtrer par statut"> @* Ce filtre porte sur MON statut, pas sur celui du foyer : un livre sans statut pour moi
ne remonte sous aucune valeur, il reste visible sous « Tous ». *@
<div class="segments" role="group" aria-label="Filtrer par mon statut de lecture">
<button type="button" class="segment @(_statut is null ? "segment-actif" : null)" <button type="button" class="segment @(_statut is null ? "segment-actif" : null)"
@onclick="() => FiltrerStatut(null)">Tous</button> @onclick="() => FiltrerStatut(null)">Tous</button>
@foreach (var statut in Enum.GetValues<Statut>()) @foreach (var statut in Enum.GetValues<Statut>())
@@ -61,9 +72,15 @@ else if (_livres is not null)
<div class="carte-corps"> <div class="carte-corps">
<a class="carte-titre" href="@($"livres/{livre.Id}")">@livre.Titre</a> <a class="carte-titre" href="@($"livres/{livre.Id}")">@livre.Titre</a>
@if (!string.IsNullOrWhiteSpace(livre.Auteur)) @if (livre.Auteurs.Count > 0)
{ {
<p class="carte-auteur">@livre.Auteur</p> <p class="carte-auteur">
@foreach (var (auteur, index) in livre.Auteurs.Select((a, i) => (a, i)))
{
@(index > 0 ? ", " : "")
<a class="lien-auteur" href="@($"?auteur={auteur.Id}")">@auteur.Nom</a>
}
</p>
} }
<p class="carte-details"> <p class="carte-details">
@@ -75,17 +92,10 @@ else if (_livres is not null)
</p> </p>
@* Changement de statut : action la plus fréquente, donc directement dans la liste. *@ @* Changement de statut : action la plus fréquente, donc directement dans la liste. *@
<div class="segments segments-compacts" role="group" aria-label="Statut de lecture"> <ChoixStatut Valeur="livre.Statut"
@foreach (var statut in Enum.GetValues<Statut>()) ValeurChanged="s => ChangerStatut(livre, s)"
{ Desactive="_statutEnCours == livre.Id"
<button type="button" Compact="true" />
class="segment @Libelles.ClasseStatut(statut) @(livre.Statut == statut ? "segment-actif" : null)"
disabled="@(_statutEnCours == livre.Id)"
@onclick="() => ChangerStatut(livre, statut)">
@Libelles.Statut(statut)
</button>
}
</div>
</div> </div>
</li> </li>
} }
@@ -95,10 +105,17 @@ else if (_livres is not null)
<nav class="actions-flottantes"> <nav class="actions-flottantes">
<a class="bouton bouton-principal" href="ajout/isbn">Ajouter par ISBN</a> <a class="bouton bouton-principal" href="ajout/isbn">Ajouter par ISBN</a>
<a class="bouton bouton-discret" href="ajout/manuel">Saisie manuelle</a> <a class="bouton bouton-discret" href="ajout/manuel">Saisie manuelle</a>
<a class="bouton bouton-discret" href="auteurs">Auteurs</a>
</nav> </nav>
@code { @code {
/// <summary>Restreint le catalogue à un auteur. Vient de « ?auteur=… », posé par les liens de la liste.</summary>
[SupplyParameterFromQuery(Name = "auteur")]
public int? AuteurId { get; set; }
private IReadOnlyList<LivreDto>? _livres; private IReadOnlyList<LivreDto>? _livres;
private AuteurDto? _auteur;
private int? _auteurCharge;
private string _recherche = string.Empty; private string _recherche = string.Empty;
private Format? _format; private Format? _format;
private Statut? _statut; private Statut? _statut;
@@ -110,9 +127,19 @@ else if (_livres is not null)
private CancellationTokenSource _cts = new(); private CancellationTokenSource _cts = new();
private bool EstFiltre => private bool EstFiltre =>
!string.IsNullOrWhiteSpace(_recherche) || _format is not null || _statut is not null; !string.IsNullOrWhiteSpace(_recherche) || _format is not null || _statut is not null
|| AuteurId is not null;
protected override Task OnInitializedAsync() => ChargerAsync(); protected override async Task OnParametersSetAsync()
{
if (_auteurCharge != AuteurId)
{
_auteurCharge = AuteurId;
_auteur = AuteurId is { } id ? await Api.ObtenirAuteurAsync(id) : null;
}
await ChargerAsync();
}
private async Task SurRecherche(ChangeEventArgs e) private async Task SurRecherche(ChangeEventArgs e)
{ {
@@ -144,7 +171,14 @@ else if (_livres is not null)
try try
{ {
var criteres = new CritereLivres { Recherche = _recherche, Format = _format, Statut = _statut }; var criteres = new CritereLivres
{
Recherche = _recherche,
Format = _format,
Statut = _statut,
AuteurId = AuteurId,
};
_livres = await Api.ListerAsync(criteres, jeton); _livres = await Api.ListerAsync(criteres, jeton);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -164,7 +198,7 @@ else if (_livres is not null)
} }
} }
private async Task ChangerStatut(LivreDto livre, Statut statut) private async Task ChangerStatut(LivreDto livre, Statut? statut)
{ {
if (livre.Statut == statut) if (livre.Statut == statut)
{ {
+4 -1
View File
@@ -97,7 +97,10 @@ else
{ {
Isbn = _livre.Isbn, Isbn = _livre.Isbn,
Titre = _livre.Titre, Titre = _livre.Titre,
Auteur = _livre.Auteur,
// Les auteurs sont repris tels que rattachés au livre. Le statut, lui, est celui de
// la personne qui édite : enregistrer ne touche jamais à la lecture de quelqu'un d'autre.
Auteurs = _livre.Auteurs.Select(a => a.Nom).ToList(),
Editeur = _livre.Editeur, Editeur = _livre.Editeur,
Format = _livre.Format, Format = _livre.Format,
Statut = _livre.Statut, Statut = _livre.Statut,
+44 -1
View File
@@ -36,6 +36,11 @@ public sealed class ServiceLivresApi(HttpClient http)
parametres.Add($"statut={statut}"); parametres.Add($"statut={statut}");
} }
if (criteres.AuteurId is { } auteurId)
{
parametres.Add($"auteurId={auteurId}");
}
var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty); var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty);
return await http.GetFromJsonAsync<List<LivreDto>>(url, Json, ct) ?? []; return await http.GetFromJsonAsync<List<LivreDto>>(url, Json, ct) ?? [];
@@ -61,13 +66,51 @@ public sealed class ServiceLivresApi(HttpClient http)
return await InterpreterAsync<LivreDto>(reponse, ct); return await InterpreterAsync<LivreDto>(reponse, ct);
} }
public async Task<ResultatApi<LivreDto>> ChangerStatutAsync(int id, Statut statut, CancellationToken ct = default) /// <summary>
/// Change le statut de lecture <b>de l'utilisateur courant</b> ; <c>null</c> le remet à
/// « non commencé ». Le serveur sait qui appelle : le client ne transmet jamais d'identité.
/// </summary>
public async Task<ResultatApi<LivreDto>> ChangerStatutAsync(
int id, Statut? statut, CancellationToken ct = default)
{ {
var reponse = await http.PatchAsJsonAsync( var reponse = await http.PatchAsJsonAsync(
$"api/livres/{id}/statut", new ChangementStatut { Statut = statut }, Json, ct); $"api/livres/{id}/statut", new ChangementStatut { Statut = statut }, Json, ct);
return await InterpreterAsync<LivreDto>(reponse, ct); return await InterpreterAsync<LivreDto>(reponse, ct);
} }
public async Task<IReadOnlyList<AuteurDto>> ListerAuteursAsync(CancellationToken ct = default) =>
await http.GetFromJsonAsync<List<AuteurDto>>("api/auteurs", Json, ct) ?? [];
public async Task<AuteurDto?> ObtenirAuteurAsync(int id, CancellationToken ct = default)
{
var reponse = await http.GetAsync($"api/auteurs/{id}", ct);
return reponse.StatusCode == HttpStatusCode.NotFound
? null
: await LireAsync<AuteurDto>(reponse, ct);
}
/// <summary>Rapprochements d'auteurs ambigus, que le serveur refuse d'appliquer seul.</summary>
public async Task<IReadOnlyList<SuggestionRapprochement>> ListerRapprochementsAsync(
CancellationToken ct = default) =>
await http.GetFromJsonAsync<List<SuggestionRapprochement>>("api/auteurs/rapprochements", Json, ct) ?? [];
public Task<string?> AccepterRapprochementAsync(int sourceId, int cibleId, CancellationToken ct = default) =>
DeciderAsync("accepter", sourceId, cibleId, ct);
public Task<string?> RefuserRapprochementAsync(int sourceId, int cibleId, CancellationToken ct = default) =>
DeciderAsync("refuser", sourceId, cibleId, ct);
private async Task<string?> DeciderAsync(string action, int sourceId, int cibleId, CancellationToken ct)
{
var reponse = await http.PostAsJsonAsync(
$"api/auteurs/rapprochements/{action}",
new DecisionRapprochement { SourceId = sourceId, CibleId = cibleId },
Json,
ct);
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
}
public async Task<string?> SupprimerAsync(int id, CancellationToken ct = default) public async Task<string?> SupprimerAsync(int id, CancellationToken ct = default)
{ {
var reponse = await http.DeleteAsync($"api/livres/{id}", ct); var reponse = await http.DeleteAsync($"api/livres/{id}", ct);
+18 -2
View File
@@ -9,15 +9,31 @@ namespace MaBibli.Shared.Catalogue;
/// <b>Il n'existe volontairement aucun critère sur <c>AjoutePar</c>.</b> La bibliothèque est /// <b>Il n'existe volontairement aucun critère sur <c>AjoutePar</c>.</b> La bibliothèque est
/// commune à tout le foyer : filtrer les lectures sur ce champ serait un bug de conception. /// commune à tout le foyer : filtrer les lectures sur ce champ serait un bug de conception.
/// L'absence de ce critère est l'endroit où l'invariant est le plus visible. /// L'absence de ce critère est l'endroit où l'invariant est le plus visible.
/// <para>
/// <see cref="Statut"/> n'est pas une exception : il filtre sur le statut de <b>l'appelant</b>,
/// que le serveur connaît par les en-têtes SSOwat. Le client ne désigne jamais de qui il parle.
/// </para>
/// </remarks> /// </remarks>
public record CritereLivres public record CritereLivres
{ {
/// <summary>Texte cherché dans le titre <i>ou</i> l'auteur. Insensible à la casse.</summary> /// <summary>
/// Texte cherché dans le titre <i>ou</i> les auteurs. Insensible à la casse
/// <b>et aux accents</b> : « emile » trouve « Émile ».
/// </summary>
public string? Recherche { get; init; } public string? Recherche { get; init; }
/// <summary>Physique ou numérique. <c>null</c> = les deux : le catalogue est unique.</summary> /// <summary>Physique ou numérique. <c>null</c> = les deux : le catalogue est unique.</summary>
public Format? Format { get; init; } public Format? Format { get; init; }
/// <summary>Statut de lecture. <c>null</c> = tous.</summary> /// <summary>
/// Statut de lecture de l'utilisateur courant. <c>null</c> = tous.
/// </summary>
/// <remarks>
/// Un livre sans statut pour cette personne ne remonte sous <b>aucune</b> valeur : « non
/// commencé » n'est pas un statut, c'est l'absence de statut. Il reste visible sans filtre.
/// </remarks>
public Statut? Statut { get; init; } public Statut? Statut { get; init; }
/// <summary>Ne garder que les livres d'un auteur — le « cliquer sur un auteur » d'IDEES.md.</summary>
public int? AuteurId { get; init; }
} }
+39 -11
View File
@@ -1,4 +1,5 @@
using MaBibli.Shared.Entites; using MaBibli.Shared.Entites;
using MaBibli.Shared.Textes;
namespace MaBibli.Shared.Catalogue; namespace MaBibli.Shared.Catalogue;
@@ -9,14 +10,21 @@ namespace MaBibli.Shared.Catalogue;
/// Écrit sur <see cref="IQueryable{T}"/> pour être exécuté par EF Core côté base <b>et</b> /// Écrit sur <see cref="IQueryable{T}"/> pour être exécuté par EF Core côté base <b>et</b>
/// testable en mémoire sur une simple liste, sans base ni réseau. /// testable en mémoire sur une simple liste, sans base ni réseau.
/// <para> /// <para>
/// La comparaison passe par <c>ToLower()</c> : EF Core la traduit en SQL, et LINQ-to-Objects /// La recherche porte sur les colonnes <b>normalisées</b> (<see cref="Livre.TitreNormalise"/>,
/// lui donne le même comportement insensible à la casse. Les accents ne sont pas normalisés /// <see cref="Auteur.NomNormalise"/>), pas sur les colonnes d'affichage : SQLite ne sait pas
/// (« Emile » ne trouve pas « Émile ») — limitation assumée à ce stade. /// ignorer les accents, et sa collation <c>NOCASE</c> s'arrête à l'ASCII. Le terme cherché passe
/// par la même normalisation que la valeur stockée — c'est ce qui fait que « emile » trouve
/// « Émile ».
/// </para> /// </para>
/// </remarks> /// </remarks>
public static class FiltreLivres public static class FiltreLivres
{ {
public static IQueryable<Livre> Appliquer(IQueryable<Livre> source, CritereLivres criteres) /// <param name="utilisateur">
/// Personne dont on regarde le statut. Sert <b>uniquement</b> à
/// <see cref="CritereLivres.Statut"/> : il ne restreint jamais la liste elle-même.
/// </param>
public static IQueryable<Livre> Appliquer(
IQueryable<Livre> source, CritereLivres criteres, string? utilisateur)
{ {
// AUCUN filtre sur AjoutePar : la bibliothèque est commune (CLAUDE.md). // AUCUN filtre sur AjoutePar : la bibliothèque est commune (CLAUDE.md).
// Ce commentaire est là pour qu'une régression future saute aux yeux en relecture. // Ce commentaire est là pour qu'une régression future saute aux yeux en relecture.
@@ -28,16 +36,32 @@ public static class FiltreLivres
if (criteres.Statut is { } statut) if (criteres.Statut is { } statut)
{ {
source = source.Where(l => l.Statut == statut); // Le statut vit dans une table par personne : on interroge la ligne de l'appelant,
// et lui seulement. Sans utilisateur identifié, personne n'a de statut : la sélection
// est vide, ce qui vaut mieux que de montrer celui d'un autre.
source = utilisateur is null
? source.Where(_ => false)
: source.Where(l => l.Statuts.Any(s => s.Utilisateur == utilisateur && s.Statut == statut));
} }
var terme = criteres.Recherche?.Trim(); if (criteres.AuteurId is { } auteurId)
if (!string.IsNullOrEmpty(terme))
{ {
var recherche = terme.ToLower(); source = source.Where(l => l.Auteurs.Any(la => la.AuteurId == auteurId));
}
var terme = NormalisationTexte.Normaliser(criteres.Recherche);
if (terme.Length > 0)
{
// Le terme est confronté aux deux formes stockées de chaque auteur : le nom dans
// l'ordre de saisie, et la clé aux mots triés. La seconde est ce qui fait que
// « zola émile » trouve « Émile Zola » — le terme est trié de la même façon.
var cle = RapprochementAuteurs.Cle(criteres.Recherche);
source = source.Where(l => source = source.Where(l =>
l.Titre.ToLower().Contains(recherche) l.TitreNormalise.Contains(terme)
|| (l.Auteur != null && l.Auteur.ToLower().Contains(recherche))); || l.Auteurs.Any(la =>
la.Auteur!.NomNormalise.Contains(terme)
|| la.Auteur.CleRegroupement.Contains(cle)));
} }
return Trier(source); return Trier(source);
@@ -49,7 +73,11 @@ public static class FiltreLivres
/// <remarks> /// <remarks>
/// CLAUDE.md ne tranche pas le tri. L'ordre alphabétique a été retenu parce qu'une /// CLAUDE.md ne tranche pas le tri. L'ordre alphabétique a été retenu parce qu'une
/// bibliothèque se parcourt par titre ; l'ordre d'ajout n'a d'intérêt que le jour de la saisie. /// bibliothèque se parcourt par titre ; l'ordre d'ajout n'a d'intérêt que le jour de la saisie.
/// <para>
/// Le tri porte sur le titre normalisé : sinon « Œuvres » et « Éducation » atterrissent
/// après « Zola » dans l'ordre des points de code.
/// </para>
/// </remarks> /// </remarks>
public static IQueryable<Livre> Trier(IQueryable<Livre> source) => public static IQueryable<Livre> Trier(IQueryable<Livre> source) =>
source.OrderBy(l => l.Titre).ThenBy(l => l.Id); source.OrderBy(l => l.TitreNormalise).ThenBy(l => l.Id);
} }
+18 -2
View File
@@ -23,8 +23,24 @@ public record CandidatLivre
/// <summary>Titre nettoyé de la ponctuation ISBD.</summary> /// <summary>Titre nettoyé de la ponctuation ISBD.</summary>
public required string Titre { get; init; } public required string Titre { get; init; }
/// <summary>Auteur au format « Prénom Nom », ou <c>null</c> si la source n'en donne pas.</summary> /// <summary>
public string? Auteur { get; init; } /// Auteurs au format « Prénom Nom », dans l'ordre donné par la source. Peut être vide.
/// </summary>
/// <remarks>
/// Une liste, et non une chaîne : <i>Introduction to Algorithms</i> en renvoie quatre
/// (Cormen, Leiserson, Rivest, Stein), et les recoller pour les redécouper ensuite ferait
/// perdre la frontière entre « Zola, Émile » et deux auteurs.
/// </remarks>
public IReadOnlyList<string> Auteurs { get; init; } = [];
/// <summary>
/// Les auteurs sur une ligne, séparés comme les attend un champ de saisie
/// (<see cref="EnregistrementLivre.SeparateurAuteurs"/>).
/// </summary>
public string? Auteur =>
Auteurs.Count == 0
? null
: string.Join($"{EnregistrementLivre.SeparateurAuteurs} ", Auteurs);
public string? Editeur { get; init; } public string? Editeur { get; init; }
+47 -3
View File
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using MaBibli.Shared.Entites; using MaBibli.Shared.Entites;
namespace MaBibli.Shared.Dtos; namespace MaBibli.Shared.Dtos;
@@ -11,24 +12,67 @@ namespace MaBibli.Shared.Dtos;
/// </remarks> /// </remarks>
public record EnregistrementLivre public record EnregistrementLivre
{ {
/// <summary>Séparateur des auteurs dans la forme texte d'un champ de saisie.</summary>
/// <remarks>
/// Le point-virgule, et non la virgule : celle-ci est déjà prise par la forme
/// bibliographique « Zola, Émile », qu'on doit pouvoir taper telle quelle.
/// </remarks>
public const char SeparateurAuteurs = ';';
/// <summary>Facultatif : certains livres n'ont pas d'ISBN. Normalisé par le serveur.</summary> /// <summary>Facultatif : certains livres n'ont pas d'ISBN. Normalisé par le serveur.</summary>
public string? Isbn { get; set; } public string? Isbn { get; set; }
public string Titre { get; set; } = string.Empty; public string Titre { get; set; } = string.Empty;
public string? Auteur { get; set; } /// <summary>
/// Noms des auteurs, dans l'ordre de la couverture. Le serveur les rattache aux fiches
/// existantes quand c'est sûr, et en crée sinon.
/// </summary>
public List<string> Auteurs { get; set; } = [];
public string? Editeur { get; set; } public string? Editeur { get; set; }
public Format Format { get; set; } public Format Format { get; set; }
public Statut Statut { get; set; } /// <summary>
/// Statut de lecture <b>de l'appelant</b>. <c>null</c> = non commencé, et aucune ligne n'est
/// alors écrite en base.
/// </summary>
public Statut? Statut { get; set; }
public string? CoverUrl { get; set; } public string? CoverUrl { get; set; }
/// <summary>
/// Vue « une seule ligne » de <see cref="Auteurs"/>, pour les champs de saisie simples.
/// </summary>
/// <remarks>
/// Non sérialisée : <see cref="Auteurs"/> est la forme de référence sur le fil. Cette
/// propriété existe pour que l'interface puisse lier un <c>&lt;input&gt;</c> unique sans
/// avoir à gérer une liste dynamique — un foyer saisit un auteur dans l'immense majorité
/// des cas, et les rares livres à quatre auteurs se tapent avec des « ; ».
/// </remarks>
[JsonIgnore]
public string? Auteur
{
get => Auteurs.Count == 0 ? null : string.Join($"{SeparateurAuteurs} ", Auteurs);
set => Auteurs = Decouper(value);
}
/// <summary>Découpe une saisie texte en noms d'auteurs, en écartant les entrées vides.</summary>
public static List<string> Decouper(string? saisie) =>
string.IsNullOrWhiteSpace(saisie)
? []
: saisie.Split(SeparateurAuteurs, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
} }
/// <summary>Charge utile du changement de statut de lecture — l'action la plus fréquente.</summary> /// <summary>Charge utile du changement de statut de lecture — l'action la plus fréquente.</summary>
/// <remarks>
/// Le statut posé est toujours celui de l'appelant : il n'y a pas de champ « utilisateur »,
/// et il ne doit jamais y en avoir. Personne ne coche « lu » à la place d'un autre.
/// </remarks>
public record ChangementStatut public record ChangementStatut
{ {
public Statut Statut { get; set; } /// <summary><c>null</c> retire le statut : le livre redevient « non commencé » pour l'appelant.</summary>
public Statut? Statut { get; set; }
} }
+28 -2
View File
@@ -1,7 +1,19 @@
using System.Text.Json.Serialization;
using MaBibli.Shared.Entites; using MaBibli.Shared.Entites;
namespace MaBibli.Shared.Dtos; namespace MaBibli.Shared.Dtos;
/// <summary>Un auteur tel qu'exposé par l'API : de quoi l'afficher et cliquer dessus.</summary>
public record AuteurDto
{
public required int Id { get; init; }
public required string Nom { get; init; }
/// <summary>Nombre de livres du catalogue rattachés à cet auteur. Renseigné par la liste des auteurs.</summary>
public int NombreDeLivres { get; init; }
}
/// <summary> /// <summary>
/// Un livre tel qu'exposé par l'API. Volontairement sans la collection <c>Prets</c> : /// Un livre tel qu'exposé par l'API. Volontairement sans la collection <c>Prets</c> :
/// les prêts sont la phase 4. /// les prêts sont la phase 4.
@@ -14,13 +26,22 @@ public record LivreDto
public required string Titre { get; init; } public required string Titre { get; init; }
public string? Auteur { get; init; } /// <summary>Auteurs, dans l'ordre de la couverture. Peut être vide.</summary>
public IReadOnlyList<AuteurDto> Auteurs { get; init; } = [];
public string? Editeur { get; init; } public string? Editeur { get; init; }
public required Format Format { get; init; } public required Format Format { get; init; }
public required Statut Statut { get; init; } /// <summary>
/// Statut de lecture <b>de l'utilisateur courant</b>, ou <c>null</c> s'il n'en a jamais posé
/// (« non commencé »).
/// </summary>
/// <remarks>
/// Ce champ dépend de qui appelle : le même livre remonte « Lu » pour l'un et <c>null</c>
/// pour l'autre. C'est voulu — le livre est commun, sa lecture ne l'est pas.
/// </remarks>
public Statut? Statut { get; init; }
public string? CoverUrl { get; init; } public string? CoverUrl { get; init; }
@@ -31,4 +52,9 @@ public record LivreDto
/// la bibliothèque est commune et aucune lecture ne filtre sur ce champ. /// la bibliothèque est commune et aucune lecture ne filtre sur ce champ.
/// </summary> /// </summary>
public string? AjoutePar { get; init; } public string? AjoutePar { get; init; }
/// <summary>Auteurs sur une ligne, pour un affichage compact.</summary>
[JsonIgnore]
public string? AuteursAffiches =>
Auteurs.Count == 0 ? null : string.Join(", ", Auteurs.Select(a => a.Nom));
} }
@@ -0,0 +1,27 @@
namespace MaBibli.Shared.Dtos;
/// <summary>
/// Un rapprochement d'auteurs <b>proposé</b> à l'utilisateur, jamais appliqué d'office.
/// </summary>
/// <remarks>
/// Les variantes sûres (casse, accents, ordre, initiales) sont fusionnées automatiquement à
/// l'écriture et n'apparaissent donc jamais ici. Ne restent que les cas ambigus, typiquement un
/// patronyme seul face à un nom complet : « Hamilton » et « Peter F. Hamilton » peuvent être la
/// même personne ou deux membres d'une même famille, et seule l'utilisatrice le sait.
/// </remarks>
public record SuggestionRapprochement
{
/// <summary>Auteur au nom le plus court — celui qui disparaîtrait en cas de fusion.</summary>
public required AuteurDto Source { get; init; }
/// <summary>Auteur au nom le plus complet — celui qui absorberait l'autre.</summary>
public required AuteurDto Cible { get; init; }
}
/// <summary>Décision de l'utilisateur sur un rapprochement proposé.</summary>
public record DecisionRapprochement
{
public required int SourceId { get; set; }
public required int CibleId { get; set; }
}
+85
View File
@@ -0,0 +1,85 @@
using MaBibli.Shared.Textes;
namespace MaBibli.Shared.Entites;
/// <summary>
/// Un auteur du catalogue. Table dédiée, et non plus un champ texte sur <see cref="Livre"/>.
/// </summary>
/// <remarks>
/// Deux besoins imposaient la table : regrouper les livres d'un même auteur d'un clic, et
/// retrouver « Émile » en tapant « Emile ». Un champ texte libre ne sait faire ni l'un ni l'autre.
/// </remarks>
public class Auteur
{
public int Id { get; set; }
/// <summary>Nom tel qu'affiché, accents et casse compris : « Émile Zola ».</summary>
public string Nom { get; set; } = string.Empty;
/// <summary>
/// Forme normalisée du nom (minuscules, sans accents), dans l'ordre de saisie.
/// Sert à la <b>recherche</b>.
/// </summary>
public string NomNormalise { get; set; } = string.Empty;
/// <summary>
/// Clé de regroupement : mêmes mots normalisés mais <b>triés</b>, ce qui rend l'ordre
/// indifférent. <b>Unique</b> en base : c'est elle qui empêche « Zola, Émile » et
/// « Émile Zola » de coexister.
/// </summary>
public string CleRegroupement { get; set; } = string.Empty;
public List<LivreAuteur> Livres { get; set; } = [];
/// <summary>Renseigne les deux formes dérivées à partir du nom d'affichage.</summary>
public void RecalculerFormes()
{
Nom = Nom.Trim();
NomNormalise = NormalisationTexte.Normaliser(Nom);
CleRegroupement = RapprochementAuteurs.Cle(Nom);
}
}
/// <summary>
/// Table de liaison livre ↔ auteur : un livre peut avoir plusieurs auteurs.
/// </summary>
/// <remarks>
/// Le cas n'est pas théorique : le lookup ISBN d'<i>Introduction to Algorithms</i> renvoie
/// quatre noms (Cormen, Leiserson, Rivest, Stein). <see cref="Position"/> conserve l'ordre de
/// la couverture, qui n'est pas alphabétique et fait partie de l'information.
/// </remarks>
public class LivreAuteur
{
public int LivreId { get; set; }
public Livre? Livre { get; set; }
public int AuteurId { get; set; }
public Auteur? Auteur { get; set; }
/// <summary>Rang de l'auteur sur la couverture, à partir de 0.</summary>
public int Position { get; set; }
}
/// <summary>
/// Rapprochement d'auteurs <b>explicitement refusé</b> par l'utilisateur.
/// </summary>
/// <remarks>
/// Sans cette mémoire, une suggestion écartée reviendrait à chaque affichage : « Hamilton » et
/// « Peter F. Hamilton » se ressembleront toujours autant. On ne stocke <b>que</b> les refus ;
/// les suggestions elles-mêmes sont recalculées à la demande, et une acceptation ne laisse rien
/// à mémoriser puisque l'un des deux auteurs disparaît.
/// <para>
/// Le couple est toujours rangé avec le plus petit identifiant en premier, pour qu'un refus vaille
/// dans les deux sens.
/// </para>
/// </remarks>
public class RapprochementRefuse
{
public int Id { get; set; }
public int AuteurAId { get; set; }
public int AuteurBId { get; set; }
}
+28 -3
View File
@@ -1,3 +1,5 @@
using MaBibli.Shared.Textes;
namespace MaBibli.Shared.Entites; namespace MaBibli.Shared.Entites;
/// <summary> /// <summary>
@@ -12,14 +14,19 @@ public class Livre
public string Titre { get; set; } = string.Empty; public string Titre { get; set; } = string.Empty;
public string? Auteur { get; set; } /// <summary>
/// Titre en minuscules et sans accents, recalculé à chaque écriture.
/// </summary>
/// <remarks>
/// SQLite ne sait pas comparer sans tenir compte des accents : sans cette colonne,
/// « Bete » ne trouverait pas « La Bête humaine ».
/// </remarks>
public string TitreNormalise { get; set; } = string.Empty;
public string? Editeur { get; set; } public string? Editeur { get; set; }
public Format Format { get; set; } public Format Format { get; set; }
public Statut Statut { get; set; }
/// <summary>URL de la couverture (OpenLibrary), pas de fichier stocké localement.</summary> /// <summary>URL de la couverture (OpenLibrary), pas de fichier stocké localement.</summary>
public string? CoverUrl { get; set; } public string? CoverUrl { get; set; }
@@ -33,6 +40,24 @@ public class Livre
/// </summary> /// </summary>
public string? AjoutePar { get; set; } public string? AjoutePar { get; set; }
/// <summary>Auteurs du livre, ordonnés par <see cref="LivreAuteur.Position"/>.</summary>
public List<LivreAuteur> Auteurs { get; set; } = [];
/// <summary>
/// Statuts de lecture, <b>un par personne</b> et seulement pour celles qui en ont posé un.
/// </summary>
/// <remarks>
/// ⚠️ Il n'y a volontairement <b>aucune</b> propriété <c>Statut</c> sur cette classe : elle
/// serait commune à tout le foyer, exactement le défaut que cette table corrige.
/// </remarks>
public List<StatutLecture> Statuts { get; set; } = [];
/// <summary>Historique complet des prêts de ce livre (pas seulement le prêt en cours).</summary> /// <summary>Historique complet des prêts de ce livre (pas seulement le prêt en cours).</summary>
public List<Pret> Prets { get; set; } = []; public List<Pret> Prets { get; set; } = [];
public void RecalculerFormes()
{
Titre = Titre.Trim();
TitreNormalise = NormalisationTexte.Normaliser(Titre);
}
} }
+39
View File
@@ -0,0 +1,39 @@
namespace MaBibli.Shared.Entites;
/// <summary>
/// Statut de lecture d'un livre <b>pour une personne donnée</b>.
/// </summary>
/// <remarks>
/// Décision actée dans CLAUDE.md : le livre est commun au foyer, sa lecture est personnelle.
/// Deux membres lisent le même exemplaire à des rythmes différents, et une colonne <c>Statut</c>
/// sur <see cref="Livre"/> les forcerait à partager un état unique — ce qui était le cas avant
/// cette refonte.
/// <para>
/// <b>Absence de ligne = non commencé.</b> On n'écrit rien à la création d'un livre : sur une
/// bibliothèque de foyer, la plupart des couples (livre, personne) n'ont aucun statut, et les
/// matérialiser tous multiplierait les lignes par le nombre de comptes pour n'exprimer qu'un vide.
/// </para>
/// <para>
/// Cette table est le patron de tout ce qui sera personnel plus tard — la liste d'envies évoquée
/// dans IDEES.md s'ajoutera de la même façon, sans toucher à <see cref="Livre"/>.
/// </para>
/// </remarks>
public class StatutLecture
{
public int Id { get; set; }
public int LivreId { get; set; }
public Livre? Livre { get; set; }
/// <summary>
/// <c>YNH_USER</c> de la personne concernée. Contrairement à <see cref="Livre.AjoutePar"/>,
/// ce champ <b>est</b> une frontière : on ne lit jamais le statut de quelqu'un d'autre.
/// </summary>
public string Utilisateur { get; set; } = string.Empty;
public Statut Statut { get; set; }
/// <summary>Dernière modification du statut, utile pour un futur « repris le… ».</summary>
public DateTime DateMaj { get; set; }
}
+75
View File
@@ -73,6 +73,81 @@ public class BnfSruParserTests
Assert.DoesNotContain("(", c.Editeur ?? string.Empty); Assert.DoesNotContain("(", c.Editeur ?? string.Empty);
} }
/// <summary>
/// Notice minimale bâtie à la main, au format de celles de la BnF.
/// </summary>
/// <remarks>
/// Les cas ci-dessous n'ont pas de réponse réelle enregistrée : ils portent sur la manière
/// dont le parseur <b>combine</b> ses règles, pas sur la lecture du XML — que les fixtures
/// réelles couvrent déjà.
/// </remarks>
private static string Notice(string titre, params string[] auteurs) =>
$"""
<srw:searchRetrieveResponse xmlns:srw="http://www.loc.gov/zing/srw/">
<srw:numberOfRecords>1</srw:numberOfRecords>
<srw:records><srw:record>
<srw:recordData xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>{titre}</dc:title>
{string.Concat(auteurs.Select(a => $"<dc:creator>{a}</dc:creator>"))}
</srw:recordData>
</srw:record></srw:records>
</srw:searchRetrieveResponse>
""";
/// <summary>
/// Cas réel de l'ISBN <c>9782749942636</c> : la BnF recolle le nom de l'auteur en fin de
/// titre <b>sans</b> le séparateur ISBD. Il faut donc les auteurs nettoyés avant de pouvoir
/// finir le titre — c'est l'ordre des opérations qui est vérifié ici.
/// </summary>
[Fact]
public void Le_nom_de_lauteur_colle_en_fin_de_titre_est_retire()
{
var candidats = BnfSruParser.Parser(
Notice(
"Le plus grand défi de l'histoire de l'humanité : face à la catastrophe "
+ "écologique et sociale (Nouvelle éd. augmentée) Aurélien Barrau",
"Barrau, Aurélien (1973-....). Auteur du texte"),
"9782749942636",
null);
var c = Assert.Single(candidats);
Assert.Equal(
"Le plus grand défi de l'histoire de l'humanité : face à la catastrophe écologique "
+ "et sociale (Nouvelle éd. augmentée)",
c.Titre);
Assert.Equal(["Aurélien Barrau"], c.Auteurs);
}
/// <summary>Contre-exemple : un nom de personne ailleurs que tout à la fin reste en place.</summary>
[Fact]
public void Un_nom_de_personne_dans_le_titre_nest_pas_mutile()
{
var candidats = BnfSruParser.Parser(
Notice("Lettre à Émile Zola et autres textes", "Maupassant, Guy de (1850-1893)"),
"0000000000",
null);
Assert.Equal("Lettre à Émile Zola et autres textes", Assert.Single(candidats).Titre);
}
[Fact]
public void Plusieurs_dc_creator_donnent_plusieurs_auteurs()
{
var candidats = BnfSruParser.Parser(
Notice(
"Le Horla et autres nouvelles",
"Maupassant, Guy de (1850-1893). Auteur du texte",
"Zola, Émile (1840-1902). Préfacier"),
"0000000000",
null);
var c = Assert.Single(candidats);
Assert.Equal(["Guy de Maupassant", "Émile Zola"], c.Auteurs);
// La forme « une ligne » reste disponible pour un champ de saisie unique.
Assert.Equal("Guy de Maupassant; Émile Zola", c.Auteur);
}
[Fact] [Fact]
public void Reponse_sans_notice_donne_une_liste_vide_et_pas_une_exception() public void Reponse_sans_notice_donne_une_liste_vide_et_pas_une_exception()
{ {
+100 -27
View File
@@ -8,32 +8,54 @@ namespace MaBibli.Tests;
/// </summary> /// </summary>
public class FiltreLivresTests public class FiltreLivresTests
{ {
private static Auteur Auteur(int id, string nom)
{
var auteur = new Auteur { Id = id, Nom = nom };
auteur.RecalculerFormes();
return auteur;
}
private static readonly Auteur Zola = Auteur(1, "Émile Zola");
private static readonly Auteur Maupassant = Auteur(2, "Guy de Maupassant");
private static Livre Livre(
int id, string titre, Auteur? auteur, Format format, Statut? statut, string? ajoutePar,
string? lecteur = "mathieu")
{
var livre = new Livre
{
Id = id,
Titre = titre,
Format = format,
AjoutePar = ajoutePar,
};
livre.RecalculerFormes();
if (auteur is not null)
{
livre.Auteurs.Add(new LivreAuteur { LivreId = id, AuteurId = auteur.Id, Auteur = auteur });
}
if (statut is { } valeur && lecteur is not null)
{
livre.Statuts.Add(new StatutLecture { LivreId = id, Utilisateur = lecteur, Statut = valeur });
}
return livre;
}
// « mathieu » a un statut sur les quatre livres ; « camille » n'en a aucun.
private static readonly Livre[] Catalogue = private static readonly Livre[] Catalogue =
[ [
new() Livre(1, "Germinal", Zola, Format.Physique, Statut.Lu, "mathieu"),
{ Livre(2, "La Bête humaine", Zola, Format.Numerique, Statut.ALire, "camille"),
Id = 1, Titre = "Germinal", Auteur = "Émile Zola", Livre(3, "Le Horla", Maupassant, Format.Physique, Statut.EnCours, "camille"),
Format = Format.Physique, Statut = Statut.Lu, AjoutePar = "mathieu", Livre(4, "Bel-Ami", null, Format.Numerique, Statut.Lu, null),
},
new()
{
Id = 2, Titre = "La Bête humaine", Auteur = "Émile Zola",
Format = Format.Numerique, Statut = Statut.ALire, AjoutePar = "camille",
},
new()
{
Id = 3, Titre = "Le Horla", Auteur = "Guy de Maupassant",
Format = Format.Physique, Statut = Statut.EnCours, AjoutePar = "camille",
},
new()
{
Id = 4, Titre = "Bel-Ami", Auteur = null,
Format = Format.Numerique, Statut = Statut.Lu, AjoutePar = null,
},
]; ];
private static List<Livre> Filtrer(CritereLivres criteres) => private static List<Livre> Filtrer(CritereLivres criteres, string? utilisateur = "mathieu") =>
FiltreLivres.Appliquer(Catalogue.AsQueryable(), criteres).ToList(); FiltreLivres.Appliquer(Catalogue.AsQueryable(), criteres, utilisateur).ToList();
[Fact] [Fact]
public void Sans_critere_tout_le_catalogue_remonte() public void Sans_critere_tout_le_catalogue_remonte()
@@ -61,13 +83,26 @@ public class FiltreLivresTests
[InlineData(Statut.EnCours, 1)] [InlineData(Statut.EnCours, 1)]
[InlineData(Statut.Lu, 2)] [InlineData(Statut.Lu, 2)]
public void Le_filtre_statut_ne_garde_que_ce_statut(Statut statut, int attendu) public void Le_filtre_statut_ne_garde_que_ce_statut(Statut statut, int attendu)
{ => Assert.Equal(attendu, Filtrer(new CritereLivres { Statut = statut }).Count);
var resultat = Filtrer(new CritereLivres { Statut = statut });
Assert.Equal(attendu, resultat.Count); /// <summary>
Assert.All(resultat, l => Assert.Equal(statut, l.Statut)); /// <b>Le statut est personnel.</b> Le même filtre, la même bibliothèque, une autre personne :
/// rien ne remonte, parce que « camille » n'a posé aucun statut.
/// </summary>
[Fact]
public void Le_filtre_statut_ne_voit_que_le_statut_de_lappelant()
{
Assert.Equal(2, Filtrer(new CritereLivres { Statut = Statut.Lu }, "mathieu").Count);
Assert.Empty(Filtrer(new CritereLivres { Statut = Statut.Lu }, "camille"));
// Sans filtre, en revanche, camille voit toute la bibliothèque : elle est commune.
Assert.Equal(4, Filtrer(new CritereLivres(), "camille").Count);
} }
[Fact]
public void Sans_identite_aucun_statut_ne_correspond()
=> Assert.Empty(Filtrer(new CritereLivres { Statut = Statut.Lu }, null));
[Fact] [Fact]
public void La_recherche_porte_sur_le_titre() public void La_recherche_porte_sur_le_titre()
=> Assert.Equal(["Germinal"], Filtrer(new CritereLivres { Recherche = "germ" }).Select(l => l.Titre)); => Assert.Equal(["Germinal"], Filtrer(new CritereLivres { Recherche = "germ" }).Select(l => l.Titre));
@@ -78,6 +113,26 @@ public class FiltreLivresTests
["Germinal", "La Bête humaine"], ["Germinal", "La Bête humaine"],
Filtrer(new CritereLivres { Recherche = "zola" }).Select(l => l.Titre)); Filtrer(new CritereLivres { Recherche = "zola" }).Select(l => l.Titre));
/// <summary>
/// Le besoin exprimé dans IDEES.md : « Emile » doit trouver « Émile ». Il n'y a pas de
/// collation SQLite pour ça, seule la colonne normalisée le permet.
/// </summary>
[Theory]
[InlineData("emile")]
[InlineData("Émile")]
[InlineData("EMILE ZOLA")]
[InlineData("zola, émile")]
public void La_recherche_ignore_les_accents_sur_lauteur(string terme)
=> Assert.Equal(
["Germinal", "La Bête humaine"],
Filtrer(new CritereLivres { Recherche = terme }).Select(l => l.Titre));
[Theory]
[InlineData("bete")]
[InlineData("BÊTE")]
public void La_recherche_ignore_les_accents_sur_le_titre(string terme)
=> Assert.Equal(["La Bête humaine"], Filtrer(new CritereLivres { Recherche = terme }).Select(l => l.Titre));
[Theory] [Theory]
[InlineData("HORLA")] [InlineData("HORLA")]
[InlineData("horla")] [InlineData("horla")]
@@ -88,12 +143,18 @@ public class FiltreLivresTests
[Fact] [Fact]
public void Un_livre_sans_auteur_ne_fait_pas_echouer_la_recherche() public void Un_livre_sans_auteur_ne_fait_pas_echouer_la_recherche()
{ {
// Le livre 4 a Auteur = null : la clause sur l'auteur doit le traverser sans exploser. // Le livre 4 n'a aucun auteur : la clause sur les auteurs doit le traverser sans exploser.
var resultat = Filtrer(new CritereLivres { Recherche = "bel" }); var resultat = Filtrer(new CritereLivres { Recherche = "bel" });
Assert.Equal(["Bel-Ami"], resultat.Select(l => l.Titre)); Assert.Equal(["Bel-Ami"], resultat.Select(l => l.Titre));
} }
[Fact]
public void Le_filtre_par_auteur_regroupe_ses_livres()
=> Assert.Equal(
["Germinal", "La Bête humaine"],
Filtrer(new CritereLivres { AuteurId = Zola.Id }).Select(l => l.Titre));
[Fact] [Fact]
public void Les_criteres_se_combinent() public void Les_criteres_se_combinent()
{ {
@@ -147,4 +208,16 @@ public class FiltreLivresTests
Assert.DoesNotContain(proprietes, nom => nom.Contains("Ajoute", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(proprietes, nom => nom.Contains("Ajoute", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(proprietes, nom => nom.Contains("Utilisateur", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(proprietes, nom => nom.Contains("Utilisateur", StringComparison.OrdinalIgnoreCase));
} }
/// <summary>
/// Troisième verrou, sur la décision inverse : <see cref="Livre"/> ne doit plus jamais porter
/// de colonne <c>Statut</c>, qui redeviendrait commune à tout le foyer.
/// </summary>
[Fact]
public void Livre_ne_porte_aucun_statut_commun()
{
var statut = typeof(Livre).GetProperty("Statut");
Assert.Null(statut);
}
} }
+4 -2
View File
@@ -82,9 +82,11 @@ public class OpenLibraryMapperTests
var candidat = OpenLibraryMapper.VersCandidat(edition, noms, "9780262033848", null)!; var candidat = OpenLibraryMapper.VersCandidat(edition, noms, "9780262033848", null)!;
Assert.Equal("Introduction to Algorithms", candidat.Titre); Assert.Equal("Introduction to Algorithms", candidat.Titre);
// Les quatre auteurs restent quatre entrées distinctes : les recoller en une chaîne
// rendrait impossible de les distinguer d'un « Zola, Émile » à la virgule près.
Assert.Equal( Assert.Equal(
"Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein", ["Thomas H. Cormen", "Charles E. Leiserson", "Ronald L. Rivest", "Clifford Stein"],
candidat.Auteur); candidat.Auteurs);
Assert.Equal("The MIT Press", candidat.Editeur); Assert.Equal("The MIT Press", candidat.Editeur);
Assert.Equal("2009", candidat.Annee); Assert.Equal("2009", candidat.Annee);
} }
+198
View File
@@ -0,0 +1,198 @@
using MaBibli.Api.Data;
using MaBibli.Api.Services.Catalogue;
using MaBibli.Shared.Catalogue;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Tests;
/// <summary>
/// Table des auteurs : regroupement automatique, suggestions et fusions.
/// </summary>
public class ServiceAuteursTests : IDisposable
{
private readonly SqliteConnection _connexion;
private readonly MaBibliDbContext _db;
private readonly ServiceAuteurs _auteurs;
private readonly ServiceCatalogue _catalogue;
public ServiceAuteursTests()
{
_connexion = new SqliteConnection("Data Source=:memory:");
_connexion.Open();
_db = new MaBibliDbContext(
new DbContextOptionsBuilder<MaBibliDbContext>().UseSqlite(_connexion).Options);
_db.Database.EnsureCreated();
_auteurs = new ServiceAuteurs(_db);
_catalogue = new ServiceCatalogue(_db, _auteurs);
}
private async Task<LivreDto> AjouterAsync(string titre, string auteurs) =>
(await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = titre, Auteur = auteurs }, "mathieu")).Livre!;
[Fact]
public async Task Un_auteur_nest_cree_quune_fois()
{
await AjouterAsync("Germinal", "Émile Zola");
await AjouterAsync("Nana", "Émile Zola");
var liste = await _auteurs.ListerAsync();
var zola = Assert.Single(liste);
Assert.Equal("Émile Zola", zola.Nom);
Assert.Equal(2, zola.NombreDeLivres);
}
[Fact]
public async Task Le_nom_daffichage_reste_celui_de_la_premiere_saisie()
{
await AjouterAsync("Germinal", "Émile Zola");
await AjouterAsync("Nana", "ZOLA, emile");
// Les deux livres pointent la même fiche, sous le nom d'origine : l'accentuation correcte
// ne doit pas être écrasée par une saisie plus négligée.
Assert.Equal("Émile Zola", Assert.Single(await _auteurs.ListerAsync()).Nom);
}
[Fact]
public async Task Aucune_suggestion_sur_des_auteurs_sans_rapport()
{
await AjouterAsync("Germinal", "Émile Zola");
await AjouterAsync("Le Horla", "Guy de Maupassant");
Assert.Empty(await _auteurs.SuggestionsAsync());
}
[Fact]
public async Task Un_patronyme_seul_est_propose_vers_le_nom_complet()
{
await AjouterAsync("Pandora's Star", "Peter F. Hamilton");
await AjouterAsync("Un autre", "Hamilton");
var suggestion = Assert.Single(await _auteurs.SuggestionsAsync());
// La fusion efface la source : c'est le nom le moins complet qui doit disparaître.
Assert.Equal("Hamilton", suggestion.Source.Nom);
Assert.Equal("Peter F. Hamilton", suggestion.Cible.Nom);
}
[Fact]
public async Task Accepter_un_rapprochement_reunit_les_livres()
{
var complet = await AjouterAsync("Pandora's Star", "Peter F. Hamilton");
var court = await AjouterAsync("Judas Unchained", "Hamilton");
var suggestion = Assert.Single(await _auteurs.SuggestionsAsync());
Assert.True(await _auteurs.FusionnerAsync(suggestion.Source.Id, suggestion.Cible.Id));
var restant = Assert.Single(await _auteurs.ListerAsync());
Assert.Equal("Peter F. Hamilton", restant.Nom);
Assert.Equal(2, restant.NombreDeLivres);
// Les deux livres remontent maintenant sous le même auteur.
var groupe = await _catalogue.ListerAsync(new CritereLivres { AuteurId = restant.Id }, "mathieu");
Assert.Equal([complet.Id, court.Id], groupe.Select(l => l.Id).Order());
// Et la suggestion ne peut plus revenir : l'une des deux fiches n'existe plus.
Assert.Empty(await _auteurs.SuggestionsAsync());
}
[Fact]
public async Task Refuser_un_rapprochement_le_fait_taire_definitivement()
{
await AjouterAsync("Pandora's Star", "Peter F. Hamilton");
await AjouterAsync("Un autre", "Hamilton");
var suggestion = Assert.Single(await _auteurs.SuggestionsAsync());
Assert.True(await _auteurs.RefuserAsync(suggestion.Source.Id, suggestion.Cible.Id));
Assert.Empty(await _auteurs.SuggestionsAsync());
// Les deux auteurs restent bien distincts : refuser n'est pas fusionner.
Assert.Equal(2, (await _auteurs.ListerAsync()).Count);
}
[Fact]
public async Task Un_refus_vaut_dans_les_deux_sens()
{
await AjouterAsync("Pandora's Star", "Peter F. Hamilton");
await AjouterAsync("Un autre", "Hamilton");
var suggestion = Assert.Single(await _auteurs.SuggestionsAsync());
// Refus enregistré dans l'ordre inverse de celui proposé : il doit compter quand même.
Assert.True(await _auteurs.RefuserAsync(suggestion.Cible.Id, suggestion.Source.Id));
Assert.Empty(await _auteurs.SuggestionsAsync());
// Idempotent : redire non ne doit pas empiler les lignes.
Assert.True(await _auteurs.RefuserAsync(suggestion.Source.Id, suggestion.Cible.Id));
Assert.Equal(1, await _db.RapprochementsRefuses.CountAsync());
}
[Fact]
public async Task Fusionner_un_livre_cosigne_ne_cree_pas_de_lien_en_double()
{
// Le même livre signé par les deux variantes : après fusion il ne doit rester qu'un lien.
var livre = await AjouterAsync("Œuvre commune", "Peter F. Hamilton; Hamilton");
Assert.Equal(2, livre.Auteurs.Count);
var suggestion = Assert.Single(await _auteurs.SuggestionsAsync());
Assert.True(await _auteurs.FusionnerAsync(suggestion.Source.Id, suggestion.Cible.Id));
var relu = await _catalogue.ObtenirAsync(livre.Id, "mathieu");
Assert.Single(relu!.Auteurs);
Assert.Equal(1, await _db.LivreAuteurs.CountAsync());
}
[Fact]
public async Task Fusionner_avec_un_auteur_inexistant_echoue_sans_rien_casser()
{
var livre = await AjouterAsync("Germinal", "Émile Zola");
var zola = livre.Auteurs.Single().Id;
Assert.False(await _auteurs.FusionnerAsync(zola, 4242));
Assert.False(await _auteurs.FusionnerAsync(zola, zola));
Assert.Equal(1, await _db.Auteurs.CountAsync());
}
[Fact]
public async Task Retirer_un_auteur_dun_livre_supprime_sa_fiche_devenue_vide()
{
var livre = await AjouterAsync("Germinal", "Émile Zola");
await _catalogue.ModifierAsync(
livre.Id,
new EnregistrementLivre { Titre = "Germinal", Auteur = "Guy de Maupassant" },
"mathieu");
var restant = Assert.Single(await _auteurs.ListerAsync());
Assert.Equal("Guy de Maupassant", restant.Nom);
}
[Fact]
public async Task Une_saisie_sans_lettre_ne_cree_pas_dauteur()
{
await AjouterAsync("Sans auteur", " ; ; .,; ");
Assert.Empty(await _auteurs.ListerAsync());
}
[Fact]
public async Task Le_meme_auteur_saisi_deux_fois_ne_donne_quun_lien()
{
var livre = await AjouterAsync("Germinal", "Émile Zola; ZOLA, Emile");
Assert.Single(livre.Auteurs);
}
public void Dispose()
{
_db.Dispose();
_connexion.Dispose();
GC.SuppressFinalize(this);
}
}
+156 -18
View File
@@ -31,7 +31,7 @@ public class ServiceCatalogueTests : IDisposable
_db = new MaBibliDbContext(options); _db = new MaBibliDbContext(options);
_db.Database.EnsureCreated(); _db.Database.EnsureCreated();
_service = new ServiceCatalogue(_db); _service = new ServiceCatalogue(_db, new ServiceAuteurs(_db));
} }
private static EnregistrementLivre Saisie( private static EnregistrementLivre Saisie(
@@ -39,7 +39,7 @@ public class ServiceCatalogueTests : IDisposable
string? auteur = null, string? auteur = null,
string? isbn = null, string? isbn = null,
Format format = Format.Physique, Format format = Format.Physique,
Statut statut = Statut.ALire) => new() Statut? statut = null) => new()
{ {
Titre = titre, Titre = titre,
Auteur = auteur, Auteur = auteur,
@@ -58,6 +58,7 @@ public class ServiceCatalogueTests : IDisposable
Assert.True(livre.Id > 0); Assert.True(livre.Id > 0);
Assert.Equal("Germinal", livre.Titre); Assert.Equal("Germinal", livre.Titre);
Assert.Equal("mathieu", livre.AjoutePar); Assert.Equal("mathieu", livre.AjoutePar);
Assert.Equal(["Émile Zola"], livre.Auteurs.Select(a => a.Nom));
Assert.NotEqual(default, livre.DateAjout); Assert.NotEqual(default, livre.DateAjout);
} }
@@ -97,10 +98,10 @@ public class ServiceCatalogueTests : IDisposable
await _service.CreerAsync(Saisie("Papier", format: Format.Physique), "mathieu"); await _service.CreerAsync(Saisie("Papier", format: Format.Physique), "mathieu");
await _service.CreerAsync(Saisie("Epub", format: Format.Numerique), "mathieu"); await _service.CreerAsync(Saisie("Epub", format: Format.Numerique), "mathieu");
var tout = await _service.ListerAsync(new CritereLivres()); var tout = await _service.ListerAsync(new CritereLivres(), "mathieu");
Assert.Equal(2, tout.Count); Assert.Equal(2, tout.Count);
var numeriques = await _service.ListerAsync(new CritereLivres { Format = Format.Numerique }); var numeriques = await _service.ListerAsync(new CritereLivres { Format = Format.Numerique }, "mathieu");
Assert.Equal(["Epub"], numeriques.Select(l => l.Titre)); Assert.Equal(["Epub"], numeriques.Select(l => l.Titre));
} }
@@ -110,13 +111,31 @@ public class ServiceCatalogueTests : IDisposable
await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu"); await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu");
await _service.CreerAsync(Saisie("Le Horla", "Guy de Maupassant"), "mathieu"); await _service.CreerAsync(Saisie("Le Horla", "Guy de Maupassant"), "mathieu");
var parAuteur = await _service.ListerAsync(new CritereLivres { Recherche = "maupassant" }); var parAuteur = await _service.ListerAsync(new CritereLivres { Recherche = "maupassant" }, "mathieu");
Assert.Equal(["Le Horla"], parAuteur.Select(l => l.Titre)); Assert.Equal(["Le Horla"], parAuteur.Select(l => l.Titre));
var parTitre = await _service.ListerAsync(new CritereLivres { Recherche = "GERMI" }); var parTitre = await _service.ListerAsync(new CritereLivres { Recherche = "GERMI" }, "mathieu");
Assert.Equal(["Germinal"], parTitre.Select(l => l.Titre)); Assert.Equal(["Germinal"], parTitre.Select(l => l.Titre));
} }
/// <summary>
/// La recherche sans accents doit traverser SQLite, pas seulement LINQ-to-Objects : c'est là
/// que la colonne normalisée gagne son existence.
/// </summary>
[Theory]
[InlineData("emile")]
[InlineData("EMILE")]
[InlineData("Émile Zola")]
public async Task La_recherche_sans_accents_traverse_bien_SQLite(string terme)
{
await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu");
await _service.CreerAsync(Saisie("Le Horla", "Guy de Maupassant"), "mathieu");
var resultat = await _service.ListerAsync(new CritereLivres { Recherche = terme }, "mathieu");
Assert.Equal(["Germinal"], resultat.Select(l => l.Titre));
}
/// <summary> /// <summary>
/// <b>Invariant central</b> : la bibliothèque est commune. Des livres saisis par des comptes /// <b>Invariant central</b> : la bibliothèque est commune. Des livres saisis par des comptes
/// différents doivent tous ressortir, quelle que soit la personne qui consulte. /// différents doivent tous ressortir, quelle que soit la personne qui consulte.
@@ -128,7 +147,7 @@ public class ServiceCatalogueTests : IDisposable
await _service.CreerAsync(Saisie("Livre de Camille"), "camille"); await _service.CreerAsync(Saisie("Livre de Camille"), "camille");
await _service.CreerAsync(Saisie("Livre anonyme"), null); await _service.CreerAsync(Saisie("Livre anonyme"), null);
var tout = await _service.ListerAsync(new CritereLivres()); var tout = await _service.ListerAsync(new CritereLivres(), "mathieu");
Assert.Equal(3, tout.Count); Assert.Equal(3, tout.Count);
Assert.Contains(tout, l => l.AjoutePar == "mathieu"); Assert.Contains(tout, l => l.AjoutePar == "mathieu");
@@ -139,19 +158,20 @@ public class ServiceCatalogueTests : IDisposable
[Fact] [Fact]
public async Task Modifier_ne_reecrit_ni_AjoutePar_ni_DateAjout() public async Task Modifier_ne_reecrit_ni_AjoutePar_ni_DateAjout()
{ {
var cree = (await _service.CreerAsync(Saisie("Titre initial", "Auteur"), "camille")).Livre!; var cree = (await _service.CreerAsync(Saisie("Titre initial", "Auteur Initial"), "camille")).Livre!;
var modifie = await _service.ModifierAsync(cree.Id, new EnregistrementLivre var modifie = await _service.ModifierAsync(cree.Id, new EnregistrementLivre
{ {
Titre = "Titre corrigé", Titre = "Titre corrigé",
Auteur = "Auteur corrigé", Auteur = "Auteur Corrigé",
Format = Format.Numerique, Format = Format.Numerique,
Statut = Statut.Lu, Statut = Statut.Lu,
}); }, "camille");
Assert.Null(modifie.Erreur); Assert.Null(modifie.Erreur);
Assert.Equal("Titre corrigé", modifie.Livre!.Titre); Assert.Equal("Titre corrigé", modifie.Livre!.Titre);
Assert.Equal(Format.Numerique, modifie.Livre.Format); Assert.Equal(Format.Numerique, modifie.Livre.Format);
Assert.Equal(["Auteur Corrigé"], modifie.Livre.Auteurs.Select(a => a.Nom));
// La trace de la saisie d'origine survit à l'édition, y compris par quelqu'un d'autre. // La trace de la saisie d'origine survit à l'édition, y compris par quelqu'un d'autre.
Assert.Equal("camille", modifie.Livre.AjoutePar); Assert.Equal("camille", modifie.Livre.AjoutePar);
@@ -161,7 +181,7 @@ public class ServiceCatalogueTests : IDisposable
[Fact] [Fact]
public async Task Modifier_un_livre_inexistant_est_signale_comme_introuvable() public async Task Modifier_un_livre_inexistant_est_signale_comme_introuvable()
{ {
var resultat = await _service.ModifierAsync(4242, Saisie("Fantôme")); var resultat = await _service.ModifierAsync(4242, Saisie("Fantôme"), "mathieu");
Assert.True(resultat.EstIntrouvable); Assert.True(resultat.EstIntrouvable);
} }
@@ -171,43 +191,161 @@ public class ServiceCatalogueTests : IDisposable
{ {
var cree = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!; var cree = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!;
var apres = await _service.ChangerStatutAsync(cree.Id, Statut.EnCours); var apres = await _service.ChangerStatutAsync(cree.Id, Statut.EnCours, "mathieu");
Assert.NotNull(apres); Assert.NotNull(apres);
Assert.Equal(Statut.EnCours, apres!.Statut); Assert.Equal(Statut.EnCours, apres!.Statut);
Assert.Equal(cree.Titre, apres.Titre); Assert.Equal(cree.Titre, apres.Titre);
Assert.Equal(cree.Auteur, apres.Auteur); Assert.Equal(cree.Auteurs.Select(a => a.Nom), apres.Auteurs.Select(a => a.Nom));
Assert.Equal(cree.AjoutePar, apres.AjoutePar); Assert.Equal(cree.AjoutePar, apres.AjoutePar);
} }
/// <summary>
/// <b>La décision de cette phase, vérifiée de bout en bout.</b> Un seul livre, deux personnes,
/// deux statuts qui ne se marchent pas dessus.
/// </summary>
[Fact]
public async Task Deux_utilisateurs_ont_des_statuts_differents_sur_le_meme_livre()
{
var livre = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!;
await _service.ChangerStatutAsync(livre.Id, Statut.Lu, "mathieu");
await _service.ChangerStatutAsync(livre.Id, Statut.EnCours, "camille");
Assert.Equal(Statut.Lu, (await _service.ObtenirAsync(livre.Id, "mathieu"))!.Statut);
Assert.Equal(Statut.EnCours, (await _service.ObtenirAsync(livre.Id, "camille"))!.Statut);
// Une troisième personne n'a rien posé : pour elle le livre est « non commencé ».
Assert.Null((await _service.ObtenirAsync(livre.Id, "claire"))!.Statut);
// Une seule ligne par personne : rien ne s'empile à force de changer d'avis.
Assert.Equal(2, await _db.StatutsLecture.CountAsync(s => s.LivreId == livre.Id));
}
[Fact]
public async Task Un_livre_cree_sans_statut_nen_ecrit_aucun()
{
var livre = (await _service.CreerAsync(Saisie("Germinal"), "mathieu")).Livre!;
Assert.Null((await _service.ObtenirAsync(livre.Id, "mathieu"))!.Statut);
Assert.Empty(_db.StatutsLecture);
}
[Fact]
public async Task Un_statut_nul_remet_le_livre_a_non_commence()
{
var livre = (await _service.CreerAsync(Saisie("Germinal", statut: Statut.Lu), "mathieu")).Livre!;
Assert.Equal(Statut.Lu, livre.Statut);
var apres = await _service.ChangerStatutAsync(livre.Id, null, "mathieu");
Assert.Null(apres!.Statut);
Assert.Empty(_db.StatutsLecture);
}
/// <summary>Éditer un livre pose MON statut, jamais celui de la personne qui l'a saisi.</summary>
[Fact]
public async Task Modifier_un_livre_ne_touche_pas_au_statut_des_autres()
{
var livre = (await _service.CreerAsync(Saisie("Germinal", statut: Statut.Lu), "mathieu")).Livre!;
await _service.ModifierAsync(
livre.Id, Saisie("Germinal", statut: Statut.EnCours), "camille");
Assert.Equal(Statut.Lu, (await _service.ObtenirAsync(livre.Id, "mathieu"))!.Statut);
Assert.Equal(Statut.EnCours, (await _service.ObtenirAsync(livre.Id, "camille"))!.Statut);
}
[Fact] [Fact]
public async Task Changer_le_statut_dun_livre_inexistant_renvoie_null() public async Task Changer_le_statut_dun_livre_inexistant_renvoie_null()
=> Assert.Null(await _service.ChangerStatutAsync(4242, Statut.Lu)); => Assert.Null(await _service.ChangerStatutAsync(4242, Statut.Lu, "mathieu"));
[Fact]
public async Task Sans_identite_aucun_statut_nest_ecrit()
{
var livre = (await _service.CreerAsync(Saisie("Anonyme", statut: Statut.Lu), null)).Livre!;
Assert.Null(livre.Statut);
Assert.Empty(_db.StatutsLecture);
}
[Fact]
public async Task Un_livre_peut_avoir_plusieurs_auteurs_dans_lordre()
{
var livre = (await _service.CreerAsync(
Saisie("Introduction to Algorithms",
"Thomas H. Cormen; Charles E. Leiserson; Ronald L. Rivest; Clifford Stein"),
"mathieu")).Livre!;
Assert.Equal(
["Thomas H. Cormen", "Charles E. Leiserson", "Ronald L. Rivest", "Clifford Stein"],
livre.Auteurs.Select(a => a.Nom));
}
[Fact]
public async Task Les_variantes_sures_dun_auteur_partagent_la_meme_fiche()
{
var a = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!;
var b = (await _service.CreerAsync(Saisie("Nana", "Zola, Emile"), "mathieu")).Livre!;
var c = (await _service.CreerAsync(Saisie("La Curée", "E. Zola"), "mathieu")).Livre!;
var id = a.Auteurs.Single().Id;
Assert.Equal(id, b.Auteurs.Single().Id);
Assert.Equal(id, c.Auteurs.Single().Id);
Assert.Equal(1, await _db.Auteurs.CountAsync());
}
[Fact]
public async Task Un_patronyme_seul_ne_se_fond_pas_dans_un_nom_complet()
{
var complet = (await _service.CreerAsync(
Saisie("Pandora's Star", "Peter F. Hamilton"), "mathieu")).Livre!;
var seul = (await _service.CreerAsync(Saisie("Autre livre", "Hamilton"), "mathieu")).Livre!;
// Deux fiches distinctes : le rapprochement est ambigu, il sera proposé, pas appliqué.
Assert.NotEqual(complet.Auteurs.Single().Id, seul.Auteurs.Single().Id);
Assert.Equal(2, await _db.Auteurs.CountAsync());
}
[Fact] [Fact]
public async Task Supprimer_retire_le_livre_du_catalogue() public async Task Supprimer_retire_le_livre_du_catalogue()
{ {
var cree = (await _service.CreerAsync(Saisie("À jeter"), "mathieu")).Livre!; var cree = (await _service.CreerAsync(Saisie("À jeter", "Auteur Jetable"), "mathieu")).Livre!;
Assert.True(await _service.SupprimerAsync(cree.Id)); Assert.True(await _service.SupprimerAsync(cree.Id));
Assert.Null(await _service.ObtenirAsync(cree.Id)); Assert.Null(await _service.ObtenirAsync(cree.Id, "mathieu"));
Assert.Empty(await _service.ListerAsync(new CritereLivres())); Assert.Empty(await _service.ListerAsync(new CritereLivres(), "mathieu"));
// L'auteur n'a plus aucun livre : il ne doit pas rester à polluer l'écran des auteurs.
Assert.Empty(_db.Auteurs);
// Seconde suppression : plus rien à supprimer. // Seconde suppression : plus rien à supprimer.
Assert.False(await _service.SupprimerAsync(cree.Id)); Assert.False(await _service.SupprimerAsync(cree.Id));
} }
[Fact]
public async Task Un_auteur_encore_lu_par_un_autre_livre_survit_a_une_suppression()
{
var premier = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!;
await _service.CreerAsync(Saisie("Nana", "Émile Zola"), "mathieu");
await _service.SupprimerAsync(premier.Id);
Assert.Equal(1, await _db.Auteurs.CountAsync());
}
[Fact] [Fact]
public async Task Obtenir_renvoie_la_fiche_complete() public async Task Obtenir_renvoie_la_fiche_complete()
{ {
var cree = (await _service.CreerAsync( var cree = (await _service.CreerAsync(
Saisie("Le Horla", "Guy de Maupassant", "9782080704092"), "mathieu")).Livre!; Saisie("Le Horla", "Guy de Maupassant", "9782080704092"), "mathieu")).Livre!;
var relu = await _service.ObtenirAsync(cree.Id); var relu = await _service.ObtenirAsync(cree.Id, "mathieu");
Assert.NotNull(relu); Assert.NotNull(relu);
Assert.Equal("Le Horla", relu!.Titre); Assert.Equal("Le Horla", relu!.Titre);
Assert.Equal("9782080704092", relu.Isbn); Assert.Equal("9782080704092", relu.Isbn);
Assert.Equal(["Guy de Maupassant"], relu.Auteurs.Select(a => a.Nom));
} }
public void Dispose() public void Dispose()
@@ -0,0 +1,195 @@
using MaBibli.Api.Data;
using MaBibli.Shared.Entites;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace MaBibli.Tests;
/// <summary>
/// Rattrapage des colonnes normalisées après une migration.
/// </summary>
/// <remarks>
/// Les données sont écrites <b>telles que la migration SQL les laisse</b> : normalisation
/// approchée (<c>lower()</c> ne touche pas aux accents), donc variantes d'un même auteur encore
/// éclatées. C'est exactement l'état que ce service doit savoir rattraper.
/// </remarks>
public class ServiceRenormalisationTests : IDisposable
{
private readonly SqliteConnection _connexion;
private readonly MaBibliDbContext _db;
public ServiceRenormalisationTests()
{
_connexion = new SqliteConnection("Data Source=:memory:");
_connexion.Open();
_db = new MaBibliDbContext(
new DbContextOptionsBuilder<MaBibliDbContext>().UseSqlite(_connexion).Options);
_db.Database.EnsureCreated();
}
private Task ExecuterAsync() =>
new ServiceRenormalisation(_db, NullLogger<ServiceRenormalisation>.Instance).ExecuterAsync();
/// <summary>Écrit un auteur comme la migration le ferait : formes en <c>lower()</c> brut.</summary>
private Auteur AjouterHerite(string nom, params string[] titres)
{
var auteur = new Auteur
{
Nom = nom,
NomNormalise = nom.ToLowerInvariant(),
CleRegroupement = nom.ToLowerInvariant(),
};
_db.Auteurs.Add(auteur);
foreach (var titre in titres)
{
var livre = new Livre { Titre = titre, TitreNormalise = titre.ToLowerInvariant() };
_db.Livres.Add(livre);
livre.Auteurs.Add(new LivreAuteur { Auteur = auteur });
}
_db.SaveChanges();
return auteur;
}
[Fact]
public async Task Les_titres_perdent_leurs_accents_dans_la_colonne_normalisee()
{
AjouterHerite("Émile Zola", "La Bête humaine");
await ExecuterAsync();
Assert.Equal("la bete humaine", _db.Livres.Single().TitreNormalise);
}
[Fact]
public async Task Les_variantes_daccent_et_dordre_sont_reunies()
{
AjouterHerite("Emile Zola", "Germinal");
AjouterHerite("Émile Zola", "Nana");
AjouterHerite("ZOLA, Émile", "La Curée");
await ExecuterAsync();
var zola = Assert.Single(_db.Auteurs);
Assert.Equal(3, await _db.LivreAuteurs.CountAsync(la => la.AuteurId == zola.Id));
}
/// <summary>
/// Le nom qui survit est celui qui se lit le mieux. Sans arbitrage, c'est l'ordre — arbitraire —
/// des identifiants issus de la migration qui déciderait, et la bibliothèque perdrait ses
/// accents au profit de la saisie la plus négligée.
/// </summary>
[Theory]
// L'ordre est celui des identifiants après migration : le résultat doit en être indépendant.
[InlineData("Emile Zola|Émile Zola")]
[InlineData("Émile Zola|Emile Zola")]
[InlineData("EMILE ZOLA|ZOLA, Émile|Émile Zola")]
[InlineData("ZOLA, Émile|Émile Zola")]
public async Task Le_nom_conserve_est_le_plus_presentable(string variantes)
{
foreach (var variante in variantes.Split('|'))
{
AjouterHerite(variante, $"Livre de {variante}");
}
await ExecuterAsync();
Assert.Equal("Émile Zola", Assert.Single(_db.Auteurs).Nom);
}
/// <summary>
/// Les initiales échappent à la clé de regroupement. Sans second tour, une base héritée
/// resterait éclatée là où une saisie neuve aurait été réunie d'emblée.
/// </summary>
[Fact]
public async Task Les_initiales_abregees_sont_reunies_sous_le_nom_le_plus_complet()
{
AjouterHerite("P.F. Hamilton", "Pandora's Star");
AjouterHerite("Peter F. Hamilton", "Judas Unchained");
await ExecuterAsync();
var hamilton = Assert.Single(_db.Auteurs);
Assert.Equal("Peter F. Hamilton", hamilton.Nom);
Assert.Equal(2, await _db.LivreAuteurs.CountAsync());
}
/// <summary>Le cas ambigu reste ambigu : il sera proposé, pas appliqué.</summary>
[Fact]
public async Task Un_patronyme_seul_nest_jamais_absorbe()
{
AjouterHerite("Hamilton", "Great North Road");
AjouterHerite("Peter F. Hamilton", "Pandora's Star");
await ExecuterAsync();
Assert.Equal(2, await _db.Auteurs.CountAsync());
}
[Fact]
public async Task Un_livre_cosigne_par_deux_variantes_ne_garde_quun_lien()
{
var livre = new Livre { Titre = "Œuvre commune", TitreNormalise = "œuvre commune" };
_db.Livres.Add(livre);
_db.SaveChanges();
foreach (var nom in new[] { "Emile Zola", "Émile Zola" })
{
var auteur = new Auteur
{
Nom = nom,
NomNormalise = nom.ToLowerInvariant(),
CleRegroupement = nom.ToLowerInvariant(),
};
_db.Auteurs.Add(auteur);
_db.LivreAuteurs.Add(new LivreAuteur { LivreId = livre.Id, Auteur = auteur });
}
_db.SaveChanges();
await ExecuterAsync();
Assert.Single(_db.Auteurs);
Assert.Equal(1, await _db.LivreAuteurs.CountAsync());
}
[Fact]
public async Task Un_second_passage_ne_change_plus_rien()
{
AjouterHerite("Emile Zola", "Germinal");
AjouterHerite("Émile Zola", "Nana");
await ExecuterAsync();
var apresPremier = _db.Auteurs.Single();
var nom = apresPremier.Nom;
var cle = apresPremier.CleRegroupement;
await ExecuterAsync();
var apresSecond = Assert.Single(_db.Auteurs);
Assert.Equal(nom, apresSecond.Nom);
Assert.Equal(cle, apresSecond.CleRegroupement);
Assert.Equal(2, await _db.LivreAuteurs.CountAsync());
}
[Fact]
public async Task Une_base_vide_ne_pose_aucun_probleme()
{
await ExecuterAsync();
Assert.Empty(_db.Auteurs);
Assert.Empty(_db.Livres);
}
public void Dispose()
{
_db.Dispose();
_connexion.Dispose();
GC.SuppressFinalize(this);
}
}