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>
209 lines
7.7 KiB
C#
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;
|
|
}
|
|
}
|