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>
307 lines
11 KiB
C#
307 lines
11 KiB
C#
using System.Globalization;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace MaBibli.Api.Services.Isbn;
|
|
|
|
/// <summary>
|
|
/// Nettoyage de la ponctuation ISBD des champs Dublin Core de la BnF.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Les champs BnF ne sont pas exploitables bruts. Règles validées le 2026-08-17 (CLAUDE.md) :
|
|
/// <list type="bullet">
|
|
/// <item><c>dc:title</c> : <c>Germinal / Émile Zola ; préface…</c> → <c>Germinal</c></item>
|
|
/// <item><c>dc:creator</c> : <c>Zola, Émile (1840-1902). Auteur du texte</c> → <c>Émile Zola</c></item>
|
|
/// <item><c>dc:publisher</c> : <c>le Livre de poche (Paris)</c> → <c>le Livre de poche</c></item>
|
|
/// </list>
|
|
/// Les valeurs déjà propres doivent traverser ces règles <b>inchangées</b>.
|
|
/// </remarks>
|
|
public static partial class NettoyageIsbd
|
|
{
|
|
[GeneratedRegex(@"\s*\([^)]*\)")]
|
|
private static partial Regex Parentheses();
|
|
|
|
[GeneratedRegex(@"\s*\([^)]*\)\s*$")]
|
|
private static partial Regex ParenthesesFinales();
|
|
|
|
/// <summary>
|
|
/// Un nombre collé à une mention de pages. Le nombre doit <b>précéder</b> « p. » : dans
|
|
/// « p. 45-90 », les chiffres décrivent un intervalle dans un volume, pas son épaisseur.
|
|
/// </summary>
|
|
[GeneratedRegex(@"(?<![\d\]])(\d{1,6})\s*(?:p\.|pages?\b)", RegexOptions.IgnoreCase)]
|
|
private static partial Regex Pagination();
|
|
|
|
/// <summary>
|
|
/// Titre : couper à la première mention de responsabilité (« / »).
|
|
/// </summary>
|
|
public static string? Titre(string? brut)
|
|
{
|
|
var s = brut?.Trim();
|
|
if (string.IsNullOrEmpty(s))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var i = s.IndexOf(" / ", StringComparison.Ordinal);
|
|
if (i >= 0)
|
|
{
|
|
s = s[..i];
|
|
}
|
|
|
|
s = s.Trim();
|
|
return s.Length == 0 ? null : s;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retire du titre le nom d'auteur qui y est collé en fin de chaîne.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Cas réel mesuré, ISBN <c>9782749942636</c> :
|
|
/// <c>dc:title</c> vaut « Le plus grand défi … (Nouvelle éd. augmentée) <b>Aurélien Barrau</b> »
|
|
/// alors que <c>dc:creator</c> vaut « Barrau, Aurélien (1973-....) ». Le nom est recollé
|
|
/// <b>sans</b> le séparateur ISBD « / », donc <see cref="Titre"/> ne peut rien y faire : la
|
|
/// notice est sale à la source.
|
|
/// <para>
|
|
/// L'heuristique est volontairement stricte — comparaison en <b>fin de chaîne</b> uniquement,
|
|
/// et seulement contre un auteur déjà nettoyé. Un titre qui contient légitimement un nom de
|
|
/// personne ailleurs (« Lettre à Émile Zola ») n'est pas touché, et un titre qui <i>est</i>
|
|
/// le nom de l'auteur (biographie, recueil) est laissé intact plutôt que réduit à rien.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static string? RetirerAuteurEnFin(string? titre, IEnumerable<string?> auteurs)
|
|
{
|
|
var s = titre?.Trim();
|
|
if (string.IsNullOrEmpty(s))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
foreach (var auteur in auteurs)
|
|
{
|
|
var nom = auteur?.Trim();
|
|
if (string.IsNullOrEmpty(nom) || nom.Length >= s.Length)
|
|
{
|
|
// Un nom aussi long que le titre ne laisserait rien : c'est une biographie
|
|
// ou un recueil, pas une notice sale.
|
|
continue;
|
|
}
|
|
|
|
var debut = s.Length - nom.Length;
|
|
|
|
// IgnoreNonSpace ignore les accents sans changer les longueurs, contrairement à une
|
|
// normalisation NFD : les indices calculés restent valables sur la chaîne d'origine.
|
|
var identique = string.Compare(
|
|
s, debut, nom, 0, nom.Length,
|
|
CultureInfo.InvariantCulture,
|
|
CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace) == 0;
|
|
|
|
if (!identique)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Le nom doit commencer sur une frontière de mot, sinon « …Barrau » couperait aussi
|
|
// dans un mot qui se termine par les mêmes lettres.
|
|
if (char.IsLetterOrDigit(s[debut - 1]))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var reste = s[..debut].TrimEnd(' ', ',', ';', '.', '-', '/', ':');
|
|
if (reste.Length > 0)
|
|
{
|
|
return reste;
|
|
}
|
|
}
|
|
|
|
return s;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Auteur : retirer les dates entre parenthèses, le rôle qui suit le point,
|
|
/// puis inverser « Nom, Prénom » en « Prénom Nom ».
|
|
/// </summary>
|
|
public static string? Auteur(string? brut)
|
|
{
|
|
var s = brut?.Trim();
|
|
if (string.IsNullOrEmpty(s))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// 1. Dates de vie et autres qualificatifs entre parenthèses : (1840-1902), (1932-....)
|
|
s = Parentheses().Replace(s, string.Empty).Trim();
|
|
|
|
// 2. Rôle après le point : « . Auteur du texte », « . Préfacier ».
|
|
var pointRole = IndexDuPointDeRole(s);
|
|
if (pointRole >= 0)
|
|
{
|
|
// Quand le rôle suit une initiale (« Cormen, Thomas H. Auteur du texte »), le point
|
|
// qui sert de séparateur appartient aussi au nom : on le conserve.
|
|
s = EstPointDInitiale(s, pointRole) ? s[..(pointRole + 1)] : s[..pointRole];
|
|
}
|
|
|
|
s = RetirerPonctuationFinale(s);
|
|
|
|
// 3. Inversion « Nom, Prénom » → « Prénom Nom » (sur la première virgule seulement).
|
|
var virgule = s.IndexOf(", ", StringComparison.Ordinal);
|
|
if (virgule > 0)
|
|
{
|
|
var nom = s[..virgule].Trim();
|
|
var prenom = s[(virgule + 2)..].Trim();
|
|
if (nom.Length > 0 && prenom.Length > 0)
|
|
{
|
|
s = $"{prenom} {nom}";
|
|
}
|
|
}
|
|
|
|
s = s.Trim();
|
|
return s.Length == 0 ? null : s;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Éditeur : retirer la ville entre parenthèses en fin de chaîne.
|
|
/// </summary>
|
|
public static string? Editeur(string? brut)
|
|
{
|
|
var s = brut?.Trim();
|
|
if (string.IsNullOrEmpty(s))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
s = ParenthesesFinales().Replace(s, string.Empty).Trim();
|
|
return s.Length == 0 ? null : s;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Nombre de pages lu dans la description matérielle (<c>dc:format</c>), ou <c>null</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Ce champ n'est pas un nombre, c'est une phrase</b> : la BnF y décrit le support
|
|
/// (« 1 vol. (113 p.) : ill., couv. ill. en coul. ; 18 cm », « 503 p. : couv. ill. ; 17 cm »,
|
|
/// mesuré sur les notices enregistrées sous <c>Fixtures/</c>). Les règles restent
|
|
/// volontairement <b>étroites</b> : on ne reconnaît qu'un nombre suivi de « p. » ou de
|
|
/// « page(s) », et <b>tout le reste rend <c>null</c></b> — le catalogue décrit aussi des
|
|
/// disques compacts et des dimensions en centimètres.
|
|
/// <para>
|
|
/// ⚠️ Le sens de 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 remarque. C'est pourquoi
|
|
/// « 1 vol. » ne rend pas 1, « 30 cm » ne rend pas 30, et « (p. 45-90) » — une pagination de
|
|
/// contribution, pas une épaisseur — ne rend rien du tout.
|
|
/// </para>
|
|
/// <para>
|
|
/// La valeur reste de toute façon <b>proposée</b> dans un champ modifiable, jamais imposée.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static int? NombreDePages(string? brut)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(brut))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var trouve = Pagination().Match(brut);
|
|
if (!trouve.Success)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (!int.TryParse(
|
|
trouve.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var pages))
|
|
{
|
|
// Un nombre à rallonge ne tient pas dans un int : ce n'est pas une pagination.
|
|
return null;
|
|
}
|
|
|
|
// Bornes de bon sens. Zéro page n'est pas une donnée, et au-delà de dix mille on a lu
|
|
// autre chose que l'épaisseur d'un livre.
|
|
return pages is > 0 and <= 10_000 ? pages : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Titre d'un <b>périodique</b> : couper la mention de responsabilité puis retirer le
|
|
/// qualificatif entre parenthèses.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Mesuré sur l'ISSN <c>2466-6718</c> le 2026-08-18 : la BnF donne
|
|
/// « <c>Médor (Bruxelles)</c> ». Le parenthésé n'est pas une ville d'édition mais le
|
|
/// <i>qualificatif de désambiguïsation</i> propre aux notices de revues — deux périodiques
|
|
/// peuvent porter le même titre. Il se retire comme la ville d'un éditeur, et pour la même
|
|
/// raison : ce qu'on montre à l'utilisateur doit ressembler à ce qui est écrit sur la
|
|
/// couverture du magazine qu'il tient en main.
|
|
/// </remarks>
|
|
public static string? TitrePeriodique(string? brut) => Editeur(Titre(brut));
|
|
|
|
/// <summary>
|
|
/// Retire les virgules et espaces de fin, et le point final <b>sauf</b> s'il appartient
|
|
/// à une initiale (« Cormen, Thomas H. » doit garder son point).
|
|
/// </summary>
|
|
private static string RetirerPonctuationFinale(string s)
|
|
{
|
|
s = s.TrimEnd(' ', ',');
|
|
|
|
while (s.EndsWith('.'))
|
|
{
|
|
if (EstPointDInitiale(s, s.Length - 1))
|
|
{
|
|
break;
|
|
}
|
|
|
|
s = s[..^1].TrimEnd(' ', ',');
|
|
}
|
|
|
|
return s;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Position du point qui introduit le rôle, ou -1.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Un point suivi d'une espace sépare le rôle du nom. Le point d'une initiale
|
|
/// (« Cormen, Thomas <b>H.</b> ») n'en est pas un — le couper amputerait le prénom.
|
|
/// <para>
|
|
/// Mais protéger l'initiale ne doit pas protéger le rôle qui la suit : « Cormen, Thomas H.
|
|
/// Auteur du texte » renvoyait auparavant « Thomas H. Auteur du texte Cormen ». Ce qui
|
|
/// tranche : dans « Nom, Prénom », la seule chose qui puisse suivre une initiale et faire
|
|
/// encore partie du nom est <b>une autre initiale</b> (« Tolkien, J. R. R. »). Tout le
|
|
/// reste est le rôle.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static int IndexDuPointDeRole(string s)
|
|
{
|
|
for (var i = 0; i < s.Length - 1; i++)
|
|
{
|
|
if (s[i] != '.' || s[i + 1] != ' ')
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (EstPointDInitiale(s, i) && CommenceParUneInitiale(s, i + 2))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
return i;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Le point en <paramref name="i"/> clôt-il une initiale (majuscule isolée) ?
|
|
/// </summary>
|
|
private static bool EstPointDInitiale(string s, int i)
|
|
=> i >= 1
|
|
&& char.IsUpper(s[i - 1])
|
|
&& (i == 1 || s[i - 2] == ' ' || s[i - 2] == '-' || s[i - 2] == '\'');
|
|
|
|
/// <summary>
|
|
/// Le texte à partir de <paramref name="debut"/> commence-t-il par une initiale
|
|
/// (une majuscule suivie d'un point ou de la fin de chaîne) ?
|
|
/// </summary>
|
|
private static bool CommenceParUneInitiale(string s, int debut)
|
|
=> debut < s.Length
|
|
&& char.IsUpper(s[debut])
|
|
&& (debut + 1 == s.Length || s[debut + 1] == '.');
|
|
}
|