Ajouter la liste d'envies personnelle, son export et la bibliographie par auteur

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>
This commit is contained in:
mathieu
2026-08-18 13:59:06 +02:00
co-authored by Claude Opus 5
parent 3a5a3a7763
commit 8b8fe3be6e
27 changed files with 3939 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
using System.Text;
using MaBibli.Api.Services.Catalogue;
using MaBibli.Api.Services.Identite;
using MaBibli.Api.Services.Souhaits;
using MaBibli.Shared.Dtos;
namespace MaBibli.Api.Endpoints;
public static class SouhaitsEndpoints
{
/// <summary>
/// La liste d'envies et son export.
/// </summary>
/// <remarks>
/// <b>Aucun point d'entrée n'accepte de nom d'utilisateur</b>, et il ne doit jamais y en
/// avoir : la liste rendue est toujours celle de l'appelant, identifié par les en-têtes
/// SSOwat. C'est la même règle que pour les statuts de lecture, et elle est ici encore plus
/// nette — le sens de la fonctionnalité est de préparer un cadeau sans que l'autre le voie.
/// </remarks>
public static IEndpointRouteBuilder MapSouhaitsEndpoints(this IEndpointRouteBuilder routes)
{
var groupe = routes.MapGroup("/api/souhaits").WithTags("Liste d'envies");
groupe.MapGet("/", async (
IServiceSouhaits service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
Results.Ok(await service.ListerAsync(utilisateurs.Obtenir().Identifiant, ct)))
.WithName("ListerSouhaits")
.WithSummary("La liste d'envies de l'appelant, groupée par auteur.")
.WithDescription(
"Personnelle : deux membres du foyer obtiennent deux listes différentes. "
+ "Sans identité, la liste est vide.")
.Produces<IReadOnlyList<SouhaitDto>>();
groupe.MapPost("/", async (
EnregistrementSouhait saisie,
IServiceSouhaits service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var resultat = await service.AjouterAsync(
saisie, utilisateurs.Obtenir().Identifiant, ct);
return resultat.Erreur is not null
? Results.BadRequest(new { erreur = resultat.Erreur })
: Results.Created($"/api/souhaits/{resultat.Souhait!.Id}", resultat.Souhait);
})
.WithName("AjouterSouhait")
.WithSummary("Ajoute un livre à la liste d'envies de l'appelant.")
.Produces<SouhaitDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
groupe.MapDelete("/{id:int}", async (
int id,
IServiceSouhaits service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var supprime = await service.SupprimerAsync(
id, utilisateurs.Obtenir().Identifiant, ct);
return supprime ? Results.NoContent() : Results.NotFound();
})
.WithName("SupprimerSouhait")
.WithSummary("Retire un livre de la liste d'envies de l'appelant.")
.WithDescription(
"404 si l'envie n'existe pas OU appartient à quelqu'un d'autre : les deux cas "
+ "sont volontairement indiscernables.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
// Deux formats parce que les deux usages décrits dans IDEES.md diffèrent : le .txt
// s'emporte en librairie et se lit tel quel, le .csv s'ouvre dans un tableur pour se
// répartir des achats. Ce sont deux fichiers texte : aucune dépendance ni pour les
// produire, ni pour les lire.
groupe.MapGet("/export.txt", (
IServiceSouhaits service, IFournisseurUtilisateur utilisateurs, CancellationToken ct) =>
ExporterAsync(FormatExportSouhaits.Texte, service, utilisateurs, ct))
.WithName("ExporterSouhaitsTexte")
.WithSummary("Liste d'envies en texte lisible, groupée par auteur.")
.ExcludeFromDescription();
groupe.MapGet("/export.csv", (
IServiceSouhaits service, IFournisseurUtilisateur utilisateurs, CancellationToken ct) =>
ExporterAsync(FormatExportSouhaits.Csv, service, utilisateurs, ct))
.WithName("ExporterSouhaitsCsv")
.WithSummary("Liste d'envies en CSV, ouvrable dans un tableur.")
.ExcludeFromDescription();
return routes;
}
/// <summary>
/// Produit le fichier d'export et le renvoie en <b>téléchargement</b>.
/// </summary>
/// <remarks>
/// Le nom de fichier passé à <c>Results.File</c> pose un <c>Content-Disposition:
/// attachment</c> : le navigateur enregistre au lieu d'afficher, y compris sur mobile où un
/// .txt s'ouvrirait sinon dans l'onglet. C'est ce qui rend l'export réellement
/// « emportable ».
/// <para>
/// Le fichier est produit ici plutôt que côté client : le client Blazor n'a pas la liste
/// complète sous la main, et un export généré par le serveur reste correct même si l'écran
/// affiche une vue filtrée.
/// </para>
/// </remarks>
private static async Task<IResult> ExporterAsync(
FormatExportSouhaits format,
IServiceSouhaits service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct)
{
var utilisateur = utilisateurs.Obtenir();
var souhaits = await service.ListerAsync(utilisateur.Identifiant, ct);
var maintenant = DateTime.Now;
var contenu = ExportSouhaits.Produire(
format, souhaits, utilisateur.Affichage, maintenant);
// Le BOM n'est mis QUE sur le CSV, où il est indispensable : sans lui, Excel lit le
// fichier en codage hérité et affiche « Émile Zola ». Le .txt s'en passe — il est fait
// pour être lu tel quel, et certains lecteurs simples affichent le BOM comme un
// caractère parasite en tête de la première ligne.
var corps = Encoding.UTF8.GetBytes(contenu);
var octets = format == FormatExportSouhaits.Csv
? [.. ExportSouhaits.Bom, .. corps]
: corps;
return Results.File(
octets,
ExportSouhaits.TypeMime(format),
ExportSouhaits.NomFichier(format, maintenant));
}
/// <summary>Bibliographie d'un auteur du catalogue, d'après la BnF.</summary>
public static IEndpointRouteBuilder MapBibliographieEndpoints(this IEndpointRouteBuilder routes)
{
routes.MapGet("/api/auteurs/{id:int}/bibliographie", async (
int id,
IServiceBibliographie service,
IFournisseurUtilisateur utilisateurs,
CancellationToken ct) =>
{
var bibliographie = await service.ObtenirAsync(
id, utilisateurs.Obtenir().Identifiant, ct);
return bibliographie is null ? Results.NotFound() : Results.Ok(bibliographie);
})
.WithName("BibliographieAuteur")
.WithTags("Auteurs")
.WithSummary("Ce que la BnF connaît de cet auteur, confronté à l'étagère.")
.WithDescription(
"« Possédé » vaut pour tout le foyer, « souhaité » seulement pour l'appelant. "
+ "Le rapprochement se fait par titre normalisé : il peut manquer un livre "
+ "possédé sous un autre titre, d'où le fait qu'aucune ligne ne soit masquée.")
.Produces<BibliographieDto>()
.Produces(StatusCodes.Status404NotFound);
return routes;
}
}