MaBibli 1.0.0
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>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
namespace MaBibli.Shared.Textes;
|
||||
|
||||
/// <summary>
|
||||
/// Clé d'identification d'une <b>œuvre</b> à partir d'un titre, indépendamment de l'édition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C'est le rapprochement le moins mauvais dont on dispose, et il faut être clair sur ses limites.
|
||||
/// <para>
|
||||
/// <b>Pourquoi pas l'ISBN ?</b> Parce qu'il désigne une <i>édition</i>, pas une œuvre : le poche
|
||||
/// de 1998, le grand format de 1991 et la réédition de 2015 portent trois ISBN différents pour le
|
||||
/// même roman. Mesuré sur la bibliographie BnF de Bernard Werber : 183 notices de livres pour
|
||||
/// 51 œuvres, chacune revenant en moyenne trois fois sous trois ISBN. Comparer les ISBN
|
||||
/// répondrait donc « vous ne l'avez pas » à propos d'un livre posé sur l'étagère, ce qui est le
|
||||
/// pire résultat possible pour une liste d'envies.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Ce que la clé fait.</b> Elle coupe la mention de responsabilité ISBD (« <c> / </c> ») puis
|
||||
/// le sous-titre (« <c> : </c> »), et met le reste à plat. C'est ce qui réunit
|
||||
/// « Les thanatonautes », « Les thanatonautes : roman » et
|
||||
/// « Les thanatonautes / Bernard Werber ».
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Ce qu'elle rate</b>, et qu'il ne faut pas prétendre autrement :
|
||||
/// <list type="bullet">
|
||||
/// <item>Deux œuvres distinctes partageant un titre principal sont confondues (« Œuvres »,
|
||||
/// « Nouvelles »). Le risque est borné : la comparaison n'a lieu qu'<b>à auteur donné</b>.</item>
|
||||
/// <item>Un tome n'est pas rapproché de son recueil : « Troisième humanité » et
|
||||
/// « Troisième humanité. Tome 1 » restent deux lignes. Le point n'est pas coupé, faute de savoir
|
||||
/// distinguer « Tome 1 » d'un titre qui contient légitimement un point.</item>
|
||||
/// <item>Un titre retraduit ou changé au fil des rééditions n'est pas rapproché du tout.</item>
|
||||
/// <item>Une œuvre possédée à l'intérieur d'une intégrale n'est pas repérée : l'intégrale a son
|
||||
/// propre titre.</item>
|
||||
/// </list>
|
||||
/// Conséquence assumée : l'écran de bibliographie peut afficher comme « à découvrir » un livre
|
||||
/// déjà présent sous un autre titre. Il ne fait donc que <b>griser</b> ce qu'il reconnaît, sans
|
||||
/// jamais masquer une ligne — un faux négatif se voit et se corrige d'un coup d'œil, alors qu'une
|
||||
/// ligne masquée à tort serait invisible.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class CleOeuvre
|
||||
{
|
||||
/// <summary>Sépare le titre de la mention de responsabilité : « Germinal / Émile Zola ».</summary>
|
||||
private const string SeparateurResponsabilite = " / ";
|
||||
|
||||
/// <summary>Sépare le titre de son sous-titre : « Les thanatonautes : roman ».</summary>
|
||||
private const string SeparateurSousTitre = " : ";
|
||||
|
||||
/// <summary>
|
||||
/// Clé comparable d'un titre : « Les thanatonautes : roman » → <c>les thanatonautes</c>.
|
||||
/// </summary>
|
||||
public static string Cle(string? titre)
|
||||
{
|
||||
var s = titre?.Trim();
|
||||
if (string.IsNullOrEmpty(s))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
s = Tronquer(s, SeparateurResponsabilite);
|
||||
var sansSousTitre = Tronquer(s, SeparateurSousTitre);
|
||||
|
||||
// Un titre qui n'est QUE son sous-titre (« : roman ») ne doit pas se réduire à rien :
|
||||
// mieux vaut alors garder la chaîne entière que produire une clé vide, qui se
|
||||
// confondrait avec toutes les autres clés vides.
|
||||
var retenu = NormalisationTexte.Normaliser(sansSousTitre);
|
||||
return retenu.Length > 0 ? retenu : NormalisationTexte.Normaliser(s);
|
||||
}
|
||||
|
||||
private static string Tronquer(string s, string separateur)
|
||||
{
|
||||
var i = s.IndexOf(separateur, StringComparison.Ordinal);
|
||||
return i >= 0 ? s[..i].Trim() : s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace MaBibli.Shared.Textes;
|
||||
|
||||
/// <summary>
|
||||
/// Découpage du champ texte qui porte <b>plusieurs thèmes</b> d'un livre.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Le séparateur est le point-virgule</b>, comme celui des auteurs
|
||||
/// (<c>EnregistrementLivre.SeparateurAuteurs</c>) : deux champs voisins du même formulaire ne
|
||||
/// doivent pas se saisir de deux façons. La virgule reste acceptée en repli — elle était le
|
||||
/// séparateur jusqu'au 2026-08-21, et un thème (« dark fantasy ») n'en contient jamais.
|
||||
/// <para>
|
||||
/// ⚠️ <b>Ce découpage ne convient pas à tout champ à valeurs multiples.</b> Les articles à la
|
||||
/// une d'un numéro y sont passés jusqu'au 2026-08-21 et en sont sortis : un titre contient
|
||||
/// souvent une virgule, et surtout un champ relié à chaque frappe sur une propriété qui découpe
|
||||
/// puis recompose <b>réécrit la saisie sous les doigts</b> — le séparateur lui-même devient
|
||||
/// intapable. Ils se saisissent maintenant un par un.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ListeSaisie
|
||||
{
|
||||
public const char Separateur = ';';
|
||||
|
||||
/// <summary>Recompose la ligne de saisie à partir des valeurs enregistrées.</summary>
|
||||
public static string Joindre(IEnumerable<string> valeurs) =>
|
||||
string.Join($"{Separateur} ", valeurs);
|
||||
|
||||
/// <summary>
|
||||
/// Découpe la saisie, élague, écarte les vides et les <b>doublons au sens de la
|
||||
/// normalisation</b> — « Fantasy » et « fantasy » sont la même étiquette, et le premier
|
||||
/// libellé tapé est celui qui reste.
|
||||
/// </summary>
|
||||
public static List<string> Decouper(string? saisie)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(saisie))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
char[] separateurs = [Separateur, ','];
|
||||
var vus = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
return saisie
|
||||
.Split(separateurs, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(valeur => vus.Add(NormalisationTexte.Normaliser(valeur)))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace MaBibli.Shared.Textes;
|
||||
|
||||
/// <summary>
|
||||
/// Mise à plat d'un texte pour la recherche et le regroupement : minuscules, sans accents,
|
||||
/// sans ponctuation, espaces réduits.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Pourquoi une forme stockée plutôt qu'une comparaison à la volée ?</b> SQLite ne sait pas
|
||||
/// comparer sans tenir compte des accents : sa collation <c>NOCASE</c> ne couvre que l'ASCII, et
|
||||
/// il n'existe pas de <c>unaccent()</c> intégré. Faire le travail en C# à l'écriture, dans une
|
||||
/// colonne dédiée, est la seule façon d'obtenir « Emile » → « Émile » <b>sans</b> charger tout
|
||||
/// le catalogue en mémoire à chaque recherche.
|
||||
/// <para>
|
||||
/// Conséquence à ne pas oublier : toute écriture doit recalculer la forme normalisée, et le
|
||||
/// terme cherché doit passer par la <b>même</b> fonction que la valeur stockée.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class NormalisationTexte
|
||||
{
|
||||
/// <summary>
|
||||
/// Forme comparable d'un texte quelconque : « Émile ZOLA » → « emile zola ».
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// La ponctuation devient une espace plutôt que de disparaître : « P.F. Hamilton » doit
|
||||
/// donner trois mots (<c>p f hamilton</c>) et non un seul (<c>pf hamilton</c>), sans quoi
|
||||
/// le rapprochement par initiales serait impossible.
|
||||
/// <para>
|
||||
/// Les <b>ligatures</b> <c>œ</c> et <c>æ</c> sont décomposées en <c>oe</c> et <c>ae</c>.
|
||||
/// ⚠️ 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, <i>L'Œuvre</i> de Zola — que la BnF
|
||||
/// écrit avec la ligature — resterait introuvable à qui tape « oeuvre » au clavier.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static string Normaliser(string? brut)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(brut))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// NFD sépare la lettre de son accent ; on ne garde ensuite que la lettre.
|
||||
var decompose = brut.Normalize(NormalizationForm.FormD);
|
||||
var sortie = new StringBuilder(decompose.Length);
|
||||
var espaceEnAttente = false;
|
||||
|
||||
foreach (var c in decompose)
|
||||
{
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
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)
|
||||
{
|
||||
sortie.Append(' ');
|
||||
}
|
||||
|
||||
espaceEnAttente = false;
|
||||
sortie.Append(char.ToLowerInvariant(c));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tout le reste (espaces, points, virgules, tirets…) est un séparateur de mots.
|
||||
espaceEnAttente = true;
|
||||
}
|
||||
|
||||
return sortie.ToString().Normalize(NormalizationForm.FormC);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Développement d'une ligature, ou <c>null</c> si le caractère n'en est pas une.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Volontairement limité à <c>œ</c> et <c>æ</c>, seules ligatures courantes du français.
|
||||
/// Les ligatures typographiques de présentation (<c>fi</c>, <c>fl</c>) ne sont pas traitées :
|
||||
/// elles n'apparaissent pas dans les catalogues, et rien n'a montré le besoin.
|
||||
/// </remarks>
|
||||
private static string? Ligature(char c) => c switch
|
||||
{
|
||||
'œ' or 'Œ' => "oe",
|
||||
'æ' or 'Æ' => "ae",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Mots de la forme normalisée, sans mot vide.</summary>
|
||||
public static string[] Mots(string? brut)
|
||||
{
|
||||
var normalise = Normaliser(brut);
|
||||
return normalise.Length == 0
|
||||
? []
|
||||
: normalise.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
namespace MaBibli.Shared.Textes;
|
||||
|
||||
/// <summary>Verdict du rapprochement de deux noms d'auteur.</summary>
|
||||
public enum NiveauRapprochement
|
||||
{
|
||||
/// <summary>Rien de commun : ce sont deux auteurs distincts.</summary>
|
||||
Aucun = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Variante <b>sûre</b> : même nom à la casse, aux accents, à l'ordre ou à l'abréviation près.
|
||||
/// Peut être fusionné automatiquement.
|
||||
/// </summary>
|
||||
Certain = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Rapprochement <b>plausible mais ambigu</b> : un nom est contenu dans l'autre
|
||||
/// (« Hamilton » vers « Peter F. Hamilton »). À <b>proposer</b>, jamais à appliquer seul.
|
||||
/// </summary>
|
||||
Suggere = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Règles de rapprochement des noms d'auteurs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Décision actée dans CLAUDE.md : le regroupement <b>automatique</b> se limite aux variantes
|
||||
/// sûres — casse, accents, ordre <c>Nom, Prénom</c> ↔ <c>Prénom Nom</c>, initiales abrégées
|
||||
/// quand le reste concorde. Tout le reste est <b>proposé</b> à l'utilisateur.
|
||||
/// <para>
|
||||
/// La raison est asymétrique : ne pas fusionner laisse deux fiches à réunir d'un clic, alors
|
||||
/// qu'une fusion erronée mélange les livres de deux personnes et se défait mal. Dans le doute,
|
||||
/// on ne fusionne pas.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class RapprochementAuteurs
|
||||
{
|
||||
/// <summary>
|
||||
/// Clé de regroupement : mots normalisés <b>triés</b>, ce qui rend l'ordre indifférent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// « Zola, Émile » et « Émile Zola » donnent tous deux <c>emile zola</c>. C'est cette clé qui
|
||||
/// porte l'unicité en base : deux auteurs ne peuvent pas coexister avec la même.
|
||||
/// <para>
|
||||
/// Elle ne couvre <b>pas</b> les initiales (<c>p f hamilton</c> ≠ <c>f hamilton peter</c>) :
|
||||
/// ce cas relève de <see cref="Comparer"/>, qui aligne les mots au lieu de les hacher.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static string Cle(string? nom)
|
||||
{
|
||||
var mots = NormalisationTexte.Mots(nom);
|
||||
Array.Sort(mots, StringComparer.Ordinal);
|
||||
return string.Join(' ', mots);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compare deux noms d'auteur et dit s'ils désignent sûrement, peut-être, ou sûrement pas
|
||||
/// la même personne.
|
||||
/// </summary>
|
||||
public static NiveauRapprochement Comparer(string? a, string? b)
|
||||
{
|
||||
var motsA = NormalisationTexte.Mots(a);
|
||||
var motsB = NormalisationTexte.Mots(b);
|
||||
|
||||
if (motsA.Length == 0 || motsB.Length == 0)
|
||||
{
|
||||
return NiveauRapprochement.Aucun;
|
||||
}
|
||||
|
||||
if (motsA.Length == motsB.Length)
|
||||
{
|
||||
// Même nombre de mots : soit tout s'aligne (variante sûre), soit ce sont deux personnes.
|
||||
// On exige au moins un mot entier commun : « P. H. » et « Peter Hamilton » s'alignent
|
||||
// par les seules initiales, ce qui ne concorde sur rien de vérifiable.
|
||||
return Aligne(motsA, motsB, out var pleinPartage) && pleinPartage
|
||||
? NiveauRapprochement.Certain
|
||||
: NiveauRapprochement.Aucun;
|
||||
}
|
||||
|
||||
// Nombres de mots différents : au mieux l'un est un fragment de l'autre
|
||||
// (« Hamilton » ⊂ « Peter F. Hamilton »). Jamais sûr — deux personnes partagent
|
||||
// couramment un patronyme.
|
||||
var (court, longue) = motsA.Length < motsB.Length ? (motsA, motsB) : (motsB, motsA);
|
||||
|
||||
return Aligne(court, longue, out var motPleinPartage) && motPleinPartage
|
||||
? NiveauRapprochement.Suggere
|
||||
: NiveauRapprochement.Aucun;
|
||||
}
|
||||
|
||||
/// <summary>Raccourci : les deux noms sont-ils une variante sûre l'un de l'autre ?</summary>
|
||||
public static bool SontLeMemeAuteur(string? a, string? b) =>
|
||||
Comparer(a, b) == NiveauRapprochement.Certain;
|
||||
|
||||
/// <summary>
|
||||
/// Deux jeux de noms ont-ils au moins un auteur en commun ?
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sert partout où l'on rapproche deux fiches par leur titre : le titre seul ne suffit
|
||||
/// jamais — deux recueils « Nouvelles » sans rapport se confondraient — mais un auteur
|
||||
/// commun tranche.
|
||||
/// <para>
|
||||
/// <b>Convention</b> : deux jeux <b>tous deux vides</b> concordent. Le titre est alors la
|
||||
/// seule information disponible des deux côtés, et exiger un auteur reviendrait à ne jamais
|
||||
/// rien rapprocher. Un jeu vide face à un jeu renseigné, en revanche, ne concorde pas :
|
||||
/// l'information manque d'un seul côté, ce qui n'est pas une raison de conclure.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static bool PartagentUnAuteur(IEnumerable<string?> a, IEnumerable<string?> b)
|
||||
{
|
||||
var gauche = a.Where(n => !string.IsNullOrWhiteSpace(n)).ToList();
|
||||
var droite = b.Where(n => !string.IsNullOrWhiteSpace(n)).ToList();
|
||||
|
||||
if (gauche.Count == 0 && droite.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return gauche.Any(g => droite.Any(d => SontLeMemeAuteur(g, d)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cherche à placer chaque mot de <paramref name="aPlacer"/> sur un mot distinct de
|
||||
/// <paramref name="cible"/>, par égalité ou par initiale.
|
||||
/// </summary>
|
||||
/// <param name="motPleinPartage">
|
||||
/// Vrai si au moins un couple aligné est une égalité de <b>mots entiers</b>. Sans cette
|
||||
/// garantie, « P. » se rapprocherait de n'importe quel prénom commençant par P — une
|
||||
/// suggestion sans valeur qui n'apprendrait rien à l'utilisateur.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// Recherche exhaustive avec retour arrière. Les noms de personnes tiennent en quelques mots,
|
||||
/// le coût est négligeable ; une correspondance gloutonne, elle, échouerait sur
|
||||
/// « P. Peter » vs « Peter Paul » en consommant le mauvais mot en premier.
|
||||
/// </remarks>
|
||||
private static bool Aligne(string[] aPlacer, string[] cible, out bool motPleinPartage)
|
||||
{
|
||||
var pris = new bool[cible.Length];
|
||||
motPleinPartage = false;
|
||||
return Placer(aPlacer, cible, pris, 0, ref motPleinPartage);
|
||||
}
|
||||
|
||||
private static bool Placer(
|
||||
string[] aPlacer, string[] cible, bool[] pris, int index, ref bool motPleinPartage)
|
||||
{
|
||||
if (index == aPlacer.Length)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var mot = aPlacer[index];
|
||||
|
||||
for (var i = 0; i < cible.Length; i++)
|
||||
{
|
||||
if (pris[i])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var autre = cible[i];
|
||||
var egalite = mot == autre;
|
||||
var initiale = !egalite && EstInitialeDe(mot, autre);
|
||||
|
||||
if (!egalite && !initiale)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
pris[i] = true;
|
||||
var pleinAvant = motPleinPartage;
|
||||
motPleinPartage = pleinAvant || (egalite && mot.Length > 1);
|
||||
|
||||
if (Placer(aPlacer, cible, pris, index + 1, ref motPleinPartage))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
pris[i] = false;
|
||||
motPleinPartage = pleinAvant;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>« p » est l'initiale de « peter ». Une lettre seule d'un côté, le mot de l'autre.</summary>
|
||||
private static bool EstInitialeDe(string a, string b) =>
|
||||
(a.Length == 1 && b.Length > 1 && b[0] == a[0])
|
||||
|| (b.Length == 1 && a.Length > 1 && a[0] == b[0]);
|
||||
}
|
||||
Reference in New Issue
Block a user