Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts, scan de code-barres, consultation hors-ligne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
418 lines
15 KiB
C#
418 lines
15 KiB
C#
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
|
|
{
|
|
/// <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);
|
|
|
|
Task<BibliographieDto?> ObtenirNouveautesAsync(
|
|
int auteurId, string? utilisateur, CancellationToken ct = default);
|
|
|
|
Task<bool> MasquerAsync(int auteurId, string titre, string? utilisateur, CancellationToken ct = default);
|
|
|
|
Task<bool> DemasquerAsync(int auteurId, string titre, 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 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<BibliographieDto?> 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<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);
|
|
}
|
|
|
|
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<bool> 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<bool> 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;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
private async Task<HashSet<string>> 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);
|
|
}
|
|
|
|
/// <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,
|
|
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<string, int> parTitre,
|
|
IReadOnlyDictionary<string, int> parIsbn,
|
|
IReadOnlyDictionary<string, int> souhaits,
|
|
IReadOnlySet<string> 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<NoticeAuteur> notices)
|
|
{
|
|
var annees = new List<int>();
|
|
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<OeuvreBibliographie> FiltrerNouveautes(
|
|
IEnumerable<OeuvreBibliographie> 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();
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|