From b97cf3349490732625556c673e8755d459cbddbb Mon Sep 17 00:00:00 2001 From: mathieu Date: Wed, 19 Aug 2026 22:25:59 +0200 Subject: [PATCH] =?UTF-8?q?Ramener=20les=20ligatures=20=C2=AB=20=C5=93=20?= =?UTF-8?q?=C2=BB=20et=20=C2=AB=20=C3=A6=20=C2=BB=20=C3=A0=20=C2=AB=20oe?= =?UTF-8?q?=20=C2=BB=20et=20=C2=AB=20ae=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 39 ++++++ IDEES.md | 20 --- MaBibli.Api/Data/ServiceRenormalisation.cs | 122 ++++++++++++++++++- MaBibli.Shared/Textes/NormalisationTexte.cs | 39 ++++++ MaBibli.Tests/CleOeuvreTests.cs | 20 ++- MaBibli.Tests/RapprochementAuteursTests.cs | 6 + MaBibli.Tests/ServiceRenormalisationTests.cs | 83 +++++++++++++ 7 files changed, 293 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 35b6999..795a97f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -797,6 +797,45 @@ Deux formes cohabitent sur `Auteur`, et ce n'est pas une redondance : 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. +### Ligatures : « œ » vaut « oe » — corrigé le 2026-08-19 + +`NormalisationTexte` décompose en **NFD**, qui sépare les accents mais **laisse les ligatures +intactes** : `L'Œuvre` et `L'oeuvre` ne se rencontraient jamais. Zola a écrit *L'Œuvre*, que la +BnF orthographie avec la ligature — le livre était donc introuvable à qui tape « oeuvre », et +n'apparaissait pas grisé dans la bibliographie de son auteur. + +**Table explicite plutôt que NFKD**, qui aurait fait bien davantage : `²` → `2`, `Ⅻ` → `XII`, +espaces insécables, formes de présentation. Une normalisation de recherche doit rester +prévisible : on ne défait que ce dont le besoin est constaté, soit `œ`/`Œ` et `æ`/`Æ`. + +⚠️ **La ligature vaut deux lettres, elle ne coupe pas le mot** : `cœur` → `coeur`, pas +`co eur`. La ponctuation, elle, sépare toujours (`P.F.` → `p f`). + +#### Changer cette fonction déplace des invariants en base + +C'est ce qui avait fait repousser la correction, et le risque était réel : **sept colonnes +normalisées** en dépendent, dont **trois portent une unicité** (`Auteur.CleRegroupement`, +`LivreSouhaite (Utilisateur, TitreNormalise, AuteurNormalise)`, `Serie.TitreNormalise`, +`Revue.TitreNormalise`, `NumeroRevue (RevueId, NumeroNormalise)`). + +`ServiceRenormalisation` a donc été étendu aux tables apparues après lui — envies, séries, +revues, numéros —, qui calculaient leurs formes **à l'écriture** et que rien ne rattrapait. + +⚠️ **Une collision d'unicité au démarrage empêcherait le serveur de se lancer.** Si la nouvelle +règle rend identiques deux lignes qui ne l'étaient pas (« L'Œuvre » et « L'oeuvre »), l'écriture +échoue — et elle échoue *avant* que l'application ne réponde. Le service détecte donc la +collision avant d'écrire : **la ligne la plus ancienne prend la nouvelle forme, les suivantes +gardent l'ancienne**, et un avertissement est journalisé. Mal normalisée vaut infiniment mieux +que supprimée, ou qu'un serveur mort. + +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, toute ligne verrait sa nouvelle +valeur « déjà prise » dès qu'elle égale son ancienne, et rien ne serait jamais corrigé. Deux +tests couvrent le cas de collision et l'idempotence. + +Les auteurs, eux, sont **fusionnés** et non laissés en double : c'est le comportement déjà en +place pour les variantes d'accent, et il vaut pour les ligatures sans rien changer. + ### 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. diff --git a/IDEES.md b/IDEES.md index be8af52..665d5c7 100644 --- a/IDEES.md +++ b/IDEES.md @@ -16,26 +16,6 @@ Le travail visuel de fond (couleurs, typographie, mise en page) est repoussé vo --- -## Recherche : la ligature « œ » n'est pas réduite à « oe » - -Repéré le 2026-08-18 en implémentant la liste d'envies. `NormalisationTexte.Normaliser` -décompose en **NFD**, qui sépare les accents mais laisse les ligatures intactes — seul NFKD les -défait. Conséquence : `L'Œuvre` et `L'oeuvre` ne se rencontrent jamais. - -Ce n'est pas propre à la liste d'envies : la même fonction alimente la **recherche du catalogue** -et la **clé unique des auteurs**. Zola a écrit *L'Œuvre*, que la BnF orthographie avec la -ligature ; un utilisateur qui tape « oeuvre » ne le trouvera pas, et ne verra pas non plus le -livre grisé dans la bibliographie. - -Correction envisagée : passer en NFKD, ou traiter explicitement `œ`/`Œ` et `æ`/`Æ`. **Non fait -volontairement** — changer cette fonction déplace deux invariants en base (les colonnes -normalisées et l'index unique `CleRegroupement`). `ServiceRenormalisation` sait recalculer les -colonnes au démarrage, mais la fusion d'auteurs que la nouvelle règle provoquerait mérite d'être -regardée avant. Documenté par un test (`CleOeuvreTests.Ne_reduit_pas_la_ligature_oe`) pour que -la limite reste visible. - ---- - ## Bibliographie : ce qui reste à creuser La bibliographie par auteur est implémentée (voir `CLAUDE.md`). Deux pistes non traitées : diff --git a/MaBibli.Api/Data/ServiceRenormalisation.cs b/MaBibli.Api/Data/ServiceRenormalisation.cs index 1cda3e7..95cb8c6 100644 --- a/MaBibli.Api/Data/ServiceRenormalisation.cs +++ b/MaBibli.Api/Data/ServiceRenormalisation.cs @@ -25,15 +25,131 @@ public sealed class ServiceRenormalisation(MaBibliDbContext db, ILogger 0) + if (titres + auteursCorriges + auteursFusionnes + autres > 0) { logger.LogInformation( - "Renormalisation : {Titres} titre(s), {Auteurs} auteur(s) corrigés, {Fusions} fusionné(s).", - titres, auteursCorriges, auteursFusionnes); + "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} »"); + + 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; + } + + /// + /// 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; + } + + /// 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); diff --git a/MaBibli.Shared/Textes/NormalisationTexte.cs b/MaBibli.Shared/Textes/NormalisationTexte.cs index ead925d..689ed87 100644 --- a/MaBibli.Shared/Textes/NormalisationTexte.cs +++ b/MaBibli.Shared/Textes/NormalisationTexte.cs @@ -27,6 +27,18 @@ public static class NormalisationTexte /// La ponctuation devient une espace plutôt que de disparaître : « P.F. Hamilton » doit /// donner trois mots (p f hamilton) et non un seul (pf hamilton), sans quoi /// le rapprochement par initiales serait impossible. + /// + /// Les ligatures œ et æ sont décomposées en oe et ae. + /// ⚠️ Ce n'est pas ce que fait la décomposition NFD, qui sépare les accents mais laisse les + /// ligatures intactes : sans ce traitement explicite, L'Œuvre de Zola — que la BnF + /// écrit avec la ligature — resterait introuvable à qui tape « oeuvre » au clavier. + /// + /// + /// Le choix s'est porté sur une table explicite plutôt que sur NFKD, qui aurait fait bien + /// davantage : « ² » → « 2 », « Ⅻ » → « XII », les espaces insécables, les formes de + /// présentation. Une normalisation de recherche doit rester prévisible ; on ne défait ici que + /// ce dont on a constaté le besoin. + /// /// public static string Normaliser(string? brut) { @@ -47,6 +59,18 @@ public static class NormalisationTexte continue; // Accent détaché par la décomposition. } + if (Ligature(c) is { } lettres) + { + if (espaceEnAttente && sortie.Length > 0) + { + sortie.Append(' '); + } + + espaceEnAttente = false; + sortie.Append(lettres); + continue; + } + if (char.IsLetterOrDigit(c)) { if (espaceEnAttente && sortie.Length > 0) @@ -66,6 +90,21 @@ public static class NormalisationTexte return sortie.ToString().Normalize(NormalizationForm.FormC); } + /// + /// Développement d'une ligature, ou null si le caractère n'en est pas une. + /// + /// + /// Volontairement limité à œ et æ, seules ligatures courantes du français. + /// Les ligatures typographiques de présentation (, ) ne sont pas traitées : + /// elles n'apparaissent pas dans les catalogues, et rien n'a montré le besoin. + /// + private static string? Ligature(char c) => c switch + { + 'œ' or 'Œ' => "oe", + 'æ' or 'Æ' => "ae", + _ => null, + }; + /// Mots de la forme normalisée, sans mot vide. public static string[] Mots(string? brut) { diff --git a/MaBibli.Tests/CleOeuvreTests.cs b/MaBibli.Tests/CleOeuvreTests.cs index edb33e4..ecfbfe4 100644 --- a/MaBibli.Tests/CleOeuvreTests.cs +++ b/MaBibli.Tests/CleOeuvreTests.cs @@ -40,23 +40,17 @@ public class CleOeuvreTests CleOeuvre.Cle("Nouvelles : second recueil")); /// - /// Limite héritée de , pas propre à la clé d'œuvre : - /// la ligature « œ » n'est pas ramenée à « oe ». + /// La ligature « œ » est ramenée à « oe » — corrigé le 2026-08-19. /// /// - /// NormalisationTexte décompose en NFD, qui sépare les accents mais laisse les - /// ligatures intactes — seul NFKD les défait. Conséquence concrète : L'Œuvre de Zola, - /// que la BnF écrit avec la ligature, n'est pas rapproché d'un « L'oeuvre » saisi au clavier. - /// - /// Le défaut ne vient pas d'ici et ne se corrige pas ici : la même fonction alimente la - /// recherche du catalogue et la clé unique des auteurs, et en changer déplacerait ces deux - /// invariants. Consigné dans IDEES.md ; ce test existe pour que la limite soit visible plutôt - /// que découverte en usage. - /// + /// Le comportement vient de et non d'ici : la décomposition + /// NFD sépare les accents mais laisse les ligatures intactes, d'où une table explicite. + /// Sans elle, L'Œuvre de Zola — que la BnF écrit avec la ligature — restait + /// introuvable à qui tape « oeuvre » au clavier, et n'était pas grisé dans sa bibliographie. /// [Fact] - public void Ne_reduit_pas_la_ligature_oe() => - Assert.NotEqual(CleOeuvre.Cle("L'Œuvre"), CleOeuvre.Cle("L'oeuvre")); + public void Reduit_la_ligature_oe() => + Assert.Equal(CleOeuvre.Cle("L'Œuvre"), CleOeuvre.Cle("L'oeuvre")); /// /// Limite assumée : un titre retraduit n'est pas rapproché du tout. diff --git a/MaBibli.Tests/RapprochementAuteursTests.cs b/MaBibli.Tests/RapprochementAuteursTests.cs index da3309e..0a23381 100644 --- a/MaBibli.Tests/RapprochementAuteursTests.cs +++ b/MaBibli.Tests/RapprochementAuteursTests.cs @@ -106,6 +106,12 @@ public class RapprochementAuteursTests [InlineData(" Zola, Émile ", "zola emile")] [InlineData("Saint-Exupéry", "saint exupery")] [InlineData("L'Écume", "l ecume")] + // Ligatures : NFD ne les défait pas, une table explicite s'en charge (2026-08-19). + [InlineData("L'Œuvre", "l oeuvre")] + [InlineData("Sœur Emmanuelle", "soeur emmanuelle")] + [InlineData("Ex æquo", "ex aequo")] + // La ligature vaut deux lettres, elle ne coupe donc PAS le mot en deux. + [InlineData("cœur", "coeur")] [InlineData("", "")] [InlineData(null, "")] public void La_normalisation_met_a_plat_casse_accents_et_ponctuation(string? brut, string attendu) diff --git a/MaBibli.Tests/ServiceRenormalisationTests.cs b/MaBibli.Tests/ServiceRenormalisationTests.cs index 028ae45..25c6bf8 100644 --- a/MaBibli.Tests/ServiceRenormalisationTests.cs +++ b/MaBibli.Tests/ServiceRenormalisationTests.cs @@ -186,6 +186,89 @@ public class ServiceRenormalisationTests : IDisposable Assert.Empty(_db.Livres); } + // ── Rattrapage des tables plus récentes ───────────────────────────────── + // + // Envies, séries, revues et numéros calculent leurs formes À L'ÉCRITURE : sans ce passage, + // un changement de règle de normalisation — comme le développement des ligatures le + // 2026-08-19 — laisserait ces lignes sur leur ancienne clé, donc introuvables par la + // nouvelle. Trois de ces colonnes portent en plus une UNICITÉ, et une collision se paierait + // au démarrage du serveur. + + [Fact] + public async Task Une_envie_ecrite_avant_le_changement_de_regle_est_rattrapee() + { + // Forme telle qu'elle aurait été calculée avant le développement des ligatures. + _db.LivresSouhaites.Add(new LivreSouhaite + { + Utilisateur = "mathieu", + Titre = "L'Œuvre", + TitreNormalise = "l œuvre", + Auteur = "Émile Zola", + AuteurNormalise = "emile zola", + }); + + await _db.SaveChangesAsync(); + await ExecuterAsync(); + + Assert.Equal("l oeuvre", _db.LivresSouhaites.Single().TitreNormalise); + } + + [Fact] + public async Task Une_serie_et_une_revue_sont_rattrapees_aussi() + { + _db.Series.Add(new Serie { Titre = "Les Sœurs", TitreNormalise = "les sœurs" }); + _db.Revues.Add(new Revue { Titre = "Cœur", TitreNormalise = "cœur" }); + await _db.SaveChangesAsync(); + + await ExecuterAsync(); + + Assert.Equal("les soeurs", _db.Series.Single().TitreNormalise); + Assert.Equal("coeur", _db.Revues.Single().TitreNormalise); + } + + /// + /// ⚠️ Le cas qui empêcherait le serveur de démarrer. + /// + /// + /// La nouvelle règle rend identiques deux séries qui ne l'étaient pas. L'index unique + /// refuserait l'écriture, et l'exception surviendrait au démarrage : l'application ne + /// se lancerait plus du tout. La plus ancienne prend la nouvelle forme, la suivante garde + /// l'ancienne — mal normalisée vaut infiniment mieux que supprimée, ou qu'un serveur mort. + /// + [Fact] + public async Task Une_collision_dunicite_ne_fait_pas_echouer_le_demarrage() + { + _db.Series.Add(new Serie { Titre = "L'Œuvre", TitreNormalise = "l œuvre" }); + _db.Series.Add(new Serie { Titre = "L'oeuvre", TitreNormalise = "l oeuvre" }); + await _db.SaveChangesAsync(); + + await ExecuterAsync(); + + var series = _db.Series.OrderBy(s => s.Id).ToList(); + Assert.Equal(2, series.Count); + Assert.Equal(["l oeuvre", "l œuvre"], series.Select(s => s.TitreNormalise).Order()); + } + + /// + /// Une ligne déjà correcte ne doit pas se bloquer elle-même. + /// + /// + /// Le jeu des clés occupées part de l'état actuel de la base : sans la libération de sa + /// propre clé, toute ligne dont la forme change verrait sa nouvelle valeur « déjà prise » + /// dès qu'elle est égale à son ancienne — et rien ne serait jamais corrigé. + /// + [Fact] + public async Task Le_rattrapage_reste_idempotent() + { + _db.Revues.Add(new Revue { Titre = "Cœur", TitreNormalise = "cœur" }); + await _db.SaveChangesAsync(); + + await ExecuterAsync(); + await ExecuterAsync(); + + Assert.Equal("coeur", _db.Revues.Single().TitreNormalise); + } + public void Dispose() { _db.Dispose();