Fusionner la phase base de donnees (statut par utilisateur, table Auteur)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-18 02:24:09 +02:00
co-authored by Claude Opus 5
38 changed files with 3377 additions and 168 deletions
+51 -4
View File
@@ -310,20 +310,47 @@ Compilation **locale**, puis dépôt manuel de l'archive en release sur le Gitea
**Ne jamais compiler sur le serveur** à l'installation : cela imposerait le SDK .NET complet sur la machine YunoHost, pour une compilation lente — l'inverse exact de ce que permet le self-contained.
## Modèle de données (base de départ, à affiner)
## Modèle de données
Schéma en vigueur depuis la migration `StatutPersonnelEtTableAuteurs` (2026-08-18).
```
Livre
├── Id
├── Isbn
├── Isbn (index)
├── Titre
├── Auteur
├── TitreNormalise (index — minuscules sans accents, recalculé à chaque écriture)
├── Editeur
├── Format : Physique | Numerique
├── Statut : ALire | EnCours | Lu
├── CoverUrl
├── DateAjout
└── AjoutePar (YNH_USER — traçabilité, PAS un cloisonnement)
⚠ pas de colonne Statut, pas de colonne Auteur
Auteur
├── Id
├── Nom (affichage : « Émile Zola »)
├── NomNormalise (index — recherche : « emile zola »)
└── CleRegroupement (index UNIQUE — mots normalisés triés : « emile zola »)
LivreAuteur (n-n : un livre peut avoir plusieurs auteurs)
├── LivreId ─┐ clé primaire composite
├── AuteurId ─┘ FK Livre = cascade, FK Auteur = restrict
└── Position (rang sur la couverture, à partir de 0)
StatutLecture (le statut est PERSONNEL)
├── Id
├── LivreId (FK vers Livre, cascade)
├── Utilisateur (YNH_USER)
├── Statut : ALire | EnCours | Lu
├── DateMaj
└── UNIQUE (LivreId, Utilisateur)
absence de ligne = « non commencé »
RapprochementRefuse (mémoire des « non » de l'utilisateur)
├── Id
├── AuteurAId ─┐ UNIQUE, couple toujours rangé (petit Id d'abord)
└── AuteurBId ─┘ pour qu'un refus vaille dans les deux sens
Pret
├── Id
@@ -341,6 +368,26 @@ Garder `Pret` comme table séparée (pas un champ sur `Livre`) pour conserver l'
**L'auteur n'est plus un champ texte sur `Livre`.** Une table dédiée porte le nom d'affichage et une forme normalisée servant au regroupement et à la recherche. Le regroupement automatique ne s'applique qu'aux variantes **sûres** (casse, accents, initiales, ordre nom/prénom) ; les rapprochements ambigus — « Hamilton » seul vers « Peter F. Hamilton » — doivent être **proposés**, jamais appliqués silencieusement : une fusion erronée est difficile à défaire.
### Recherche sans accents : pourquoi des colonnes normalisées
**SQLite ne sait pas ignorer les accents.** Sa collation `NOCASE` ne couvre que l'ASCII et il n'existe pas de `unaccent()` intégré. La seule façon d'obtenir « Emile » → « Émile » sans charger tout le catalogue en mémoire est de stocker la forme mise à plat dans une colonne dédiée, et d'y chercher un terme passé par **exactement la même** fonction (`NormalisationTexte`).
Conséquence à ne pas oublier : **toute écriture doit recalculer ces colonnes** (`Livre.RecalculerFormes()`, `Auteur.RecalculerFormes()`).
Deux formes cohabitent sur `Auteur`, et ce n'est pas une redondance :
- `NomNormalise` garde l'ordre de saisie — c'est ce que la recherche parcourt en sous-chaîne ;
- `CleRegroupement` trie les mots, ce qui rend `Zola, Émile` et `Émile Zola` identiques. C'est elle qui porte l'index **unique**, donc l'invariant « un auteur, une fiche ».
La clé ne sait pas rapprocher les **initiales** (`P.F. Hamilton``Peter F. Hamilton`) : cela demande d'aligner les mots un à un, ce qui n'a pas d'équivalent SQL. Ce cas est traité en mémoire par `RapprochementAuteurs.Comparer`, sur une table qui compte au plus quelques centaines de lignes.
### Reprise des données existantes
La migration recopie l'ancien contenu avec les moyens du bord, puis **`ServiceRenormalisation` finit le travail en C# au démarrage** — idempotent, il sert aussi de filet si les règles de normalisation changent un jour.
- L'ancien `Livres.Statut`, commun à tout le foyer, est **rattaché à `AjoutePar`**, seule personne que la base associe au livre. Les statuts des livres sans `AjoutePar` sont **perdus** : les attribuer serait une invention.
- L'ancien `Livres.Auteur` devient une fiche par valeur distincte. SQLite ne regroupant qu'à la casse près, les variantes accentuées ressortent en double — c'est le passage C# qui les réunit, en gardant le nom d'affichage le plus présentable (accents conservés, ni `NOM, Prénom` ni capitales intégrales).
**Les prêts ne concernent en pratique que les livres physiques** — les ebooks étant de simples fiches, il n'y a pas d'objet à prêter. `Emprunteur` reste un **texte libre**, sans lien avec les comptes YunoHost : on suit les prêts à des personnes extérieures au foyer, pas les échanges entre utilisateurs de l'app.
## Historique du projet (pourquoi ces choix)
+67
View File
@@ -7,6 +7,14 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
{
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>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -16,13 +24,72 @@ public class MaBibliDbContext(DbContextOptions<MaBibliDbContext> options) : DbCo
modelBuilder.Entity<Livre>(livre =>
{
livre.Property(l => l.Titre).IsRequired();
livre.Property(l => l.TitreNormalise).IsRequired();
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)
.WithOne(p => p.Livre)
.HasForeignKey(p => p.LivreId)
.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 =>
{
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
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")
@@ -26,9 +54,6 @@ namespace MaBibli.Api.Data.Migrations
b.Property<string>("AjoutePar")
.HasColumnType("TEXT");
b.Property<string>("Auteur")
.HasColumnType("TEXT");
b.Property<string>("CoverUrl")
.HasColumnType("TEXT");
@@ -44,20 +69,41 @@ namespace MaBibli.Api.Data.Migrations
b.Property<string>("Isbn")
.HasColumnType("TEXT");
b.Property<int>("Statut")
.HasColumnType("INTEGER");
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")
@@ -84,6 +130,74 @@ namespace MaBibli.Api.Data.Migrations
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")
@@ -95,9 +209,29 @@ namespace MaBibli.Api.Data.Migrations
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
}
+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.
/// 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.
/// <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>
public static IEndpointRouteBuilder MapLivresEndpoints(this IEndpointRouteBuilder routes)
{
@@ -24,7 +29,9 @@ public static class LivresEndpoints
string? recherche,
Format? format,
Statut? statut,
int? auteurId,
IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var criteres = new CritereLivres
@@ -32,20 +39,28 @@ public static class LivresEndpoints
Recherche = recherche,
Format = format,
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")
.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(
"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>>();
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);
})
.WithName("ObtenirLivre")
@@ -75,9 +90,11 @@ public static class LivresEndpoints
int id,
EnregistrementLivre saisie,
IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var resultat = await service.ModifierAsync(id, saisie, ct);
var resultat = await service.ModifierAsync(
id, saisie, utilisateurs.Obtenir().Identifiant, ct);
if (resultat.EstIntrouvable)
{
@@ -89,7 +106,7 @@ public static class LivresEndpoints
: Results.Ok(resultat.Livre);
})
.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(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
@@ -98,13 +115,17 @@ public static class LivresEndpoints
int id,
ChangementStatut changement,
IServiceCatalogue service,
IFournisseurUtilisateur utilisateurs,
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);
})
.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(StatusCodes.Status404NotFound);
@@ -120,6 +141,65 @@ public static class LivresEndpoints
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>
public static IEndpointRouteBuilder MapIdentiteEndpoints(this IEndpointRouteBuilder routes)
{
+11 -1
View File
@@ -12,6 +12,7 @@ builder.Services.AddOpenApi();
builder.Services.AddDbContext<MaBibliDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("MaBibli")));
builder.Services.AddScoped<IServiceAuteurs, ServiceAuteurs>();
builder.Services.AddScoped<IServiceCatalogue, ServiceCatalogue>();
// 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.
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())
@@ -60,6 +69,7 @@ app.UseStaticFiles();
app.MapIsbnEndpoints();
app.MapLivresEndpoints();
app.MapAuteursEndpoints();
app.MapIdentiteEndpoints();
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
{
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);
}
@@ -39,27 +43,48 @@ public interface IServiceCatalogue
/// CRUD du catalogue de livres — physiques et numériques confondus, distingués par
/// <see cref="Format"/>. Il n'y a qu'un seul catalogue.
/// </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(
CritereLivres criteres, CancellationToken ct = default)
CritereLivres criteres, string? utilisateur, CancellationToken ct = default)
{
// AsNoTracking : lecture pure. Et surtout : AUCUN filtre sur AjoutePar,
// 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);
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);
return livre is null ? null : Projeter(livre);
var livre = await db.Livres
.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(
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)
{
@@ -69,28 +94,33 @@ public sealed class ServiceCatalogue(MaBibliDbContext db) : IServiceCatalogue
var livre = new Livre
{
Isbn = isbn,
Titre = saisie.Titre.Trim(),
Auteur = Vide(saisie.Auteur),
Titre = saisie.Titre,
Editeur = Vide(saisie.Editeur),
Format = saisie.Format,
Statut = saisie.Statut,
CoverUrl = Vide(saisie.CoverUrl),
DateAjout = DateTime.UtcNow,
// Renseigné par le serveur à partir de l'utilisateur authentifié, jamais par le client.
AjoutePar = ajoutePar,
AjoutePar = utilisateur,
};
livre.RecalculerFormes();
db.Livres.Add(livre);
await RattacherAuteursAsync(livre, saisie.Auteurs, 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(
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)
{
return ResultatEcriture.Introuvable;
@@ -102,46 +132,140 @@ public sealed class ServiceCatalogue(MaBibliDbContext db) : IServiceCatalogue
}
livre.Isbn = isbn;
livre.Titre = saisie.Titre.Trim();
livre.Auteur = Vide(saisie.Auteur);
livre.Titre = saisie.Titre;
livre.Editeur = Vide(saisie.Editeur);
livre.Format = saisie.Format;
livre.Statut = saisie.Statut;
livre.CoverUrl = Vide(saisie.CoverUrl);
livre.RecalculerFormes();
// DateAjout et AjoutePar ne sont jamais réécrits : ce sont des traces de la saisie
// d'origine, pas des champs éditables.
await RattacherAuteursAsync(livre, saisie.Auteurs, 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 (livre is null)
if (!await db.Livres.AnyAsync(l => l.Id == id, ct))
{
return null;
}
livre.Statut = statut;
await db.SaveChangesAsync(ct);
return Projeter(livre);
await AppliquerStatutAsync(id, statut, utilisateur, ct);
return await ObtenirAsync(id, utilisateur, ct);
}
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)
{
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);
await db.SaveChangesAsync(ct);
await auteurs.SupprimerOrphelinsAsync(ct);
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>
/// Contrôles minimaux : un titre, et un ISBN qui tienne debout s'il est fourni.
/// </summary>
@@ -179,15 +303,19 @@ public sealed class ServiceCatalogue(MaBibliDbContext db) : IServiceCatalogue
private static string? Vide(string? valeur) =>
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,
Isbn = livre.Isbn,
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,
Format = livre.Format,
Statut = livre.Statut,
Statut = statut,
CoverUrl = livre.CoverUrl,
DateAjout = livre.DateAjout,
AjoutePar = livre.AjoutePar,
+22 -6
View File
@@ -42,7 +42,20 @@ public static class BnfSruParser
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"));
// 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)
{
// 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
{
Titre = titre,
Auteur = NettoyageIsbd.Auteur(Premier(data, "creator")),
Auteurs = auteurs,
Editeur = NettoyageIsbd.Editeur(Premier(data, "publisher")),
Annee = Premier(data, "date"),
Langue = Premier(data, "language"),
@@ -66,9 +79,12 @@ public static class BnfSruParser
return candidats;
}
private static string? Premier(XElement recordData, string nomLocal)
{
var valeur = recordData.Descendants(Dc + nomLocal).FirstOrDefault()?.Value?.Trim();
return string.IsNullOrEmpty(valeur) ? null : valeur;
}
private static string? Premier(XElement recordData, string nomLocal) =>
Tous(recordData, nomLocal).FirstOrDefault();
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!);
}
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace MaBibli.Api.Services.Isbn;
@@ -43,6 +44,71 @@ public static partial class NettoyageIsbd
return s.Length == 0 ? null : s;
}
/// <summary>
/// Retire du titre le nom d'auteur qui y est collé en fin de chaîne.
/// </summary>
/// <remarks>
/// Cas réel mesuré, ISBN <c>9782749942636</c> :
/// <c>dc:title</c> vaut « Le plus grand défi … (Nouvelle éd. augmentée) <b>Aurélien Barrau</b> »
/// alors que <c>dc:creator</c> vaut « Barrau, Aurélien (1973-....) ». Le nom est recollé
/// <b>sans</b> le séparateur ISBD « / », donc <see cref="Titre"/> ne peut rien y faire : la
/// notice est sale à la source.
/// <para>
/// L'heuristique est volontairement stricte — comparaison en <b>fin de chaîne</b> uniquement,
/// et seulement contre un auteur déjà nettoyé. Un titre qui contient légitimement un nom de
/// personne ailleurs (« Lettre à Émile Zola ») n'est pas touché, et un titre qui <i>est</i>
/// le nom de l'auteur (biographie, recueil) est laissé intact plutôt que réduit à rien.
/// </para>
/// </remarks>
public static string? RetirerAuteurEnFin(string? titre, IEnumerable<string?> auteurs)
{
var s = titre?.Trim();
if (string.IsNullOrEmpty(s))
{
return null;
}
foreach (var auteur in auteurs)
{
var nom = auteur?.Trim();
if (string.IsNullOrEmpty(nom) || nom.Length >= s.Length)
{
// Un nom aussi long que le titre ne laisserait rien : c'est une biographie
// ou un recueil, pas une notice sale.
continue;
}
var debut = s.Length - nom.Length;
// IgnoreNonSpace ignore les accents sans changer les longueurs, contrairement à une
// normalisation NFD : les indices calculés restent valables sur la chaîne d'origine.
var identique = string.Compare(
s, debut, nom, 0, nom.Length,
CultureInfo.InvariantCulture,
CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace) == 0;
if (!identique)
{
continue;
}
// Le nom doit commencer sur une frontière de mot, sinon « …Barrau » couperait aussi
// dans un mot qui se termine par les mêmes lettres.
if (char.IsLetterOrDigit(s[debut - 1]))
{
continue;
}
var reste = s[..debut].TrimEnd(' ', ',', ';', '.', '-', '/', ':');
if (reste.Length > 0)
{
return reste;
}
}
return s;
}
/// <summary>
/// Auteur : retirer les dates entre parenthèses, le rôle qui suit le point,
/// puis inverser « Nom, Prénom » en « Prénom Nom ».
+10 -4
View File
@@ -62,14 +62,17 @@ public static class OpenLibraryMapper
titre = $"{titre} : {edition.Subtitle.Trim()}";
}
var auteur = nomsAuteurs.Count > 0
? string.Join(", ", nomsAuteurs)
: NettoyerByStatement(edition.ByStatement);
// Les noms résolus par /authors/{id}.json arrivent déjà séparés. En dernier repli la
// mention de responsabilité n'est qu'une phrase : on la garde entière plutôt que de
// 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
{
Titre = titre,
Auteur = auteur,
Auteurs = auteurs,
Editeur = edition.Publishers?.FirstOrDefault()?.Trim().Trim('[', ']'),
Annee = string.IsNullOrWhiteSpace(edition.PublishDate) ? null : edition.PublishDate.Trim(),
Langue = edition.Languages?
@@ -83,6 +86,9 @@ public static class OpenLibraryMapper
};
}
private static IReadOnlyList<string> Liste(string? valeur) =>
string.IsNullOrWhiteSpace(valeur) ? [] : [valeur];
/// <summary>
/// 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.]. »).
@@ -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" />
</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">
<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"
placeholder="Prénom Nom" autocomplete="off" />
placeholder="Prénom Nom ; Prénom Nom" autocomplete="off" />
</label>
<label class="champ">
@@ -51,17 +58,10 @@
</fieldset>
<fieldset class="champ">
<legend class="champ-libelle">Statut de lecture</legend>
<div class="segments">
@foreach (var statut in Statuts)
{
<button type="button"
class="segment @(Saisie.Statut == statut ? "segment-actif" : null)"
@onclick="() => Saisie.Statut = statut">
@Libelles.Statut(statut)
</button>
}
</div>
<legend class="champ-libelle">
Statut de lecture<span class="champ-aide"> (le vôtre)</span>
</legend>
<ChoixStatut Valeur="Saisie.Statut" ValeurChanged="s => Saisie.Statut = s" />
</fieldset>
<label class="champ">
@@ -94,7 +94,6 @@
@code {
private static readonly Format[] Formats = Enum.GetValues<Format>();
private static readonly Statut[] Statuts = Enum.GetValues<Statut>();
[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>
@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">
<input class="champ-saisie" type="search" placeholder="Titre ou auteur…"
value="@_recherche" @oninput="SurRecherche" aria-label="Rechercher un livre" />
@@ -22,7 +31,9 @@
}
</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)"
@onclick="() => FiltrerStatut(null)">Tous</button>
@foreach (var statut in Enum.GetValues<Statut>())
@@ -61,9 +72,15 @@ else if (_livres is not null)
<div class="carte-corps">
<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">
@@ -75,17 +92,10 @@ else if (_livres is not null)
</p>
@* 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">
@foreach (var statut in Enum.GetValues<Statut>())
{
<button type="button"
class="segment @Libelles.ClasseStatut(statut) @(livre.Statut == statut ? "segment-actif" : null)"
disabled="@(_statutEnCours == livre.Id)"
@onclick="() => ChangerStatut(livre, statut)">
@Libelles.Statut(statut)
</button>
}
</div>
<ChoixStatut Valeur="livre.Statut"
ValeurChanged="s => ChangerStatut(livre, s)"
Desactive="_statutEnCours == livre.Id"
Compact="true" />
</div>
</li>
}
@@ -95,10 +105,17 @@ else if (_livres is not null)
<nav class="actions-flottantes">
<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="auteurs">Auteurs</a>
</nav>
@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 AuteurDto? _auteur;
private int? _auteurCharge;
private string _recherche = string.Empty;
private Format? _format;
private Statut? _statut;
@@ -110,9 +127,19 @@ else if (_livres is not null)
private CancellationTokenSource _cts = new();
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)
{
@@ -144,7 +171,14 @@ else if (_livres is not null)
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);
}
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)
{
+4 -1
View File
@@ -97,7 +97,10 @@ else
{
Isbn = _livre.Isbn,
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,
Format = _livre.Format,
Statut = _livre.Statut,
+44 -1
View File
@@ -36,6 +36,11 @@ public sealed class ServiceLivresApi(HttpClient http)
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);
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);
}
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(
$"api/livres/{id}/statut", new ChangementStatut { Statut = statut }, Json, 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)
{
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
/// 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.
/// <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>
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; }
/// <summary>Physique ou numérique. <c>null</c> = les deux : le catalogue est unique.</summary>
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; }
/// <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.Textes;
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>
/// testable en mémoire sur une simple liste, sans base ni réseau.
/// <para>
/// La comparaison passe par <c>ToLower()</c> : EF Core la traduit en SQL, et LINQ-to-Objects
/// lui donne le même comportement insensible à la casse. Les accents ne sont pas normalisés
/// (« Emile » ne trouve pas « Émile ») — limitation assumée à ce stade.
/// La recherche porte sur les colonnes <b>normalisées</b> (<see cref="Livre.TitreNormalise"/>,
/// <see cref="Auteur.NomNormalise"/>), pas sur les colonnes d'affichage : SQLite ne sait pas
/// 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>
/// </remarks>
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).
// 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)
{
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 (!string.IsNullOrEmpty(terme))
if (criteres.AuteurId is { } auteurId)
{
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 =>
l.Titre.ToLower().Contains(recherche)
|| (l.Auteur != null && l.Auteur.ToLower().Contains(recherche)));
l.TitreNormalise.Contains(terme)
|| l.Auteurs.Any(la =>
la.Auteur!.NomNormalise.Contains(terme)
|| la.Auteur.CleRegroupement.Contains(cle)));
}
return Trier(source);
@@ -49,7 +73,11 @@ public static class FiltreLivres
/// <remarks>
/// 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.
/// <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>
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>
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>
public string? Auteur { get; init; }
/// <summary>
/// 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; }
+47 -3
View File
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using MaBibli.Shared.Entites;
namespace MaBibli.Shared.Dtos;
@@ -11,24 +12,67 @@ namespace MaBibli.Shared.Dtos;
/// </remarks>
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>
public string? Isbn { get; set; }
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 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; }
/// <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>
/// <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 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;
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>
/// Un livre tel qu'exposé par l'API. Volontairement sans la collection <c>Prets</c> :
/// les prêts sont la phase 4.
@@ -14,13 +26,22 @@ public record LivreDto
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 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; }
@@ -31,4 +52,9 @@ public record LivreDto
/// la bibliothèque est commune et aucune lecture ne filtre sur ce champ.
/// </summary>
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;
/// <summary>
@@ -12,14 +14,19 @@ public class Livre
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 Format Format { get; set; }
public Statut Statut { get; set; }
/// <summary>URL de la couverture (OpenLibrary), pas de fichier stocké localement.</summary>
public string? CoverUrl { get; set; }
@@ -33,6 +40,24 @@ public class Livre
/// </summary>
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>
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; }
}
@@ -0,0 +1,77 @@
using System.Globalization;
using System.Text;
namespace MaBibli.Shared.Textes;
/// <summary>
/// Mise à plat d'un texte pour la recherche et le regroupement : minuscules, sans accents,
/// sans ponctuation, espaces réduits.
/// </summary>
/// <remarks>
/// <b>Pourquoi une forme stockée plutôt qu'une comparaison à la volée ?</b> SQLite ne sait pas
/// comparer sans tenir compte des accents : sa collation <c>NOCASE</c> ne couvre que l'ASCII, et
/// il n'existe pas de <c>unaccent()</c> intégré. Faire le travail en C# à l'écriture, dans une
/// colonne dédiée, est la seule façon d'obtenir « Emile » → « Émile » <b>sans</b> charger tout
/// le catalogue en mémoire à chaque recherche.
/// <para>
/// Conséquence à ne pas oublier : toute écriture doit recalculer la forme normalisée, et le
/// terme cherché doit passer par la <b>même</b> fonction que la valeur stockée.
/// </para>
/// </remarks>
public static class NormalisationTexte
{
/// <summary>
/// Forme comparable d'un texte quelconque : « Émile ZOLA » → « emile zola ».
/// </summary>
/// <remarks>
/// La ponctuation devient une espace plutôt que de disparaître : « P.F. Hamilton » doit
/// donner trois mots (<c>p f hamilton</c>) et non un seul (<c>pf hamilton</c>), sans quoi
/// le rapprochement par initiales serait impossible.
/// </remarks>
public static string Normaliser(string? brut)
{
if (string.IsNullOrWhiteSpace(brut))
{
return string.Empty;
}
// NFD sépare la lettre de son accent ; on ne garde ensuite que la lettre.
var decompose = brut.Normalize(NormalizationForm.FormD);
var sortie = new StringBuilder(decompose.Length);
var espaceEnAttente = false;
foreach (var c in decompose)
{
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark)
{
continue; // Accent détaché par la décomposition.
}
if (char.IsLetterOrDigit(c))
{
if (espaceEnAttente && sortie.Length > 0)
{
sortie.Append(' ');
}
espaceEnAttente = false;
sortie.Append(char.ToLowerInvariant(c));
continue;
}
// Tout le reste (espaces, points, virgules, tirets…) est un séparateur de mots.
espaceEnAttente = true;
}
return sortie.ToString().Normalize(NormalizationForm.FormC);
}
/// <summary>Mots de la forme normalisée, sans mot vide.</summary>
public static string[] Mots(string? brut)
{
var normalise = Normaliser(brut);
return normalise.Length == 0
? []
: normalise.Split(' ', StringSplitOptions.RemoveEmptyEntries);
}
}
@@ -0,0 +1,160 @@
namespace MaBibli.Shared.Textes;
/// <summary>Verdict du rapprochement de deux noms d'auteur.</summary>
public enum NiveauRapprochement
{
/// <summary>Rien de commun : ce sont deux auteurs distincts.</summary>
Aucun = 0,
/// <summary>
/// Variante <b>sûre</b> : même nom à la casse, aux accents, à l'ordre ou à l'abréviation près.
/// Peut être fusionné automatiquement.
/// </summary>
Certain = 1,
/// <summary>
/// Rapprochement <b>plausible mais ambigu</b> : un nom est contenu dans l'autre
/// (« Hamilton » vers « Peter F. Hamilton »). À <b>proposer</b>, jamais à appliquer seul.
/// </summary>
Suggere = 2,
}
/// <summary>
/// Règles de rapprochement des noms d'auteurs.
/// </summary>
/// <remarks>
/// Décision actée dans CLAUDE.md : le regroupement <b>automatique</b> se limite aux variantes
/// sûres — casse, accents, ordre <c>Nom, Prénom</c> ↔ <c>Prénom Nom</c>, initiales abrégées
/// quand le reste concorde. Tout le reste est <b>proposé</b> à l'utilisateur.
/// <para>
/// La raison est asymétrique : ne pas fusionner laisse deux fiches à réunir d'un clic, alors
/// qu'une fusion erronée mélange les livres de deux personnes et se défait mal. Dans le doute,
/// on ne fusionne pas.
/// </para>
/// </remarks>
public static class RapprochementAuteurs
{
/// <summary>
/// Clé de regroupement : mots normalisés <b>triés</b>, ce qui rend l'ordre indifférent.
/// </summary>
/// <remarks>
/// « Zola, Émile » et « Émile Zola » donnent tous deux <c>emile zola</c>. C'est cette clé qui
/// porte l'unicité en base : deux auteurs ne peuvent pas coexister avec la même.
/// <para>
/// Elle ne couvre <b>pas</b> les initiales (<c>p f hamilton</c> ≠ <c>f hamilton peter</c>) :
/// ce cas relève de <see cref="Comparer"/>, qui aligne les mots au lieu de les hacher.
/// </para>
/// </remarks>
public static string Cle(string? nom)
{
var mots = NormalisationTexte.Mots(nom);
Array.Sort(mots, StringComparer.Ordinal);
return string.Join(' ', mots);
}
/// <summary>
/// Compare deux noms d'auteur et dit s'ils désignent sûrement, peut-être, ou sûrement pas
/// la même personne.
/// </summary>
public static NiveauRapprochement Comparer(string? a, string? b)
{
var motsA = NormalisationTexte.Mots(a);
var motsB = NormalisationTexte.Mots(b);
if (motsA.Length == 0 || motsB.Length == 0)
{
return NiveauRapprochement.Aucun;
}
if (motsA.Length == motsB.Length)
{
// Même nombre de mots : soit tout s'aligne (variante sûre), soit ce sont deux personnes.
// On exige au moins un mot entier commun : « P. H. » et « Peter Hamilton » s'alignent
// par les seules initiales, ce qui ne concorde sur rien de vérifiable.
return Aligne(motsA, motsB, out var pleinPartage) && pleinPartage
? NiveauRapprochement.Certain
: NiveauRapprochement.Aucun;
}
// Nombres de mots différents : au mieux l'un est un fragment de l'autre
// (« Hamilton » ⊂ « Peter F. Hamilton »). Jamais sûr — deux personnes partagent
// couramment un patronyme.
var (court, longue) = motsA.Length < motsB.Length ? (motsA, motsB) : (motsB, motsA);
return Aligne(court, longue, out var motPleinPartage) && motPleinPartage
? NiveauRapprochement.Suggere
: NiveauRapprochement.Aucun;
}
/// <summary>Raccourci : les deux noms sont-ils une variante sûre l'un de l'autre ?</summary>
public static bool SontLeMemeAuteur(string? a, string? b) =>
Comparer(a, b) == NiveauRapprochement.Certain;
/// <summary>
/// Cherche à placer chaque mot de <paramref name="aPlacer"/> sur un mot distinct de
/// <paramref name="cible"/>, par égalité ou par initiale.
/// </summary>
/// <param name="motPleinPartage">
/// Vrai si au moins un couple aligné est une égalité de <b>mots entiers</b>. Sans cette
/// garantie, « P. » se rapprocherait de n'importe quel prénom commençant par P — une
/// suggestion sans valeur qui n'apprendrait rien à l'utilisateur.
/// </param>
/// <remarks>
/// Recherche exhaustive avec retour arrière. Les noms de personnes tiennent en quelques mots,
/// le coût est négligeable ; une correspondance gloutonne, elle, échouerait sur
/// « P. Peter » vs « Peter Paul » en consommant le mauvais mot en premier.
/// </remarks>
private static bool Aligne(string[] aPlacer, string[] cible, out bool motPleinPartage)
{
var pris = new bool[cible.Length];
motPleinPartage = false;
return Placer(aPlacer, cible, pris, 0, ref motPleinPartage);
}
private static bool Placer(
string[] aPlacer, string[] cible, bool[] pris, int index, ref bool motPleinPartage)
{
if (index == aPlacer.Length)
{
return true;
}
var mot = aPlacer[index];
for (var i = 0; i < cible.Length; i++)
{
if (pris[i])
{
continue;
}
var autre = cible[i];
var egalite = mot == autre;
var initiale = !egalite && EstInitialeDe(mot, autre);
if (!egalite && !initiale)
{
continue;
}
pris[i] = true;
var pleinAvant = motPleinPartage;
motPleinPartage = pleinAvant || (egalite && mot.Length > 1);
if (Placer(aPlacer, cible, pris, index + 1, ref motPleinPartage))
{
return true;
}
pris[i] = false;
motPleinPartage = pleinAvant;
}
return false;
}
/// <summary>« p » est l'initiale de « peter ». Une lettre seule d'un côté, le mot de l'autre.</summary>
private static bool EstInitialeDe(string a, string b) =>
(a.Length == 1 && b.Length > 1 && b[0] == a[0])
|| (b.Length == 1 && a.Length > 1 && a[0] == b[0]);
}
+75
View File
@@ -73,6 +73,81 @@ public class BnfSruParserTests
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]
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>
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 =
[
new()
{
Id = 1, Titre = "Germinal", Auteur = "Émile Zola",
Format = Format.Physique, Statut = Statut.Lu, AjoutePar = "mathieu",
},
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,
},
Livre(1, "Germinal", Zola, Format.Physique, Statut.Lu, "mathieu"),
Livre(2, "La Bête humaine", Zola, Format.Numerique, Statut.ALire, "camille"),
Livre(3, "Le Horla", Maupassant, Format.Physique, Statut.EnCours, "camille"),
Livre(4, "Bel-Ami", null, Format.Numerique, Statut.Lu, null),
];
private static List<Livre> Filtrer(CritereLivres criteres) =>
FiltreLivres.Appliquer(Catalogue.AsQueryable(), criteres).ToList();
private static List<Livre> Filtrer(CritereLivres criteres, string? utilisateur = "mathieu") =>
FiltreLivres.Appliquer(Catalogue.AsQueryable(), criteres, utilisateur).ToList();
[Fact]
public void Sans_critere_tout_le_catalogue_remonte()
@@ -61,13 +83,26 @@ public class FiltreLivresTests
[InlineData(Statut.EnCours, 1)]
[InlineData(Statut.Lu, 2)]
public void Le_filtre_statut_ne_garde_que_ce_statut(Statut statut, int attendu)
{
var resultat = Filtrer(new CritereLivres { Statut = statut });
=> Assert.Equal(attendu, Filtrer(new CritereLivres { Statut = statut }).Count);
Assert.Equal(attendu, resultat.Count);
Assert.All(resultat, l => Assert.Equal(statut, l.Statut));
/// <summary>
/// <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]
public void La_recherche_porte_sur_le_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"],
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]
[InlineData("HORLA")]
[InlineData("horla")]
@@ -88,12 +143,18 @@ public class FiltreLivresTests
[Fact]
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" });
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]
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("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);
}
}
+46
View File
@@ -22,6 +22,52 @@ public class NettoyageIsbdTests
public void Titre_laisse_passer_ce_qui_est_deja_propre(string brut, string attendu)
=> Assert.Equal(attendu, NettoyageIsbd.Titre(brut));
/// <summary>
/// Cas réel mesuré sur l'ISBN <c>9782749942636</c> : la BnF recolle le nom de l'auteur en
/// fin de titre <b>sans</b> le séparateur ISBD, ce que <see cref="NettoyageIsbd.Titre"/> ne
/// peut donc pas voir.
/// </summary>
[Fact]
public void Titre_retire_le_nom_de_lauteur_colle_en_fin()
{
const string brut = "Le plus grand défi de l'histoire de l'humanité : face à la "
+ "catastrophe écologique et sociale (Nouvelle éd. augmentée) Aurélien Barrau";
var titre = NettoyageIsbd.RetirerAuteurEnFin(brut, ["Aurélien Barrau"]);
Assert.Equal(
"Le plus grand défi de l'histoire de l'humanité : face à la catastrophe écologique "
+ "et sociale (Nouvelle éd. augmentée)",
titre);
}
[Theory]
// Contre-exemple central : le nom est bien là, mais pas en fin de chaîne. On n'y touche pas.
[InlineData("Lettre à Émile Zola", "Guy de Maupassant")]
[InlineData("Émile Zola et son temps", "Émile Zola")]
// Le titre EST le nom de l'auteur (biographie, recueil) : le retirer ne laisserait rien.
[InlineData("Aurélien Barrau", "Aurélien Barrau")]
// Fin de mot fortuite : « Zola » ne doit pas être découpé dans « Gorgonzola ».
[InlineData("Éloge du gorgonzola", "Zola")]
// Auteur inconnu du titre.
[InlineData("Germinal", "Émile Zola")]
public void Titre_ne_mutile_pas_un_nom_legitime(string titre, string auteur)
=> Assert.Equal(titre, NettoyageIsbd.RetirerAuteurEnFin(titre, [auteur]));
[Fact]
public void Titre_tolere_une_difference_daccent_ou_de_casse_sur_le_nom()
=> Assert.Equal(
"Le grand défi",
NettoyageIsbd.RetirerAuteurEnFin("Le grand défi AURELIEN BARRAU", ["Aurélien Barrau"]));
[Fact]
public void Titre_sans_auteur_connu_reste_intact()
{
Assert.Equal("Germinal", NettoyageIsbd.RetirerAuteurEnFin("Germinal", []));
Assert.Equal("Germinal", NettoyageIsbd.RetirerAuteurEnFin("Germinal", [null, " "]));
Assert.Null(NettoyageIsbd.RetirerAuteurEnFin(null, ["Émile Zola"]));
}
[Theory]
[InlineData("Zola, Émile (1840-1902). Auteur du texte", "Émile Zola")]
[InlineData("Saint-Exupéry, Antoine de (1900-1944). Auteur du texte", "Antoine de Saint-Exupéry")]
+4 -2
View File
@@ -82,9 +82,11 @@ public class OpenLibraryMapperTests
var candidat = OpenLibraryMapper.VersCandidat(edition, noms, "9780262033848", null)!;
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(
"Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein",
candidat.Auteur);
["Thomas H. Cormen", "Charles E. Leiserson", "Ronald L. Rivest", "Clifford Stein"],
candidat.Auteurs);
Assert.Equal("The MIT Press", candidat.Editeur);
Assert.Equal("2009", candidat.Annee);
}
+121
View File
@@ -0,0 +1,121 @@
using MaBibli.Shared.Textes;
namespace MaBibli.Tests;
/// <summary>
/// Règles de regroupement des noms d'auteurs.
/// </summary>
/// <remarks>
/// La frontière testée ici est celle que CLAUDE.md pose : <b>ce qui est sûr est fusionné, ce qui
/// est ambigu est proposé</b>. Les tests sont écrits par catégorie plutôt que par cas, pour que
/// l'ajout d'une règle oblige à dire de quel côté de la frontière elle tombe.
/// </remarks>
public class RapprochementAuteursTests
{
[Theory]
// Casse et accents.
[InlineData("Émile Zola", "emile zola")]
[InlineData("Émile Zola", "EMILE ZOLA")]
[InlineData("Émile Zola", "Emile Zola")]
// Ordre inversé, avec ou sans virgule.
[InlineData("Zola, Émile", "Émile Zola")]
[InlineData("Zola, Emile", "emile zola")]
// Initiales abrégées, le reste concordant.
[InlineData("Peter F. Hamilton", "P.F. Hamilton")]
[InlineData("Peter F. Hamilton", "P. F. Hamilton")]
[InlineData("P.F. Hamilton", "P. F. Hamilton")]
[InlineData("Thomas H. Cormen", "T. H. Cormen")]
// Combinaison des trois.
[InlineData("Hamilton, Peter F.", "p f hamilton")]
// Auteur mononyme.
[InlineData("Homère", "homere")]
public void Les_variantes_sures_sont_reconnues(string a, string b)
{
Assert.Equal(NiveauRapprochement.Certain, RapprochementAuteurs.Comparer(a, b));
Assert.Equal(NiveauRapprochement.Certain, RapprochementAuteurs.Comparer(b, a));
}
[Theory]
// Le cas d'école de CLAUDE.md : un patronyme seul face à un nom complet.
[InlineData("Hamilton", "Peter F. Hamilton")]
[InlineData("Zola", "Émile Zola")]
[InlineData("Cormen", "Thomas H. Cormen")]
// Un prénom en plus, ou en moins.
[InlineData("Émile Zola", "Émile Édouard Zola")]
public void Les_rapprochements_ambigus_sont_seulement_proposes(string a, string b)
{
Assert.Equal(NiveauRapprochement.Suggere, RapprochementAuteurs.Comparer(a, b));
Assert.Equal(NiveauRapprochement.Suggere, RapprochementAuteurs.Comparer(b, a));
// Et surtout : jamais fusionnés d'office.
Assert.False(RapprochementAuteurs.SontLeMemeAuteur(a, b));
}
[Theory]
// Deux personnes qui partagent un patronyme : rien ne doit les rapprocher automatiquement.
[InlineData("Peter Hamilton", "Paul Hamilton")]
[InlineData("Peter F. Hamilton", "Peter G. Hamilton")]
// Homonymes partiels sans mot entier commun.
[InlineData("Émile Zola", "Émile Ajar")]
[InlineData("Voltaire", "Molière")]
// Une initiale seule ne concorde sur rien de vérifiable.
[InlineData("P. H.", "Peter Hamilton")]
[InlineData("P. Hamilton", "Peter Paul")]
public void Les_auteurs_distincts_ne_sont_pas_rapproches(string a, string b)
{
Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(a, b));
Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(b, a));
}
[Fact]
public void Un_nom_vide_ne_se_rapproche_de_rien()
{
Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(null, "Émile Zola"));
Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(" ", "Émile Zola"));
Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(".,;", "Émile Zola"));
}
[Fact]
public void La_cle_est_identique_pour_les_variantes_dordre_et_daccents()
{
var attendue = RapprochementAuteurs.Cle("Émile Zola");
Assert.Equal(attendue, RapprochementAuteurs.Cle("Zola, Émile"));
Assert.Equal(attendue, RapprochementAuteurs.Cle("emile zola"));
Assert.Equal(attendue, RapprochementAuteurs.Cle(" ZOLA, Emile "));
Assert.Equal("emile zola", attendue);
}
/// <summary>
/// La clé ne sait pas rapprocher les initiales : c'est justement pour ça que
/// <see cref="RapprochementAuteurs.Comparer"/> existe en plus de l'index unique.
/// </summary>
[Fact]
public void La_cle_ne_couvre_pas_les_initiales()
{
Assert.NotEqual(
RapprochementAuteurs.Cle("Peter F. Hamilton"),
RapprochementAuteurs.Cle("P.F. Hamilton"));
Assert.True(RapprochementAuteurs.SontLeMemeAuteur("Peter F. Hamilton", "P.F. Hamilton"));
}
[Theory]
[InlineData("Émile ZOLA", "emile zola")]
[InlineData("P.F. Hamilton", "p f hamilton")]
[InlineData(" Zola, Émile ", "zola emile")]
[InlineData("Saint-Exupéry", "saint exupery")]
[InlineData("L'Écume", "l ecume")]
[InlineData("", "")]
[InlineData(null, "")]
public void La_normalisation_met_a_plat_casse_accents_et_ponctuation(string? brut, string attendu)
=> Assert.Equal(attendu, NormalisationTexte.Normaliser(brut));
/// <summary>
/// La ponctuation devient une espace et ne disparaît pas : sans ça « P.F. » donnerait un seul
/// mot et les initiales ne pourraient plus s'aligner.
/// </summary>
[Fact]
public void La_ponctuation_separe_les_mots()
=> Assert.Equal(["p", "f", "hamilton"], NormalisationTexte.Mots("P.F. Hamilton"));
}
+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.Database.EnsureCreated();
_service = new ServiceCatalogue(_db);
_service = new ServiceCatalogue(_db, new ServiceAuteurs(_db));
}
private static EnregistrementLivre Saisie(
@@ -39,7 +39,7 @@ public class ServiceCatalogueTests : IDisposable
string? auteur = null,
string? isbn = null,
Format format = Format.Physique,
Statut statut = Statut.ALire) => new()
Statut? statut = null) => new()
{
Titre = titre,
Auteur = auteur,
@@ -58,6 +58,7 @@ public class ServiceCatalogueTests : IDisposable
Assert.True(livre.Id > 0);
Assert.Equal("Germinal", livre.Titre);
Assert.Equal("mathieu", livre.AjoutePar);
Assert.Equal(["Émile Zola"], livre.Auteurs.Select(a => a.Nom));
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("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);
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));
}
@@ -110,13 +111,31 @@ public class ServiceCatalogueTests : IDisposable
await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "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));
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));
}
/// <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>
/// <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.
@@ -128,7 +147,7 @@ public class ServiceCatalogueTests : IDisposable
await _service.CreerAsync(Saisie("Livre de Camille"), "camille");
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.Contains(tout, l => l.AjoutePar == "mathieu");
@@ -139,19 +158,20 @@ public class ServiceCatalogueTests : IDisposable
[Fact]
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
{
Titre = "Titre corrigé",
Auteur = "Auteur corrigé",
Auteur = "Auteur Corrigé",
Format = Format.Numerique,
Statut = Statut.Lu,
});
}, "camille");
Assert.Null(modifie.Erreur);
Assert.Equal("Titre corrigé", modifie.Livre!.Titre);
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.
Assert.Equal("camille", modifie.Livre.AjoutePar);
@@ -161,7 +181,7 @@ public class ServiceCatalogueTests : IDisposable
[Fact]
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);
}
@@ -171,43 +191,161 @@ public class ServiceCatalogueTests : IDisposable
{
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.Equal(Statut.EnCours, apres!.Statut);
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);
}
/// <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]
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]
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.Null(await _service.ObtenirAsync(cree.Id));
Assert.Empty(await _service.ListerAsync(new CritereLivres()));
Assert.Null(await _service.ObtenirAsync(cree.Id, "mathieu"));
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.
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]
public async Task Obtenir_renvoie_la_fiche_complete()
{
var cree = (await _service.CreerAsync(
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.Equal("Le Horla", relu!.Titre);
Assert.Equal("9782080704092", relu.Isbn);
Assert.Equal(["Guy de Maupassant"], relu.Auteurs.Select(a => a.Nom));
}
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);
}
}