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
@@ -0,0 +1,208 @@
using MaBibli.Api.Data;
using MaBibli.Api.Services.Isbn;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Catalogue;
public interface IServiceBibliographie
{
/// <summary>
/// Bibliographie d'un auteur du catalogue, confrontée à ce que l'on possède et à ce que
/// l'appelant souhaite déjà. <c>null</c> si l'auteur n'existe pas.
/// </summary>
Task<BibliographieDto?> ObtenirAsync(int auteurId, string? utilisateur, CancellationToken ct = default);
}
/// <summary>
/// Bibliographie d'un auteur : ce que la BnF connaît de lui, mis en regard de l'étagère.
/// </summary>
/// <remarks>
/// Trois sources se rencontrent ici, et elles n'ont pas la même portée :
/// <list type="bullet">
/// <item>la <b>BnF</b>, source externe, ni possédée ni personnelle ;</item>
/// <item>le <b>catalogue</b>, commun au foyer — « possédé » veut dire possédé par tout le
/// monde ;</item>
/// <item>la <b>liste d'envies</b>, personnelle — « déjà souhaité » ne vaut que pour l'appelant.</item>
/// </list>
/// Deux membres du foyer voient donc la même colonne « possédé » et deux colonnes « souhaité »
/// différentes. C'est voulu, et c'est ce qui permet d'offrir un livre à l'autre.
/// </remarks>
public sealed class ServiceBibliographie(
MaBibliDbContext db, IBnfClient bnf, IServiceAuteurs auteurs) : IServiceBibliographie
{
public async Task<BibliographieDto?> ObtenirAsync(
int auteurId, string? utilisateur, CancellationToken ct = default)
{
var auteur = await auteurs.ObtenirAsync(auteurId, ct);
if (auteur is null)
{
return null;
}
var (resultat, avertissement) = await bnf.RechercherParAuteurAsync(auteur.Nom, ct);
// Ce que l'on possède DE CET AUTEUR. On ne compare pas à tout le catalogue : deux auteurs
// peuvent avoir écrit deux livres homonymes, et griser l'un à cause de l'autre serait un
// faux positif — bien plus gênant qu'un oubli, puisqu'il détourne d'un achat justifié.
var possedes = await db.LivreAuteurs
.AsNoTracking()
.Where(la => la.AuteurId == auteurId)
.Select(la => new { la.LivreId, la.Livre!.Titre, la.Livre.Isbn })
.ToListAsync(ct);
var parTitre = new Dictionary<string, int>();
var parIsbn = new Dictionary<string, int>();
foreach (var livre in possedes)
{
var cle = CleOeuvre.Cle(livre.Titre);
if (cle.Length > 0)
{
parTitre.TryAdd(cle, livre.LivreId);
}
// L'ISBN ne sert qu'en confirmation : il ne rapproche que la même édition exacte,
// alors que le titre rapproche l'œuvre. Il rattrape en revanche le cas où le titre
// saisi à la main diverge de celui de la BnF.
if (!string.IsNullOrWhiteSpace(livre.Isbn))
{
parIsbn.TryAdd(livre.Isbn, livre.LivreId);
}
}
var souhaits = await SouhaitsAsync(utilisateur, ct);
var oeuvres = Regrouper(resultat)
.Select(o => Confronter(o, parTitre, parIsbn, souhaits))
.OrderByDescending(o => AnneeTriable(o.Annee))
.ThenBy(o => o.Titre, StringComparer.CurrentCultureIgnoreCase)
.ToList();
return new BibliographieDto
{
Auteur = auteur,
Oeuvres = oeuvres,
NoticesAnnoncees = resultat.NombreAnnonce,
NoticesLues = resultat.NombreLu,
NoticesEcartees = resultat.EcarteesAutreAuteur + resultat.EcarteesTypeNonLivre,
Avertissement = avertissement,
};
}
/// <summary>Clés d'œuvre déjà souhaitées par l'appelant, avec l'identifiant de l'envie.</summary>
private async Task<Dictionary<string, int>> SouhaitsAsync(string? utilisateur, CancellationToken ct)
{
if (utilisateur is null)
{
return [];
}
var lignes = await db.LivresSouhaites
.AsNoTracking()
.Where(s => s.Utilisateur == utilisateur)
.Select(s => new { s.Id, s.TitreNormalise })
.ToListAsync(ct);
var index = new Dictionary<string, int>();
foreach (var ligne in lignes)
{
index.TryAdd(ligne.TitreNormalise, ligne.Id);
}
return index;
}
/// <summary>
/// Réunit les rééditions sous une seule œuvre.
/// </summary>
/// <remarks>
/// C'est le cœur de l'utilisabilité de l'écran : sans regroupement, Bernard Werber occupe
/// 183 lignes dont « Les fourmis » onze fois. Après regroupement, 51 lignes.
/// <para>
/// Le représentant retenu est la notice la <b>plus ancienne</b> : c'est l'édition originale,
/// donc l'année qui situe l'œuvre dans la carrière de l'auteur, et non celle du dernier
/// retirage en poche. Le titre et l'éditeur suivent la même notice pour rester cohérents,
/// mais l'ISBN est pris sur <b>n'importe quelle</b> notice qui en porte un : les éditions
/// anciennes n'en ont souvent aucun, et un ISBN de réédition reste utile pour commander.
/// </para>
/// </remarks>
public static IEnumerable<OeuvreBibliographie> Regrouper(ResultatBibliographie resultat) =>
resultat.Notices
.Where(n => n.CleTitre.Length > 0)
.GroupBy(n => n.CleTitre)
.Select(groupe =>
{
var ordonnees = groupe
.OrderBy(n => AnneeTriable(n.Annee) ?? int.MaxValue)
.ToList();
var reference = ordonnees[0];
return new OeuvreBibliographie
{
Titre = reference.Titre,
Annee = reference.Annee,
Editeur = reference.Editeur,
Isbn = ordonnees.Select(n => n.Isbn).FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)),
NombreEditions = groupe.Count(),
};
});
private static OeuvreBibliographie Confronter(
OeuvreBibliographie oeuvre,
IReadOnlyDictionary<string, int> parTitre,
IReadOnlyDictionary<string, int> parIsbn,
IReadOnlyDictionary<string, int> souhaits)
{
var cle = CleOeuvre.Cle(oeuvre.Titre);
int? livreId = parTitre.TryGetValue(cle, out var idTitre) ? idTitre : null;
if (livreId is null
&& !string.IsNullOrWhiteSpace(oeuvre.Isbn)
&& parIsbn.TryGetValue(oeuvre.Isbn, out var idIsbn))
{
livreId = idIsbn;
}
int? souhaitId = souhaits.TryGetValue(cle, out var idSouhait) ? idSouhait : null;
return oeuvre with
{
Possede = livreId is not null,
LivreId = livreId,
Souhaite = souhaitId is not null,
SouhaitId = souhaitId,
};
}
/// <summary>
/// Année exploitable pour le tri, extraite d'une date BnF.
/// </summary>
/// <remarks>
/// <c>dc:date</c> n'est pas un nombre : on rencontre « 2012 », mais aussi « [DL 2012] »,
/// « cop. 1991 » ou « 19..? ». On prend la première suite de quatre chiffres, et
/// <c>null</c> s'il n'y en a pas — une date illisible ne doit pas devenir l'an zéro et
/// remonter en tête de liste.
/// </remarks>
public static int? AnneeTriable(string? date)
{
if (string.IsNullOrWhiteSpace(date))
{
return null;
}
for (var i = 0; i + 4 <= date.Length; i++)
{
var tranche = date.AsSpan(i, 4);
if (int.TryParse(tranche, out var annee) && annee is >= 1000 and <= 2999)
{
return annee;
}
}
return null;
}
}