Files
mabibli/MaBibli.Api/Data/ServiceRenormalisation.cs
T
mathieuandClaude Opus 5 1d35dc06f4 Donne aux numéros de revue leur couverture et leur une, et un tiret à l'ISSN
L'ISSN est désormais rangé sous sa forme à tiret, et pas seulement affiché ainsi.
C'est le seul endroit où le projet s'écarte de « la valeur stockée reste nue »,
et il y a une raison : le code-barres produit déjà un ISSN à tiret, et la BnF
interroge `bib.issn` avec le tiret. Un ISSN tapé « 24666718 » ne se rapprochait
donc de rien, et le scan suivant créait une seconde fiche à côté. La canonisation
a lieu avant la recherche, pas seulement à l'écriture. Les champs de saisie, eux,
gardent la valeur tapée : découper à la frappe se battrait avec le curseur.

`ServiceRenormalisation` rattrape les ISSN existants — par un passage dédié, le
mécanisme générique confondant tous les NULL en une seule clé, alors que
l'unicité est ici un index partiel. Une fiche dont la forme canonique est déjà
prise garde la sienne : le serveur doit démarrer.

La couverture d'un numéro est une URL collée, jamais une photo : aucune source ne
peut la fournir, l'ISSN désignant la revue et non la parution, et le projet ne
stocke aucun fichier. Le garde du relais de couvertures a donc été étendu aux
numéros — l'oublier n'aurait produit aucune erreur visible, seulement une image
présente en ligne et absente hors-ligne.

Les articles à la une vivent dans une table à part et non dans `Theme` : un thème
est un vocabulaire qu'on réutilise, un titre d'article est unique à sa parution.
Rien ne se partage, donc pas de n-n — une simple clé étrangère vers le numéro.
Ils se saisissent au point-virgule seul : « Ukraine, deux ans après » serait
coupé en deux par la virgule.

Un PUT sur le numéro était indispensable : on note un numéro le jour où on le
range et on en recopie le sommaire plus tard. Sans lui, couverture et une
n'auraient existé qu'à la création, c'est-à-dire jamais.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:25:29 +02:00

391 lines
15 KiB
C#

using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
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}");
// ⚠️ L'ISSN ne peut PAS passer par Renormaliser : sa clé est nullable — la plupart des
// revues n'en ont pas — et un jeu de clés confondrait tous ces NULL en une seule.
// Son unicité est d'ailleurs un index PARTIEL, précisément pour cette raison.
corriges += CanoniserLesIssn(revues);
var articles = await db.ArticlesUne.OrderBy(a => a.Id).ToListAsync(ct);
corriges += Renormaliser(
articles,
a => Cle(a.NumeroRevueId.ToString(), a.TitreNormalise),
a => Cle(a.NumeroRevueId.ToString(), NormalisationTexte.Normaliser(a.Titre)),
a => a.RecalculerFormes(),
a => $"l'article « {a.Titre} »");
var themes = await db.Themes.OrderBy(t => t.Id).ToListAsync(ct);
corriges += Renormaliser(
themes,
t => t.NomNormalise,
t => NormalisationTexte.Normaliser(t.Nom),
t => t.RecalculerFormes(),
t => $"le thème « {t.Nom} »");
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>
/// Range les ISSN existants sous leur forme à tiret, sans jamais créer de collision.
/// </summary>
/// <remarks>
/// Rattrape les revues saisies à la main avant que la canonisation n'existe : « 24666718 »
/// devient « 2466-6718 », donc rapprochable du code-barres et de <c>bib.issn</c>.
/// <para>
/// ⚠️ Une revue dont l'ISSN canonique est déjà celui d'une autre <b>garde le sien</b> : la
/// fusion de deux fiches n'est pas une opération de démarrage, et l'unicité partielle
/// ferait échouer le lancement du serveur.
/// </para>
/// </remarks>
private int CanoniserLesIssn(IReadOnlyList<Revue> revues)
{
var porteuses = revues.Where(r => r.Issn is not null).ToList();
var occupes = new HashSet<string>(porteuses.Select(r => r.Issn!), StringComparer.Ordinal);
var corriges = 0;
foreach (var revue in porteuses)
{
var actuel = revue.Issn!;
var voulu = FormatageIssn.Canonique(actuel);
if (voulu is null || voulu == actuel)
{
continue;
}
occupes.Remove(actuel);
if (!occupes.Add(voulu))
{
occupes.Add(actuel);
logger.LogWarning(
"Renormalisation : la revue « {Titre} » garde l'ISSN {Issn}, sa forme à tiret est déjà prise.",
revue.Titre,
actuel);
continue;
}
revue.Issn = voulu;
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);
}