Ajouter la liste d'envies personnelle, son export et la bibliographie par auteur

La liste d'envies vit dans une table séparée (LivreSouhaite) plutôt que dans un
statut de plus sur Livre : un livre souhaité n'est pas possédé, et le loger dans
Livres l'aurait fait entrer dans le catalogue, les compteurs et les prêts, au
prix d'un « et qui n'est pas souhaité » à répéter dans chaque lecture. La portée
est personnelle, comme le statut de lecture — mais ici Utilisateur est une vraie
frontière : toute lecture filtre dessus.

Bibliographie : SRU BnF interrogé par bib.author, vérifié le 2026-08-18. Deux
filtres mesurés sur des réponses réelles sont indispensables — le type de
document (l'index mêle livres audio, jeux et spectacles) et surtout l'auteur
réel de la notice, « all » rapprochant les mots sur l'ensemble des auteurs :
« Émile Zola » remonte sinon toute l'œuvre de sa fille Denise Le Blond-Zola.
Les rééditions sont regroupées par clé d'œuvre (183 notices Werber -> 51
œuvres). Le rapprochement avec l'étagère se fait par titre, pas par ISBN, qui
désigne une édition et non une œuvre ; ses limites sont dites à l'écran.

Export en deux formats, tous deux du texte sans dépendance : .txt groupé par
auteur pour la librairie, .csv à séparateur point-virgule et BOM pour le
tableur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-18 13:59:06 +02:00
co-authored by Claude Opus 5
parent 3a5a3a7763
commit 8b8fe3be6e
27 changed files with 3939 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
using System.Text.Json.Serialization;
namespace MaBibli.Shared.Dtos;
/// <summary>
/// Une envie telle qu'exposée par l'API.
/// </summary>
/// <remarks>
/// Il n'y a <b>aucun champ « utilisateur »</b>, et il ne doit jamais y en avoir : l'API ne rend
/// que les envies de l'appelant, qu'elle identifie par les en-têtes SSOwat. Exposer le
/// propriétaire laisserait croire qu'on peut demander celles d'un autre.
/// </remarks>
public record SouhaitDto
{
public required int Id { get; init; }
public required string Titre { get; init; }
public string? Auteur { get; init; }
public string? Editeur { get; init; }
public string? Annee { get; init; }
public string? Isbn { get; init; }
public string? CoverUrl { get; init; }
public string? Note { get; init; }
public required DateTime DateAjout { get; init; }
}
/// <summary>Charge utile d'ajout d'une envie.</summary>
/// <remarks>
/// Comme <see cref="EnregistrementLivre"/>, elle ne porte pas d'identité : le propriétaire est
/// déterminé par le serveur.
/// </remarks>
public record EnregistrementSouhait
{
public string Titre { get; set; } = string.Empty;
public string? Auteur { get; set; }
public string? Editeur { get; set; }
public string? Annee { get; set; }
public string? Isbn { get; set; }
public string? CoverUrl { get; set; }
public string? Note { get; set; }
}
/// <summary>Formats d'export de la liste d'envies.</summary>
/// <remarks>
/// Les deux usages décrits dans IDEES.md ne demandent pas la même chose : « l'emporter en
/// librairie » veut un texte qui se lit tel quel sur un téléphone, « la partager avant un
/// anniversaire » finit souvent dans un tableur pour se répartir les achats. Ni l'un ni l'autre
/// n'exige de dépendance : ce sont deux fichiers texte produits par du <c>string</c>.
/// </remarks>
public enum FormatExportSouhaits
{
/// <summary>Liste lisible telle quelle, groupée par auteur.</summary>
Texte = 0,
/// <summary>Tableau ouvrable dans un tableur.</summary>
Csv = 1,
}
/// <summary>Une œuvre de la bibliographie d'un auteur, confrontée à ce que l'on possède déjà.</summary>
public record OeuvreBibliographie
{
public required string Titre { get; init; }
public string? Annee { get; init; }
public string? Editeur { get; init; }
/// <summary>ISBN d'une des éditions relevées, quand la notice en portait un.</summary>
public string? Isbn { get; init; }
/// <summary>
/// Nombre de notices BnF réunies sous cette œuvre — c'est-à-dire le nombre de rééditions
/// relevées. Sert à repérer les œuvres majeures d'un auteur très réédité.
/// </summary>
public int NombreEditions { get; init; }
/// <summary>
/// Vrai si un livre du catalogue porte le même titre d'œuvre.
/// <b>Rapprochement par titre, donc faillible</b> — voir <c>CleOeuvre</c>.
/// </summary>
public bool Possede { get; init; }
/// <summary>Le livre possédé, s'il y en a un : de quoi ouvrir sa fiche.</summary>
public int? LivreId { get; init; }
/// <summary>Vrai si l'appelant a déjà cette œuvre dans sa liste d'envies.</summary>
public bool Souhaite { get; init; }
public int? SouhaitId { get; init; }
/// <summary>Ni possédé, ni déjà souhaité : ce qui reste à découvrir.</summary>
[JsonIgnore]
public bool ADecouvrir => !Possede && !Souhaite;
}
/// <summary>
/// Bibliographie d'un auteur du catalogue, telle que la BnF la connaît.
/// </summary>
/// <remarks>
/// Les compteurs ne sont pas décoratifs : ils disent à l'utilisateur ce qu'il regarde. Sur un
/// auteur très réédité (Émile Zola : 2692 notices), on ne montre qu'un <b>extrait classé par
/// pertinence</b>, et le taire ferait passer une liste partielle pour une bibliographie complète.
/// </remarks>
public record BibliographieDto
{
public required AuteurDto Auteur { get; init; }
public IReadOnlyList<OeuvreBibliographie> Oeuvres { get; init; } = [];
/// <summary>Nombre total de notices que la BnF déclare pour cet auteur.</summary>
public int NoticesAnnoncees { get; init; }
/// <summary>Nombre de notices réellement lues (plafonné).</summary>
public int NoticesLues { get; init; }
/// <summary>Notices écartées parce qu'aucun de leurs auteurs n'est celui demandé.</summary>
public int NoticesEcartees { get; init; }
/// <summary>Vrai si la BnF en annonce plus qu'on n'en a lu : la liste est un extrait.</summary>
public bool Tronquee => NoticesAnnoncees > NoticesLues;
/// <summary>Message à afficher quand la source n'a pas pu être interrogée normalement.</summary>
public string? Avertissement { get; init; }
}
+85
View File
@@ -0,0 +1,85 @@
using MaBibli.Shared.Textes;
namespace MaBibli.Shared.Entites;
/// <summary>
/// Un livre que l'on aimerait acquérir. <b>Personnel</b> : chacun sa liste.
/// </summary>
/// <remarks>
/// <b>Pourquoi une entité séparée plutôt qu'un statut de plus sur <see cref="Livre"/> ?</b>
/// Un livre souhaité n'est pas un livre possédé. S'il était une ligne de <c>Livres</c>, il
/// entrerait mécaniquement dans le catalogue, dans les compteurs, dans les prêts et dans le
/// cache hors-ligne — et il faudrait ajouter « et qui n'est pas souhaité » à <b>chaque</b>
/// lecture du catalogue. Un invariant qu'il faut réécrire à chaque requête finit toujours par
/// être oublié quelque part ; ici l'inventaire et l'envie ne se croisent tout simplement pas,
/// parce qu'ils ne vivent pas dans la même table.
/// <para>
/// C'est aussi la seule forme qui accepte une envie <b>sans ISBN ni édition arrêtée</b> : on
/// souhaite une œuvre (« le dernier Werber »), on possède un exemplaire. Un souhait n'a donc
/// ni <see cref="Format"/> ni couverture obligatoire, et ne peut pas être prêté.
/// </para>
/// <para>
/// La portée personnelle suit <see cref="StatutLecture"/>, dont la documentation annonçait déjà
/// ce voisinage : <see cref="Utilisateur"/> <b>est</b> une frontière, contrairement à
/// <see cref="Livre.AjoutePar"/>. On ne lit jamais la liste d'envies de quelqu'un d'autre —
/// c'est précisément ce qui permet d'offrir un livre sans vendre la mèche.
/// </para>
/// </remarks>
public class LivreSouhaite
{
public int Id { get; set; }
/// <summary>
/// <c>YNH_USER</c> du propriétaire de l'envie. <b>Frontière</b> : toute lecture filtre dessus.
/// </summary>
public string Utilisateur { get; set; } = string.Empty;
public string Titre { get; set; } = string.Empty;
/// <summary>
/// Titre mis à plat, servant à la fois au dédoublonnage et au rapprochement avec le
/// catalogue (« ai-je déjà ce livre ? »). Recalculé à chaque écriture.
/// </summary>
public string TitreNormalise { get; set; } = string.Empty;
/// <summary>
/// Auteur en <b>texte libre</b>, volontairement pas une clé étrangère vers <see cref="Auteur"/>.
/// </summary>
/// <remarks>
/// La table <c>Auteurs</c> décrit qui est <i>dans la bibliothèque</i> : y insérer les auteurs
/// souhaités les ferait apparaître sur l'écran des auteurs avec « 0 livre », et exposerait la
/// liste d'envies d'une personne à tout le foyer — l'inverse de ce qu'on veut. Le texte libre
/// survit en outre à la disparition de l'auteur du catalogue.
/// </remarks>
public string? Auteur { get; set; }
/// <summary>
/// Auteur mis à plat. <b>Jamais <c>null</c></b> (chaîne vide si l'auteur est inconnu) :
/// SQLite considère deux <c>NULL</c> comme distincts, et l'index unique laisserait alors
/// passer des doublons d'envies sans auteur.
/// </summary>
public string AuteurNormalise { get; set; } = string.Empty;
public string? Editeur { get; set; }
/// <summary>Année de publication, telle que la source la donne. Texte : la BnF écrit « 2012 », mais aussi « [DL 2012] ».</summary>
public string? Annee { get; set; }
/// <summary>ISBN d'une édition repérée, quand il y en a une. Facultatif : on souhaite une œuvre, pas forcément une édition.</summary>
public string? Isbn { get; set; }
public string? CoverUrl { get; set; }
/// <summary>Note libre : « demandé à Noël », « en poche seulement », « chez Untel ».</summary>
public string? Note { get; set; }
public DateTime DateAjout { get; set; }
public void RecalculerFormes()
{
Titre = Titre.Trim();
TitreNormalise = CleOeuvre.Cle(Titre);
Auteur = string.IsNullOrWhiteSpace(Auteur) ? null : Auteur.Trim();
AuteurNormalise = RapprochementAuteurs.Cle(Auteur);
}
}
+74
View File
@@ -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;
}
}