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