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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user