Files
mabibli/MaBibli.Api/Services/Catalogue/ServiceBibliographie.cs
T
mathieuandClaude Opus 5 8b8fe3be6e 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>
2026-08-18 13:59:06 +02:00

209 lines
7.7 KiB
C#

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;
}
}