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:
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user