Ajoute ISBN, tris et nouveautés d'auteur
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
parent
359f4f59c6
commit
6b8f08ef26
@@ -1786,6 +1786,30 @@ Une œuvre bibliographique non possédée n'a pas de fiche locale. Son titre est
|
||||
qui déplie inline son ISBN et son nombre d'éditions, tandis que les œuvres possédées ouvrent
|
||||
leur fiche via la carte partagée.
|
||||
|
||||
## Lot E — trouver ce qu'on veut souhaiter (2026-08-20)
|
||||
|
||||
L'écran `/souhaits/ajout` affiche désormais la couverture et l'ISBN formaté de chaque résultat
|
||||
BnF. Le tri est local, sans nouvel appel réseau : date (récent en premier), titre ou éditeur.
|
||||
L'ISBN reste nu dans les données envoyées et seul `FormatageIsbn.Afficher` intervient à l'écran.
|
||||
|
||||
La recherche par auteur expose un bouton **Nouveautés** depuis la bibliographie. Elle utilise un
|
||||
endpoint dédié (`/api/auteurs/{id}/bibliographie/nouveautes`) afin de ne pas modifier le contrat
|
||||
de la bibliographie générale. Les résultats sont les œuvres non possédées et non déjà souhaitées
|
||||
dont la dernière édition BnF connue est postérieure à l'édition la plus récente possédée.
|
||||
|
||||
⚠️ Le représentant d'une œuvre reste la notice la plus ancienne : `OeuvreBibliographie.Annee`
|
||||
reste donc cette date, tandis que `AnneeDerniereEdition` conserve le maximum des dates de toutes
|
||||
les notices regroupées. C'est cette seconde valeur qui sert au filtre « paru depuis ». Le seuil
|
||||
est recherché d'abord par ISBN (ISBN-13 et ISBN-10 sont rapprochés), puis par clé d'œuvre.
|
||||
|
||||
La bibliographie générale conserve son plafond de deux pages (200 notices). Pour les nouveautés,
|
||||
la BnF est paginée jusqu'à **10 pages de 100 notices (1000 maximum)**, ou moins si le nombre
|
||||
annoncé est inférieur. La première page donne le total, puis les pages nécessaires sont lancées
|
||||
en parallèle. Ce bornage est volontaire : le SRU est classé par pertinence et ne permet pas de
|
||||
déduire qu'une page suivante ne contient plus de date récente ; au-delà de 1000, la liste reste
|
||||
un extrait signalé par `Tronquee`, sans boucle ni appels indéfinis. Une source muette conserve
|
||||
les mêmes `EtatSourceBibliographie` et motifs que la bibliographie normale.
|
||||
|
||||
## Installation YunoHost — éprouvée en production le 2026-08-18
|
||||
|
||||
Le paquet vit dans le dépôt `mabibli_ynh` (voir « Deux dépôts distincts »). Installé,
|
||||
|
||||
@@ -20,9 +20,9 @@ Le travail visuel de fond (couleurs, typographie, mise en page) est repoussé vo
|
||||
|
||||
La bibliographie par auteur est implémentée (voir `CLAUDE.md`). Deux pistes non traitées :
|
||||
|
||||
- **Plafond de 200 notices.** Suffisant pour un auteur contemporain (Werber : 203, Nothomb : 277),
|
||||
très insuffisant pour un classique (Zola : 2692). L'écran le dit, mais on pourrait charger les
|
||||
pages suivantes à la demande plutôt que d'annoncer un extrait.
|
||||
- **Plafond de 200 notices.** Traité pour la recherche « nouveautés d'un auteur » du lot E :
|
||||
elle charge jusqu'à 1000 notices avec un bornage explicite. La bibliographie générale reste
|
||||
plafonnée à 200 et continue d'annoncer honnêtement qu'elle est tronquée.
|
||||
- **OpenLibrary en second rideau.** La BnF ne connaît pas les auteurs étrangers non traduits ;
|
||||
l'écran affiche alors une liste vide et l'explique. OpenLibrary expose les œuvres d'un auteur
|
||||
(`/authors/{id}/works.json`) et pourrait prendre le relais — à ne faire que si le cas se
|
||||
@@ -211,6 +211,8 @@ Aujourd'hui elle se consulte ; les cinq items la font servir à **constituer** u
|
||||
|
||||
## Lot E — Trouver ce qu'on veut souhaiter
|
||||
|
||||
✅ Traité le 2026-08-20 : voir `CLAUDE.md`, section « Lot E — trouver ce qu'on veut souhaiter ».
|
||||
|
||||
L'écran `/souhaits/ajout` rend aujourd'hui une liste plate.
|
||||
|
||||
- **E1. ISBN et couverture dans les résultats** de recherche. ⚠️ L'ISBN existe déjà dans la
|
||||
|
||||
@@ -180,6 +180,27 @@ public static class SouhaitsEndpoints
|
||||
.Produces<BibliographieDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
routes.MapGet("/api/auteurs/{id:int}/bibliographie/nouveautes", async (
|
||||
int id,
|
||||
IServiceBibliographie service,
|
||||
IFournisseurUtilisateur utilisateurs,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var bibliographie = await service.ObtenirNouveautesAsync(
|
||||
id, utilisateurs.Obtenir().Identifiant, ct);
|
||||
|
||||
return bibliographie is null ? Results.NotFound() : Results.Ok(bibliographie);
|
||||
})
|
||||
.WithName("NouveautesAuteur")
|
||||
.WithTags("Auteurs")
|
||||
.WithSummary("Les œuvres de cet auteur parues depuis le livre le plus récent possédé.")
|
||||
.WithDescription(
|
||||
"La date du représentant d'une œuvre est celle de l'édition la plus ancienne ; "
|
||||
+ "la sélection compare donc la date la plus récente des éditions connues. "
|
||||
+ "La recherche étendue est bornée à 1000 notices BnF.")
|
||||
.Produces<BibliographieDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
routes.MapPost("/api/auteurs/{id:int}/bibliographie/masque", async (
|
||||
int id,
|
||||
MasquageBibliographie saisie,
|
||||
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
|
||||
@@ -15,6 +16,9 @@ public interface IServiceBibliographie
|
||||
/// </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);
|
||||
@@ -101,6 +105,70 @@ public sealed class ServiceBibliographie(
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -222,6 +290,12 @@ public sealed class ServiceBibliographie(
|
||||
{
|
||||
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(),
|
||||
@@ -258,6 +332,61 @@ public sealed class ServiceBibliographie(
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -45,10 +45,19 @@ public interface IBnfClient
|
||||
string titre, string? auteur, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension de la recherche par auteur utilisée uniquement pour les nouveautés.
|
||||
/// </summary>
|
||||
public interface IBnfClientNouveautes
|
||||
{
|
||||
Task<(ResultatBibliographie Resultat, string? Avertissement)>
|
||||
RechercherParAuteurNouveautesAsync(string auteur, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client de l'API SRU du catalogue général de la BnF (gratuite, sans clé).
|
||||
/// </summary>
|
||||
public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnfClient
|
||||
public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnfClient, IBnfClientNouveautes
|
||||
{
|
||||
/// <summary>Nom du client typé enregistré dans <c>IHttpClientFactory</c>.</summary>
|
||||
public const string NomHttpClient = "bnf";
|
||||
@@ -211,6 +220,12 @@ public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnf
|
||||
public const int NoticesParPage = 100;
|
||||
|
||||
public const int PagesMaximum = 2;
|
||||
public const int PagesMaximumNouveautes = 10;
|
||||
|
||||
public static int NombrePagesNouveautes(int nombreAnnonce) =>
|
||||
Math.Min(
|
||||
PagesMaximumNouveautes,
|
||||
Math.Max(1, (nombreAnnonce + NoticesParPage - 1) / NoticesParPage));
|
||||
|
||||
public async Task<(ResultatBibliographie Resultat, string? Avertissement)> RechercherParAuteurAsync(
|
||||
string auteur, CancellationToken ct = default)
|
||||
@@ -257,6 +272,52 @@ public sealed class BnfClient(HttpClient http, ILogger<BnfClient> logger) : IBnf
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(ResultatBibliographie Resultat, string? Avertissement)>
|
||||
RechercherParAuteurNouveautesAsync(string auteur, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(auteur))
|
||||
{
|
||||
return (new ResultatBibliographie(), null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var premiere = await LirePageAsync(auteur, 1, ct);
|
||||
if (premiere is null)
|
||||
{
|
||||
return Echec(EtatSourceBibliographie.Injoignable, auteur);
|
||||
}
|
||||
|
||||
var nombrePages = NombrePagesNouveautes(premiere.NombreAnnonce);
|
||||
|
||||
var pages = new List<ResultatBibliographie> { premiere };
|
||||
if (nombrePages > 1)
|
||||
{
|
||||
var suivantes = await Task.WhenAll(
|
||||
Enumerable.Range(1, nombrePages - 1)
|
||||
.Select(i => LirePageAsync(auteur, (i * NoticesParPage) + 1, ct)));
|
||||
pages.AddRange(suivantes.Where(p => p is not null).Select(p => p!));
|
||||
}
|
||||
|
||||
return (Fusionner(pages), null);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
|
||||
{
|
||||
logger.LogWarning(ex, "Délai dépassé par la BnF pour les nouveautés de {Auteur}", auteur);
|
||||
return Echec(EtatSourceBibliographie.DelaiDepasse, auteur);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "BnF injoignable pour les nouveautés de {Auteur}", auteur);
|
||||
return Echec(EtatSourceBibliographie.Injoignable, auteur);
|
||||
}
|
||||
catch (System.Xml.XmlException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Réponse BnF illisible pour les nouveautés de {Auteur}", auteur);
|
||||
return Echec(EtatSourceBibliographie.ReponseIllisible, auteur);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Motif <b>lisible</b> d'une bibliographie que la BnF n'a pas pu fournir.
|
||||
/// </summary>
|
||||
|
||||
@@ -98,6 +98,16 @@
|
||||
Choisissez celle qui correspond, ou complétez à la main.
|
||||
</p>
|
||||
|
||||
<div class="champ champ-tri">
|
||||
<label class="champ-libelle" for="tri-resultats">Trier par</label>
|
||||
<select id="tri-resultats" class="champ-saisie" value="@_tri"
|
||||
@onchange="e => ChangerTri(e.Value?.ToString())">
|
||||
<option value="@TriResultats.Date">Date (récent en premier)</option>
|
||||
<option value="@TriResultats.Titre">Titre</option>
|
||||
<option value="@TriResultats.Editeur">Éditeur</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ul class="liste-candidats">
|
||||
@foreach (var (candidat, index) in _candidats.Select((c, i) => (c, i)))
|
||||
{
|
||||
@@ -183,6 +193,7 @@
|
||||
private EnregistrementSouhait _saisie = new();
|
||||
private IReadOnlyList<CandidatLivre> _candidats = [];
|
||||
private IReadOnlyList<string> _avertissements = [];
|
||||
private TriResultats _tri = TriResultats.Date;
|
||||
private bool _recherche;
|
||||
private bool _ajout;
|
||||
private string? _erreur;
|
||||
@@ -195,6 +206,13 @@
|
||||
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
private enum TriResultats
|
||||
{
|
||||
Date,
|
||||
Titre,
|
||||
Editeur,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Focalise le premier champ à l'ouverture et à chaque changement de mode.
|
||||
/// </summary>
|
||||
@@ -228,6 +246,7 @@
|
||||
_erreur = null;
|
||||
_candidats = [];
|
||||
_avertissements = [];
|
||||
_tri = TriResultats.Date;
|
||||
}
|
||||
|
||||
private async Task SurTouche(KeyboardEventArgs e)
|
||||
@@ -284,6 +303,7 @@
|
||||
}
|
||||
|
||||
_candidats = resultat.Candidats;
|
||||
_candidats = Trier(_candidats);
|
||||
|
||||
if (_candidats.Count == 0)
|
||||
{
|
||||
@@ -296,6 +316,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void ChangerTri(string? valeur)
|
||||
{
|
||||
if (!Enum.TryParse<TriResultats>(valeur, out var tri))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_tri = tri;
|
||||
_candidats = Trier(_candidats);
|
||||
}
|
||||
|
||||
private IReadOnlyList<CandidatLivre> Trier(IEnumerable<CandidatLivre> candidats) =>
|
||||
_tri switch
|
||||
{
|
||||
TriResultats.Titre => candidats
|
||||
.OrderBy(c => c.Titre, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ThenBy(c => c.Editeur, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList(),
|
||||
TriResultats.Editeur => candidats
|
||||
.OrderBy(c => c.Editeur ?? string.Empty, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ThenBy(c => c.Titre, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList(),
|
||||
_ => candidats
|
||||
.OrderByDescending(c => AnneeTriable(c.Annee))
|
||||
.ThenBy(c => c.Titre, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
private static int AnneeTriable(string? date)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(date))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (var i = 0; i + 4 <= date.Length; i++)
|
||||
{
|
||||
if (int.TryParse(date.AsSpan(i, 4), out var annee) && annee is >= 1000 and <= 2999)
|
||||
{
|
||||
return annee;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enregistre le candidat choisi, <b>couverture comprise</b>.
|
||||
/// </summary>
|
||||
|
||||
@@ -30,7 +30,16 @@ else if (_erreur is not null)
|
||||
}
|
||||
else if (_bibliographie is { } biblio)
|
||||
{
|
||||
<h1 class="titre-page">@biblio.Auteur.Nom</h1>
|
||||
<h1 class="titre-page">
|
||||
@if (biblio.EstNouveautes)
|
||||
{
|
||||
<text>Nouveautés de @biblio.Auteur.Nom</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
@biblio.Auteur.Nom
|
||||
}
|
||||
</h1>
|
||||
|
||||
@*
|
||||
⚠️ Les deux cas ci-dessous s'excluent, et c'est TOUT l'objet de ce bloc.
|
||||
@@ -64,15 +73,29 @@ else if (_bibliographie is { } biblio)
|
||||
</div>
|
||||
}
|
||||
else if (biblio.Oeuvres.Count == 0)
|
||||
{
|
||||
@if (biblio.EstNouveautes && biblio.SeuilNouveautesInconnu)
|
||||
{
|
||||
<p class="message-discret">
|
||||
Impossible de dater le livre le plus récent que vous possédez dans les notices
|
||||
reçues. La BnF a répondu, mais aucune nouveauté fiable ne peut être déduite.
|
||||
</p>
|
||||
}
|
||||
else if (biblio.EstNouveautes)
|
||||
{
|
||||
<p class="message-discret">Aucune nouveauté trouvée depuis @biblio.AnneeSeuilNouveautes.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* La source a répondu, et elle ne connaît rien : là, et là seulement, on peut
|
||||
l'expliquer. Le cas est fréquent et n'a rien d'anormal. *@
|
||||
l'expliquer. *@
|
||||
<p class="message-discret">
|
||||
La BnF ne connaît aucun livre de cet auteur. C'est le cas attendu pour les auteurs
|
||||
étrangers : le dépôt légal français ne couvre que l'édition francophone, et un
|
||||
auteur traduit n'y a souvent pas de notice à son nom.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
else if (biblio.Avertissement is not null)
|
||||
{
|
||||
@* Source répondue mais bancale (une page sur deux, par exemple) : la liste vaut
|
||||
@@ -92,6 +115,13 @@ else if (_bibliographie is { } biblio)
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (biblio.EstNouveautes)
|
||||
{
|
||||
<p class="message-discret">
|
||||
Œuvres parues après @biblio.AnneeSeuilNouveautes, d'après les éditions BnF reçues.
|
||||
</p>
|
||||
}
|
||||
|
||||
@* Deux phrases distinctes plutôt qu'une seule à trous : enchaîner des fragments
|
||||
conditionnels dans une même phrase produit une ponctuation bancale dès qu'un des
|
||||
fragments manque (« exister. .34 notices ont été écartées »). *@
|
||||
@@ -290,6 +320,19 @@ else if (_bibliographie is { } biblio)
|
||||
@if (_bibliographie is { } b)
|
||||
{
|
||||
<nav class="actions-flottantes">
|
||||
@if (!b.EstNouveautes)
|
||||
{
|
||||
<a class="bouton bouton-discret"
|
||||
href="@($"auteurs/{b.Auteur.Id}/bibliographie?nouveautes=true")">
|
||||
Nouveautés
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="bouton bouton-discret" href="@($"auteurs/{b.Auteur.Id}/bibliographie")">
|
||||
Toute la bibliographie
|
||||
</a>
|
||||
}
|
||||
<a class="bouton bouton-discret" href="@($"?auteur={b.Auteur.Id}")">Ses livres chez vous</a>
|
||||
</nav>
|
||||
}
|
||||
@@ -298,6 +341,9 @@ else if (_bibliographie is { } biblio)
|
||||
[Parameter]
|
||||
public int AuteurId { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = "nouveautes")]
|
||||
public bool Nouveautes { get; set; }
|
||||
|
||||
private BibliographieDto? _bibliographie;
|
||||
private int _modePossession;
|
||||
private bool _filtresOuverts;
|
||||
@@ -337,7 +383,9 @@ else if (_bibliographie is { } biblio)
|
||||
|
||||
try
|
||||
{
|
||||
_bibliographie = await Api.ObtenirBibliographieAsync(AuteurId);
|
||||
_bibliographie = Nouveautes
|
||||
? await Api.ObtenirNouveautesAsync(AuteurId)
|
||||
: await Api.ObtenirBibliographieAsync(AuteurId);
|
||||
_livresCatalogue = await Api.ListerAsync(new CritereLivres());
|
||||
|
||||
if (_bibliographie is null)
|
||||
|
||||
@@ -775,6 +775,16 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
|
||||
return bibliographie;
|
||||
}
|
||||
|
||||
public async Task<BibliographieDto?> ObtenirNouveautesAsync(
|
||||
int auteurId, CancellationToken ct = default)
|
||||
{
|
||||
var reponse = await http.GetAsync(
|
||||
$"api/auteurs/{auteurId}/bibliographie/nouveautes", ct);
|
||||
return reponse.StatusCode == HttpStatusCode.NotFound
|
||||
? null
|
||||
: await LireAsync<BibliographieDto>(reponse, ct);
|
||||
}
|
||||
|
||||
public async Task<string?> MasquerOeuvreBibliographieAsync(
|
||||
int auteurId, string titre, CancellationToken ct = default) =>
|
||||
await ModifierBibliographieAsync(
|
||||
|
||||
@@ -97,6 +97,13 @@ public record OeuvreBibliographie
|
||||
|
||||
public string? Annee { get; init; }
|
||||
|
||||
/// <summary>Année la plus récente des éditions BnF réunies sous cette œuvre.</summary>
|
||||
/// <remarks>
|
||||
/// L'année affichée reste celle de l'édition la plus ancienne, qui représente l'œuvre.
|
||||
/// Cette seconde valeur sert uniquement à rechercher les nouveautés.
|
||||
/// </remarks>
|
||||
public int? AnneeDerniereEdition { get; init; }
|
||||
|
||||
public string? Editeur { get; init; }
|
||||
|
||||
/// <summary>ISBN d'une des éditions relevées, quand la notice en portait un.</summary>
|
||||
@@ -193,6 +200,15 @@ public record BibliographieDto
|
||||
/// </summary>
|
||||
public EtatSourceBibliographie Etat { get; init; }
|
||||
|
||||
/// <summary>Vrai quand la liste est limitée aux œuvres postérieures au seuil possédé.</summary>
|
||||
public bool EstNouveautes { get; init; }
|
||||
|
||||
/// <summary>Année de publication la plus récente retrouvée pour un livre possédé.</summary>
|
||||
public int? AnneeSeuilNouveautes { get; init; }
|
||||
|
||||
/// <summary>Vrai quand aucun ISBN ou titre possédé n'a permis de dater le seuil.</summary>
|
||||
public bool SeuilNouveautesInconnu { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vrai quand la BnF n'a pas répondu : la liste vide ne prouve alors <b>rien</b>.
|
||||
/// </summary>
|
||||
|
||||
@@ -150,6 +150,57 @@ public class BnfBibliographieParserTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Conserve_aussi_la_date_de_la_derniere_edition()
|
||||
{
|
||||
var resultat = BnfBibliographieParser.Parser(Fixture.Lire(Werber), "Bernard Werber");
|
||||
var oeuvre = ServiceBibliographie.Regrouper(resultat)
|
||||
.First(o => o.NombreEditions > 1
|
||||
&& o.AnneeDerniereEdition is not null
|
||||
&& o.Annee is not null);
|
||||
|
||||
var cle = MaBibli.Shared.Textes.CleOeuvre.Cle(oeuvre.Titre);
|
||||
var maximum = resultat.Notices
|
||||
.Where(n => n.CleTitre == cle)
|
||||
.Select(n => ServiceBibliographie.AnneeTriable(n.Annee))
|
||||
.Where(a => a is not null)
|
||||
.Max();
|
||||
|
||||
Assert.Equal(maximum, oeuvre.AnneeDerniereEdition);
|
||||
Assert.True(oeuvre.AnneeDerniereEdition >= ServiceBibliographie.AnneeTriable(oeuvre.Annee));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Les_nouveautes_comparent_la_derniere_edition_et_non_le_representant()
|
||||
{
|
||||
var anciennes = new OeuvreBibliographie
|
||||
{
|
||||
Titre = "Ancienne œuvre",
|
||||
Annee = "1980",
|
||||
AnneeDerniereEdition = 2024,
|
||||
};
|
||||
var recente = new OeuvreBibliographie
|
||||
{
|
||||
Titre = "Nouvelle œuvre",
|
||||
Annee = "2023",
|
||||
AnneeDerniereEdition = 2023,
|
||||
};
|
||||
|
||||
var resultat = ServiceBibliographie.FiltrerNouveautes([anciennes, recente], 2023);
|
||||
|
||||
Assert.Single(resultat);
|
||||
Assert.Equal("Ancienne œuvre", resultat[0].Titre);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(203, 3)]
|
||||
[InlineData(1000, 10)]
|
||||
[InlineData(2692, 10)]
|
||||
public void La_recherche_de_nouveautes_est_bornee_a_dix_pages(
|
||||
int annonces, int pages) =>
|
||||
Assert.Equal(pages, BnfClient.NombrePagesNouveautes(annonces));
|
||||
|
||||
[Fact]
|
||||
public void Une_reponse_vide_ne_fait_pas_echouer_la_lecture()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user