Deux changements de modele en une seule migration, plus la recherche qui en depend. Le statut de lecture devient personnel. Il etait une colonne de Livre, donc partage par tout le foyer, alors que deux membres lisent le meme exemplaire a des rythmes differents. Il vit desormais dans une table (LivreId, Utilisateur, Statut) avec unicite sur le couple. L'absence de ligne vaut « non commence » : sur une bibliotheque de foyer la plupart des couples n'ont aucun statut, et les materialiser tous multiplierait les lignes par le nombre de comptes pour n'exprimer qu'un vide. Rien n'est donc ecrit a la creation d'un livre. Piege rencontre : un Dictionary<int, Statut> renvoyait la valeur 0 de l'enumeration — « À lire » — pour un livre sans ligne, rendant « non commence » indiscernable d'un choix explicite. Le dictionnaire est desormais typé Statut?. L'auteur devient une table. Deux formes normalisees y cohabitent, et ce n'est pas une redondance : NomNormalise garde l'ordre de saisie pour la recherche en sous-chaine, CleRegroupement trie les mots et porte l'index unique, donc l'invariant « un auteur, une fiche ». Les initiales echappent a la cle et sont traitees en memoire, sur une table qui compte au plus quelques centaines de lignes. Un livre peut avoir plusieurs auteurs — le lookup ISBN en renvoie quatre pour Introduction to Algorithms — d'ou la table de liaison, avec une position qui conserve l'ordre de la couverture. Les rapprochements ambigus ne sont jamais appliques seuls : l'API les liste, l'utilisateur accepte ou refuse, et les refus sont memorises pour que la suggestion ne revienne pas. Le couple refuse est range par identifiant croissant, donc un refus vaut dans les deux sens. Reprise des donnees existantes. L'ancien statut, commun, est rattache a AjoutePar — seule personne que la base associe au livre. Les statuts des livres sans AjoutePar sont perdus : les attribuer serait une invention. Les trois valeurs sont reprises telles quelles, « À lire » compris, parce que c'est ce que l'ancienne interface affichait. L'ancien champ auteur devient une fiche par valeur distincte. La migration ne peut pas tout faire : lower() de SQLite ne retire pas les accents, donc « Émile Zola » et « emile zola » y restent deux fiches. ServiceRenormalisation finit le travail en C# au demarrage, reunit ces variantes, applique aussi la regle des initiales — sans quoi une base heritee resterait eclatee la ou une saisie neuve aurait ete reunie d'emblee — et garde le nom d'affichage le plus presentable. Il est idempotent, et sert de filet si les regles de normalisation changent. L'ordre de la migration compte : les colonnes condamnees sont recopiees dans une table de transit avant d'etre supprimees, parce que supprimer une colonne sous SQLite reconstruit la table. Verifie sur une base a l'ancien schema contenant 9 livres, 2 prets et trois variantes de Zola : prets intacts, statuts rattaches, les trois Zola reunis sous « Émile Zola », « P.F. Hamilton » absorbe par « Peter F. Hamilton », « Hamilton » seul laisse en suggestion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
7.5 KiB
C#
206 lines
7.5 KiB
C#
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);
|
|
}
|