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>
362 lines
17 KiB
C#
362 lines
17 KiB
C#
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, du plus désiré au moins désiré.")
|
|
.WithDescription(
|
|
"Personnelle : deux membres du foyer obtiennent deux listes différentes. "
|
|
+ "Sans identité, la liste est vide. L'ordre est celui que le propriétaire a "
|
|
+ "choisi ; il n'est pas alphabétique.")
|
|
.Produces<IReadOnlyList<SouhaitDto>>();
|
|
|
|
groupe.MapPut("/ordre", async (
|
|
int[] ids,
|
|
IServiceSouhaits service,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken ct) =>
|
|
{
|
|
var fait = await service.ReordonnerAsync(
|
|
ids, utilisateurs.Obtenir().Identifiant, ct);
|
|
|
|
return fait ? Results.NoContent() : Results.NotFound();
|
|
})
|
|
.WithName("ReordonnerSouhaits")
|
|
.WithSummary("Fixe l'ordre de la liste d'envies de l'appelant.")
|
|
.WithDescription(
|
|
"Attend la liste ENTIÈRE des identifiants, dans l'ordre voulu — c'est ce dont a "
|
|
+ "besoin le glisser-déposer, et les flèches monter/descendre s'y ramènent. "
|
|
+ "Les identifiants inconnus ou appartenant à quelqu'un d'autre sont ignorés ; "
|
|
+ "les envies absentes de la liste envoyée sont conservées, à la suite. "
|
|
+ "404 si l'appelant n'a aucune envie.")
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
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.MapPut("/{id:int}", async (
|
|
int id,
|
|
EnregistrementSouhait saisie,
|
|
IServiceSouhaits service,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken ct) =>
|
|
{
|
|
var resultat = await service.ModifierAsync(
|
|
id, saisie, utilisateurs.Obtenir().Identifiant, ct);
|
|
|
|
if (resultat.NExistePas)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
return resultat.Erreur is not null
|
|
? Results.BadRequest(new { erreur = resultat.Erreur })
|
|
: Results.Ok(resultat.Souhait);
|
|
})
|
|
.WithName("ModifierSouhait")
|
|
.WithSummary("Corrige une envie de l'appelant.")
|
|
.WithDescription(
|
|
"404 si l'envie n'existe pas OU appartient à quelqu'un d'autre : les deux cas "
|
|
+ "sont volontairement indiscernables. 400 — et non 409 — quand la correction "
|
|
+ "ferait doublon avec une autre envie : l'unicité l'interdit, il n'y a rien à "
|
|
+ "confirmer, contrairement au doublon du catalogue. Le rang n'est pas touché : "
|
|
+ "l'ordre a son propre point d'entrée.")
|
|
.Produces<SouhaitDto>()
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
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);
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Envies de revues et de numéros.
|
|
//
|
|
// Table sœur, donc points d'entrée sœurs : mêmes règles de portée, même 404 sur ce qui
|
|
// appartient à un autre. Le préfixe littéral « revues » ne peut pas être confondu avec
|
|
// « /{id:int} », qui exige un entier.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
groupe.MapGet("/revues", async (
|
|
IServiceRevuesSouhaitees service,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken ct) =>
|
|
Results.Ok(await service.ListerAsync(utilisateurs.Obtenir().Identifiant, ct)))
|
|
.WithName("ListerRevuesSouhaitees")
|
|
.WithSummary("Les revues et numéros souhaités par l'appelant.")
|
|
.WithDescription(
|
|
"Section distincte de la liste d'envies : les rangs des deux tables sont "
|
|
+ "numérotés indépendamment, les entrelacer produirait un ordre que personne "
|
|
+ "n'a choisi.")
|
|
.Produces<IReadOnlyList<RevueSouhaiteeDto>>();
|
|
|
|
groupe.MapPost("/revues", async (
|
|
EnregistrementRevueSouhaitee saisie,
|
|
IServiceRevuesSouhaitees 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/revues/{resultat.Envie!.Id}", resultat.Envie);
|
|
})
|
|
.WithName("AjouterRevueSouhaitee")
|
|
.WithSummary("Ajoute une revue — ou un numéro précis — à la liste d'envies.")
|
|
.WithDescription(
|
|
"Le numéro est facultatif : on souhaite parfois un numéro vu en kiosque, "
|
|
+ "parfois le titre entier. L'ISSN est canonisé avec son tiret.")
|
|
.Produces<RevueSouhaiteeDto>(StatusCodes.Status201Created)
|
|
.Produces(StatusCodes.Status400BadRequest);
|
|
|
|
groupe.MapPut("/revues/{id:int}", async (
|
|
int id,
|
|
EnregistrementRevueSouhaitee saisie,
|
|
IServiceRevuesSouhaitees service,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken ct) =>
|
|
{
|
|
var resultat = await service.ModifierAsync(
|
|
id, saisie, utilisateurs.Obtenir().Identifiant, ct);
|
|
|
|
if (resultat.NExistePas)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
return resultat.Erreur is not null
|
|
? Results.BadRequest(new { erreur = resultat.Erreur })
|
|
: Results.Ok(resultat.Envie);
|
|
})
|
|
.WithName("ModifierRevueSouhaitee")
|
|
.WithSummary("Corrige une envie de revue de l'appelant.")
|
|
.Produces<RevueSouhaiteeDto>()
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
groupe.MapDelete("/revues/{id:int}", async (
|
|
int id,
|
|
IServiceRevuesSouhaitees service,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken ct) =>
|
|
{
|
|
var supprime = await service.SupprimerAsync(
|
|
id, utilisateurs.Obtenir().Identifiant, ct);
|
|
|
|
return supprime ? Results.NoContent() : Results.NotFound();
|
|
})
|
|
.WithName("SupprimerRevueSouhaitee")
|
|
.WithSummary("Retire une revue de la liste d'envies de l'appelant.")
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
// Deux formats parce que les deux usages décrits dans CLAUDE.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,
|
|
IServiceRevuesSouhaitees revues,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken ct) =>
|
|
ExporterAsync(FormatExportSouhaits.Texte, service, revues, utilisateurs, ct))
|
|
.WithName("ExporterSouhaitsTexte")
|
|
.WithSummary("Liste d'envies en texte lisible, groupée par auteur.")
|
|
.ExcludeFromDescription();
|
|
|
|
groupe.MapGet("/export.csv", (
|
|
IServiceSouhaits service,
|
|
IServiceRevuesSouhaitees revues,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken ct) =>
|
|
ExporterAsync(FormatExportSouhaits.Csv, service, revues, 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,
|
|
IServiceRevuesSouhaitees revues,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken ct)
|
|
{
|
|
var utilisateur = utilisateurs.Obtenir();
|
|
var souhaits = await service.ListerAsync(utilisateur.Identifiant, ct);
|
|
|
|
// ⚠️ Les deux tables partent ensemble, sans quoi les envies de revues n'existeraient
|
|
// pas dans le fichier qu'on emporte — c'est-à-dire là où elles servent.
|
|
var revuesSouhaitees = await revues.ListerAsync(utilisateur.Identifiant, ct);
|
|
var maintenant = DateTime.Now;
|
|
|
|
var contenu = ExportSouhaits.Produire(
|
|
format, souhaits, revuesSouhaitees, 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);
|
|
|
|
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,
|
|
IServiceBibliographie service,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken ct) =>
|
|
{
|
|
var fait = await service.MasquerAsync(
|
|
id, saisie.Titre, utilisateurs.Obtenir().Identifiant, ct);
|
|
return fait ? Results.NoContent() : Results.BadRequest();
|
|
})
|
|
.WithName("MasquerOeuvreBibliographie")
|
|
.WithSummary("Masque une œuvre de la bibliographie pour l'appelant.")
|
|
.Produces(StatusCodes.Status204NoContent);
|
|
|
|
routes.MapDelete("/api/auteurs/{id:int}/bibliographie/masque", async (
|
|
int id,
|
|
string titre,
|
|
IServiceBibliographie service,
|
|
IFournisseurUtilisateur utilisateurs,
|
|
CancellationToken ct) =>
|
|
{
|
|
var fait = await service.DemasquerAsync(
|
|
id, titre, utilisateurs.Obtenir().Identifiant, ct);
|
|
return fait ? Results.NoContent() : Results.NotFound();
|
|
})
|
|
.WithName("DemasquerOeuvreBibliographie")
|
|
.WithSummary("Réaffiche une œuvre masquée de la bibliographie.")
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
return routes;
|
|
}
|
|
}
|