Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts, scan de code-barres, consultation hors-ligne. 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);
|
|
}
|