using MaBibli.Api.Data;
using MaBibli.Api.Services.Isbn;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using MaBibli.Shared.Isbn;
using MaBibli.Shared.Textes;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Catalogue;
public interface IServiceBibliographie
{
///
/// Bibliographie d'un auteur du catalogue, confrontée à ce que l'on possède et à ce que
/// l'appelant souhaite déjà. null si l'auteur n'existe pas.
///
Task ObtenirAsync(int auteurId, string? utilisateur, CancellationToken ct = default);
Task ObtenirNouveautesAsync(
int auteurId, string? utilisateur, CancellationToken ct = default);
Task MasquerAsync(int auteurId, string titre, string? utilisateur, CancellationToken ct = default);
Task DemasquerAsync(int auteurId, string titre, string? utilisateur, CancellationToken ct = default);
}
///
/// Bibliographie d'un auteur : ce que la BnF connaît de lui, mis en regard de l'étagère.
///
///
/// Trois sources se rencontrent ici, et elles n'ont pas la même portée :
///
/// - la BnF, source externe, ni possédée ni personnelle ;
/// - le catalogue, commun au foyer — « possédé » veut dire possédé par tout le
/// monde ;
/// - la liste d'envies, personnelle — « déjà souhaité » ne vaut que pour l'appelant.
///
/// 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.
///
public sealed class ServiceBibliographie(
MaBibliDbContext db, IBnfClient bnf, IServiceAuteurs auteurs) : IServiceBibliographie
{
public async Task 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();
var parIsbn = new Dictionary();
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 masquees = await MasqueesAsync(auteurId, utilisateur, ct);
var oeuvres = Regrouper(resultat)
.Select(o => Confronter(o, parTitre, parIsbn, souhaits, masquees))
.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,
// Transmis tel quel : c'est l'écran qui décide quoi en dire, mais lui seul ne
// pourrait pas deviner qu'une liste vide vient d'une panne.
Etat = resultat.Etat,
};
}
public async Task ObtenirNouveautesAsync(
int auteurId, string? utilisateur, CancellationToken ct = default)
{
var auteur = await auteurs.ObtenirAsync(auteurId, ct);
if (auteur is null)
{
return null;
}
var recherche = bnf is IBnfClientNouveautes etendu
? await etendu.RechercherParAuteurNouveautesAsync(auteur.Nom, ct)
: await bnf.RechercherParAuteurAsync(auteur.Nom, ct);
var resultat = recherche.Resultat;
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();
var parIsbn = new Dictionary();
foreach (var livre in possedes)
{
var cle = CleOeuvre.Cle(livre.Titre);
if (cle.Length > 0)
{
parTitre.TryAdd(cle, livre.LivreId);
}
if (!string.IsNullOrWhiteSpace(livre.Isbn))
{
parIsbn.TryAdd(livre.Isbn, livre.LivreId);
}
}
var souhaits = await SouhaitsAsync(utilisateur, ct);
var masquees = await MasqueesAsync(auteurId, utilisateur, ct);
var oeuvres = Regrouper(resultat)
.Select(o => Confronter(o, parTitre, parIsbn, souhaits, masquees))
.ToList();
var seuil = AnneeSeuilNouveautes(
possedes.Select(l => (l.Titre, l.Isbn)), resultat.Notices);
// Une notice d'édition originale ne date pas la dernière édition de l'œuvre. Le
// regroupement conserve donc AnneeDerniereEdition, qui est la seule date pertinente
// pour répondre à « paru depuis ».
var nouveautes = FiltrerNouveautes(oeuvres, seuil);
return new BibliographieDto
{
Auteur = auteur,
Oeuvres = nouveautes,
NoticesAnnoncees = resultat.NombreAnnonce,
NoticesLues = resultat.NombreLu,
NoticesEcartees = resultat.EcarteesAutreAuteur + resultat.EcarteesTypeNonLivre,
Avertissement = recherche.Avertissement,
Etat = resultat.Etat,
EstNouveautes = true,
AnneeSeuilNouveautes = seuil,
SeuilNouveautesInconnu = seuil is null && possedes.Count > 0,
};
}
public async Task MasquerAsync(
int auteurId, string titre, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return false;
}
var cle = CleOeuvre.Cle(titre);
if (cle.Length == 0)
{
return false;
}
var existe = await db.BibliographiesMasquees.AnyAsync(
m => m.AuteurId == auteurId && m.Utilisateur == utilisateur && m.TitreNormalise == cle, ct);
if (existe)
{
return true;
}
db.BibliographiesMasquees.Add(new BibliographieMasquee
{
AuteurId = auteurId,
Utilisateur = utilisateur,
TitreNormalise = cle,
});
await db.SaveChangesAsync(ct);
return true;
}
public async Task DemasquerAsync(
int auteurId, string titre, string? utilisateur, CancellationToken ct = default)
{
if (utilisateur is null)
{
return false;
}
var cle = CleOeuvre.Cle(titre);
var ligne = await db.BibliographiesMasquees.FirstOrDefaultAsync(
m => m.AuteurId == auteurId && m.Utilisateur == utilisateur && m.TitreNormalise == cle, ct);
if (ligne is null)
{
return false;
}
db.BibliographiesMasquees.Remove(ligne);
await db.SaveChangesAsync(ct);
return true;
}
/// Clés d'œuvre déjà souhaitées par l'appelant, avec l'identifiant de l'envie.
private async Task> 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();
foreach (var ligne in lignes)
{
index.TryAdd(ligne.TitreNormalise, ligne.Id);
}
return index;
}
private async Task> MasqueesAsync(
int auteurId, string? utilisateur, CancellationToken ct)
{
if (utilisateur is null)
{
return [];
}
var cles = await db.BibliographiesMasquees
.AsNoTracking()
.Where(m => m.AuteurId == auteurId && m.Utilisateur == utilisateur)
.Select(m => m.TitreNormalise)
.ToListAsync(ct);
return cles.ToHashSet(StringComparer.Ordinal);
}
///
/// Réunit les rééditions sous une seule œuvre.
///
///
/// 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.
///
/// Le représentant retenu est la notice la plus ancienne : 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 n'importe quelle notice qui en porte un : les éditions
/// anciennes n'en ont souvent aucun, et un ISBN de réédition reste utile pour commander.
///
///
public static IEnumerable 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,
AnneeDerniereEdition = ordonnees
.Select(n => AnneeTriable(n.Annee))
.Where(a => a is not null)
.Select(a => a!.Value)
.DefaultIfEmpty()
.Max() is var annee && annee > 0 ? annee : null,
Editeur = reference.Editeur,
Isbn = ordonnees.Select(n => n.Isbn).FirstOrDefault(i => !string.IsNullOrWhiteSpace(i)),
NombreEditions = groupe.Count(),
};
});
private static OeuvreBibliographie Confronter(
OeuvreBibliographie oeuvre,
IReadOnlyDictionary parTitre,
IReadOnlyDictionary parIsbn,
IReadOnlyDictionary souhaits,
IReadOnlySet masquees)
{
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,
Masquee = masquees.Contains(cle),
};
}
public static int? AnneeSeuilNouveautes(
IEnumerable<(string Titre, string? Isbn)> possedes,
IReadOnlyList notices)
{
var annees = new List();
foreach (var livre in possedes)
{
var correspondantes = !string.IsNullOrWhiteSpace(livre.Isbn)
? notices.Where(n => MemesIsbn(n.Isbn, livre.Isbn))
: notices.Where(n => n.CleTitre == CleOeuvre.Cle(livre.Titre));
annees.AddRange(correspondantes
.Select(n => AnneeTriable(n.Annee))
.Where(a => a is not null)
.Select(a => a!.Value));
}
return annees.Count == 0 ? null : annees.Max();
}
private static bool MemesIsbn(string? gauche, string? droite)
{
var a = IsbnUtils.Normaliser(gauche);
var b = IsbnUtils.Normaliser(droite);
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b))
{
return false;
}
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return IsbnUtils.TryConvertirEnIsbn10(a, out var a10)
&& string.Equals(a10, b, StringComparison.OrdinalIgnoreCase)
|| IsbnUtils.TryConvertirEnIsbn10(b, out var b10)
&& string.Equals(b10, a, StringComparison.OrdinalIgnoreCase);
}
public static IReadOnlyList FiltrerNouveautes(
IEnumerable oeuvres, int? seuil)
{
if (seuil is null)
{
return [];
}
return oeuvres
.Where(o => o.AnneeDerniereEdition > seuil && o.ADecouvrir)
.OrderByDescending(o => o.AnneeDerniereEdition)
.ThenBy(o => o.Titre, StringComparer.CurrentCultureIgnoreCase)
.ToList();
}
///
/// Année exploitable pour le tri, extraite d'une date BnF.
///
///
/// dc:date 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
/// null 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.
///
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;
}
}