using MaBibli.Shared.Entites; using MaBibli.Shared.Isbn; using MaBibli.Shared.Textes; using Microsoft.EntityFrameworkCore; namespace MaBibli.Api.Data; /// /// Remet en cohérence les colonnes normalisées, et réunit les auteurs que cette normalisation /// révèle identiques. /// /// /// Pourquoi ce passage existe. La migration a recopié les anciennes données avec les /// moyens du bord : lower() 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. /// /// Le passage est idempotent 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. /// /// public sealed class ServiceRenormalisation(MaBibliDbContext db, ILogger 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); } } /// /// Remet à jour les colonnes normalisées des tables apparues après ce service. /// /// /// Envies, séries, revues et numéros calculent leurs formes à l'écriture : 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 œ et æ. Sans ce passage, une envie /// enregistrée avant serait restée introuvable par sa propre clé. /// /// ⚠️ Trois de ces colonnes portent une unicité. 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 au démarrage — 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 gardent l'ancienne et sont journalisées. Mal normalisée est /// infiniment préférable à supprimée, ou à un serveur qui ne démarre pas. /// /// private async Task 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} »"); // ⚠️ La table sœur des envies suit exactement la même règle. L'oublier ne se verrait // pas tout de suite : une revue souhaitée resterait introuvable par sa propre clé le // jour où la normalisation changerait — c'est ce qui était arrivé aux envies avec les // ligatures. var revuesSouhaitees = await db.RevuesSouhaitees.OrderBy(e => e.Id).ToListAsync(ct); corriges += Renormaliser( revuesSouhaitees, e => Cle(e.Utilisateur, e.TitreNormalise, e.NumeroNormalise), e => Cle( e.Utilisateur, NormalisationTexte.Normaliser(e.Titre), e.Numero is null ? string.Empty : NormalisationTexte.Normaliser(e.Numero)), e => e.RecalculerFormes(), e => $"l'envie de revue « {e.Titre} »"); // ⚠️ Renormaliser() n'applique rien quand la CLÉ ne bouge pas : un ISSN mal formé sur // une ligne au titre inchangé y échapperait. Il se canonise donc à part — et sans le // moindre jeu de clés, cette colonne ne portant aucune unicité, contrairement à celle // de Revue. foreach (var envie in revuesSouhaitees) { var voulu = FormatageIssn.Canonique(envie.Issn); if (voulu != envie.Issn) { envie.Issn = voulu; corriges++; } } 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; } /// /// Recalcule les formes d'un ensemble de lignes en refusant toute collision d'unicité. /// /// /// Le jeu des clés occupées part de l'état actuel 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. /// private int Renormaliser( IReadOnlyList lignes, Func cleActuelle, Func cleVoulue, Action appliquer, Func nommer) { var occupees = new HashSet(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; } /// /// Range les ISSN existants sous leur forme à tiret, sans jamais créer de collision. /// /// /// Rattrape les revues saisies à la main avant que la canonisation n'existe : « 24666718 » /// devient « 2466-6718 », donc rapprochable du code-barres et de bib.issn. /// /// ⚠️ Une revue dont l'ISSN canonique est déjà celui d'une autre garde le sien : la /// fusion de deux fiches n'est pas une opération de démarrage, et l'unicité partielle /// ferait échouer le lancement du serveur. /// /// private int CanoniserLesIssn(IReadOnlyList revues) { var porteuses = revues.Where(r => r.Issn is not null).ToList(); var occupes = new HashSet(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; } /// Clé composite comparable, les composants ne pouvant pas contenir de NUL. private static string Cle(params string?[] composants) => string.Join('\0', composants); private async Task 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(StringComparer.Ordinal); var conserves = new List(); 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); } /// Reporte les livres de sur . 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); } /// /// Note grossière d'un nom en tant que nom d'affichage, pour départager des variantes /// équivalentes. /// /// /// 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 ». /// 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; } /// Nombre de mots qui ne sont pas de simples initiales. private static int NomsDeveloppes(string nom) => NormalisationTexte.Mots(nom).Count(mot => mot.Length > 1); }