L'Œuvre de Zola, que la BnF écrit avec la ligature, était introuvable à qui tape « oeuvre » : NFD sépare les accents mais laisse les ligatures. Table explicite plutôt que NFKD, qui aurait aussi transformé les exposants, les chiffres romains et les espaces insécables. Une normalisation de recherche doit rester prévisible. Sept colonnes normalisées en dépendent, dont cinq portent une unicité. Le rattrapage au démarrage est donc étendu aux envies, séries, revues et numéros — qui ne calculaient leurs formes qu'à l'écriture — et refuse toute collision AVANT d'écrire : sinon l'exception tomberait au démarrage et le serveur ne se lancerait plus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
322 lines
12 KiB
C#
322 lines
12 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);
|
|
var autres = await RenormaliserLesAutresTablesAsync(ct);
|
|
|
|
if (titres + auteursCorriges + auteursFusionnes + autres > 0)
|
|
{
|
|
logger.LogInformation(
|
|
"Renormalisation : {Titres} titre(s), {Auteurs} auteur(s) corrigés, "
|
|
+ "{Fusions} fusionné(s), {Autres} autre(s) ligne(s).",
|
|
titres, auteursCorriges, auteursFusionnes, autres);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remet à jour les colonnes normalisées des tables apparues après ce service.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Envies, séries, revues et numéros calculent leurs formes <b>à l'écriture</b> : rien ne
|
|
/// les rattraperait si la règle de normalisation changeait — et elle a changé le 2026-08-19,
|
|
/// avec le développement des ligatures <c>œ</c> et <c>æ</c>. Sans ce passage, une envie
|
|
/// enregistrée avant serait restée introuvable par sa propre clé.
|
|
/// <para>
|
|
/// ⚠️ Trois de ces colonnes portent une <b>unicité</b>. Une règle plus large peut donc
|
|
/// rendre identiques deux lignes qui ne l'étaient pas (« L'Œuvre » et « L'oeuvre »), et
|
|
/// l'écriture échouerait <b>au démarrage</b> — l'application ne se lancerait plus. La
|
|
/// collision est donc détectée avant d'écrire : la ligne la plus ancienne prend la nouvelle
|
|
/// clé, les suivantes <b>gardent l'ancienne</b> et sont journalisées. Mal normalisée est
|
|
/// infiniment préférable à supprimée, ou à un serveur qui ne démarre pas.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task<int> RenormaliserLesAutresTablesAsync(CancellationToken ct)
|
|
{
|
|
var corriges = 0;
|
|
|
|
var souhaits = await db.LivresSouhaites.OrderBy(s => s.Id).ToListAsync(ct);
|
|
corriges += Renormaliser(
|
|
souhaits,
|
|
s => Cle(s.Utilisateur, s.TitreNormalise, s.AuteurNormalise),
|
|
s => Cle(s.Utilisateur, CleOeuvre.Cle(s.Titre), RapprochementAuteurs.Cle(s.Auteur)),
|
|
s => s.RecalculerFormes(),
|
|
s => $"l'envie « {s.Titre} »");
|
|
|
|
var series = await db.Series.OrderBy(s => s.Id).ToListAsync(ct);
|
|
corriges += Renormaliser(
|
|
series,
|
|
s => s.TitreNormalise,
|
|
s => NormalisationTexte.Normaliser(s.Titre),
|
|
s => s.RecalculerFormes(),
|
|
s => $"la série « {s.Titre} »");
|
|
|
|
var revues = await db.Revues.OrderBy(r => r.Id).ToListAsync(ct);
|
|
corriges += Renormaliser(
|
|
revues,
|
|
r => r.TitreNormalise,
|
|
r => NormalisationTexte.Normaliser(r.Titre),
|
|
r => r.RecalculerFormes(),
|
|
r => $"la revue « {r.Titre} »");
|
|
|
|
var numeros = await db.NumerosRevue.OrderBy(n => n.Id).ToListAsync(ct);
|
|
corriges += Renormaliser(
|
|
numeros,
|
|
n => Cle(n.RevueId.ToString(), n.NumeroNormalise),
|
|
n => Cle(n.RevueId.ToString(), NormalisationTexte.Normaliser(n.Numero)),
|
|
n => n.RecalculerFormes(),
|
|
n => $"le numéro {n.Numero}");
|
|
|
|
if (corriges > 0)
|
|
{
|
|
await db.SaveChangesAsync(ct);
|
|
}
|
|
|
|
return corriges;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recalcule les formes d'un ensemble de lignes en refusant toute collision d'unicité.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Le jeu des clés occupées part de l'état <b>actuel</b> de la base, et chaque ligne libère
|
|
/// la sienne avant de réserver la nouvelle. Sans cette libération, une ligne inchangée
|
|
/// bloquerait sa propre mise à jour ; sans le jeu initial, une ligne traitée tôt prendrait
|
|
/// la clé d'une ligne non encore traitée, et l'écriture échouerait.
|
|
/// </remarks>
|
|
private int Renormaliser<T>(
|
|
IReadOnlyList<T> lignes,
|
|
Func<T, string> cleActuelle,
|
|
Func<T, string> cleVoulue,
|
|
Action<T> appliquer,
|
|
Func<T, string> nommer)
|
|
{
|
|
var occupees = new HashSet<string>(lignes.Select(cleActuelle), StringComparer.Ordinal);
|
|
var corriges = 0;
|
|
|
|
foreach (var ligne in lignes)
|
|
{
|
|
var actuelle = cleActuelle(ligne);
|
|
var voulue = cleVoulue(ligne);
|
|
|
|
if (actuelle == voulue)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
occupees.Remove(actuelle);
|
|
|
|
if (!occupees.Add(voulue))
|
|
{
|
|
// Deux lignes que la nouvelle règle rend identiques. On garde les deux, en
|
|
// laissant la seconde sur son ancienne clé : c'est visible, réparable à la main,
|
|
// et surtout ça ne coûte pas le démarrage du serveur.
|
|
occupees.Add(actuelle);
|
|
logger.LogWarning(
|
|
"Renormalisation : {Ligne} garde son ancienne forme, la nouvelle est déjà prise.",
|
|
nommer(ligne));
|
|
continue;
|
|
}
|
|
|
|
appliquer(ligne);
|
|
corriges++;
|
|
}
|
|
|
|
return corriges;
|
|
}
|
|
|
|
/// <summary>Clé composite comparable, les composants ne pouvant pas contenir de NUL.</summary>
|
|
private static string Cle(params string?[] composants) => string.Join('\0', composants);
|
|
|
|
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);
|
|
}
|