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:
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
+242
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 ».
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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><input></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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user