Lot U — le nombre de pages `Livre.NombrePages` est nullable, sans valeur par défaut : « 0 page » se lirait comme une donnée là où l'on veut dire « on ne sait pas ». Même raison que pour `TypeDocument.NonPrecise` — un défaut qui ne prétend rien n'a rien à reprendre, d'où une migration réduite à un `AddColumn`. Le service refuse un zéro plutôt que de l'écrire ; effacer le champ reste la façon de revenir à « inconnu ». Le préremplissage vient de `dc:format`, que rien ne lisait jusqu'ici. ⚠️ Ce champ n'est pas un nombre mais une phrase décrivant le support, et les notices déjà enregistrées sous Fixtures/ le montrent : « 1 vol. (113 p.) : ill., couv. ill. en coul. ; 18 cm », « 503 p. : couv. ill. ; 17 cm ». Les règles sont donc étroites — un nombre suivi de « p. » ou de « page(s) », rien d'autre — et tout le reste rend `null`. L'erreur n'est pas symétrique : un champ vide se remplit à la main en trois secondes, un chiffre faux s'enregistre sans que personne ne le voie. « 1 vol. » ne vaut pas 1, « 30 cm » ne vaut pas 30, et « (p. 45-90) », qui est une pagination de contribution, ne vaut rien. La valeur reste proposée dans un champ modifiable, et rien n'est déduit pour un ebook. Lot X — éditer une envie, et souhaiter une revue `PUT /api/souhaits/{id}` recalcule la clé d'œuvre et l'auteur normalisé : sans ce recalcul, le rapprochement « déjà au catalogue » continuerait de se faire sur l'ancienne forme, et le signalement mentirait sans le dire. Le filtre sur l'appelant fait partie de la clé de recherche, pas d'une vérification ultérieure — l'envie d'un autre est introuvable (404), jamais refusée (403). ⚠️ Une édition peut heurter l'unicité (utilisateur, œuvre, auteur), ce qu'un ajout ne peut pas : renommer une envie en une autre déjà présente répond par un message lisible, jamais par « UNIQUE constraint failed ». 400 et non 409, contrairement au doublon du catalogue : là-bas posséder deux exemplaires est légitime et l'appel se reconfirme, ici l'index l'interdit et il n'y a rien à confirmer. Le rang n'est pas touché — l'ordre a son propre point d'entrée. ⚠️ La couverture n'est écrite que si la charge utile en porte une. Aucun écran n'offre de champ « URL de couverture » pour une envie (décision actée), donc un remplacement inconditionnel l'aurait effacée à la première faute de frappe corrigée. `RevueSouhaitee` est une table sœur, et non des colonnes de plus sur `LivreSouhaite` : un numéro n'a pas d'auteur et se distingue par son numéro, deux choses que la clé d'unicité des envies de livres ne sait pas exprimer sans devenir fausse pour tout le monde. `NumeroNormalise` est NOT NULL avec un défaut vide — SQLite tient deux NULL pour distincts, et « Médor, sans numéro » s'ajouterait autant de fois qu'on cliquerait. L'ISSN est canonisé avec son tiret, seul code du projet rangé ainsi. Le coût de la table sœur est payé partout où il devait l'être : affichage, `.txt`, `.csv` et instantané hors-ligne `souhaits-revues`. ⚠️ Les revues forment une SECTION à part plutôt que des lignes entrelacées : chaque table numérote son rang indépendamment, et mélanger deux suites sans rapport produirait un ordre que personne n'a choisi. Le `.txt`, groupé par auteur, ne pouvait de toute façon pas les accueillir — elles n'en ont pas, et « Auteur non précisé » désigne des livres dont l'auteur est inconnu. Le CSV gagne une colonne « Type » : sans elle, un tri par titre rendrait revues et livres indiscernables, et la colonne des codes mêlerait ISBN et ISSN en silence. `ServiceRenormalisation` connaît la nouvelle table, avec la règle de collision déjà en place. ⚠️ L'ISSN y est canonisé à part : `Renormaliser` n'applique rien quand la clé ne bouge pas, un ISSN mal formé sur une ligne au titre inchangé y échapperait. `RevueSouhaitee` ne porte PAS de `CoverUrl` : rien à ajouter au garde de `GET /api/couvertures`. Vérifié en exécution : ISSN « 24666718 » rangé « 2466-6718 », édition de l'envie d'un autre en 404, et les deux exports portant bien les deux moitiés. 602 tests au vert (552 au départ), aucun avertissement de compilation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
420 lines
16 KiB
C#
420 lines
16 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} »");
|
|
|
|
// ⚠️ 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;
|
|
}
|
|
|
|
/// <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);
|
|
}
|