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();