Files
mabibli/MaBibli.Client/Services/ServiceLivresApi.cs
T
mathieuandClaude Opus 5 8b8fe3be6e 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>
2026-08-18 13:59:06 +02:00

299 lines
12 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using MaBibli.Shared.Catalogue;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
namespace MaBibli.Client.Services;
/// <summary>
/// Accès à l'API depuis le client Blazor.
/// </summary>
/// <remarks>
/// Aucun cache : la phase 3 suppose le réseau disponible. Le cache hors-ligne est la phase 6.
/// </remarks>
public sealed class ServiceLivresApi(HttpClient http)
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public async Task<IReadOnlyList<LivreDto>> ListerAsync(CritereLivres criteres, CancellationToken ct = default)
{
var parametres = new List<string>();
if (!string.IsNullOrWhiteSpace(criteres.Recherche))
{
parametres.Add($"recherche={Uri.EscapeDataString(criteres.Recherche.Trim())}");
}
if (criteres.Format is { } format)
{
parametres.Add($"format={format}");
}
if (criteres.Statut is { } statut)
{
parametres.Add($"statut={statut}");
}
if (criteres.AuteurId is { } auteurId)
{
parametres.Add($"auteurId={auteurId}");
}
var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty);
return await http.GetFromJsonAsync<List<LivreDto>>(url, Json, ct) ?? [];
}
public async Task<LivreDto?> ObtenirAsync(int id, CancellationToken ct = default)
{
var reponse = await http.GetAsync($"api/livres/{id}", ct);
return reponse.StatusCode == HttpStatusCode.NotFound
? null
: await LireAsync<LivreDto>(reponse, ct);
}
public async Task<ResultatApi<LivreDto>> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default)
{
var reponse = await http.PostAsJsonAsync("api/livres", saisie, Json, ct);
return await InterpreterAsync<LivreDto>(reponse, ct);
}
public async Task<ResultatApi<LivreDto>> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default)
{
var reponse = await http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct);
return await InterpreterAsync<LivreDto>(reponse, ct);
}
/// <summary>
/// Change le statut de lecture <b>de l'utilisateur courant</b> ; <c>null</c> le remet à
/// « non commencé ». Le serveur sait qui appelle : le client ne transmet jamais d'identité.
/// </summary>
public async Task<ResultatApi<LivreDto>> ChangerStatutAsync(
int id, Statut? statut, CancellationToken ct = default)
{
var reponse = await http.PatchAsJsonAsync(
$"api/livres/{id}/statut", new ChangementStatut { Statut = statut }, Json, ct);
return await InterpreterAsync<LivreDto>(reponse, ct);
}
public async Task<IReadOnlyList<AuteurDto>> ListerAuteursAsync(CancellationToken ct = default) =>
await http.GetFromJsonAsync<List<AuteurDto>>("api/auteurs", Json, ct) ?? [];
public async Task<AuteurDto?> ObtenirAuteurAsync(int id, CancellationToken ct = default)
{
var reponse = await http.GetAsync($"api/auteurs/{id}", ct);
return reponse.StatusCode == HttpStatusCode.NotFound
? null
: await LireAsync<AuteurDto>(reponse, ct);
}
/// <summary>Rapprochements d'auteurs ambigus, que le serveur refuse d'appliquer seul.</summary>
public async Task<IReadOnlyList<SuggestionRapprochement>> ListerRapprochementsAsync(
CancellationToken ct = default) =>
await http.GetFromJsonAsync<List<SuggestionRapprochement>>("api/auteurs/rapprochements", Json, ct) ?? [];
public Task<string?> AccepterRapprochementAsync(int sourceId, int cibleId, CancellationToken ct = default) =>
DeciderAsync("accepter", sourceId, cibleId, ct);
public Task<string?> RefuserRapprochementAsync(int sourceId, int cibleId, CancellationToken ct = default) =>
DeciderAsync("refuser", sourceId, cibleId, ct);
private async Task<string?> DeciderAsync(string action, int sourceId, int cibleId, CancellationToken ct)
{
var reponse = await http.PostAsJsonAsync(
$"api/auteurs/rapprochements/{action}",
new DecisionRapprochement { SourceId = sourceId, CibleId = cibleId },
Json,
ct);
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
}
/// <summary>Les livres actuellement dehors, du prêt le plus ancien au plus récent.</summary>
public async Task<IReadOnlyList<PretDto>> ListerPretsEnCoursAsync(CancellationToken ct = default) =>
await http.GetFromJsonAsync<List<PretDto>>("api/prets/en-cours", Json, ct) ?? [];
/// <summary>
/// Historique complet des prêts d'un livre, du plus récent au plus ancien.
/// </summary>
/// <remarks>Liste vide = jamais prêté ; <c>null</c> = livre inconnu.</remarks>
public async Task<IReadOnlyList<PretDto>?> ListerPretsLivreAsync(int livreId, CancellationToken ct = default)
{
var reponse = await http.GetAsync($"api/livres/{livreId}/prets", ct);
return reponse.StatusCode == HttpStatusCode.NotFound
? null
: await LireAsync<List<PretDto>>(reponse, ct);
}
/// <summary>
/// Prête un livre. <paramref name="datePret"/> est une date <b>locale</b>, convertie ici en
/// UTC : tout ce que l'API stocke est en UTC, comme le reste des dates du projet.
/// </summary>
public async Task<ResultatApi<PretDto>> PreterAsync(
int livreId, string emprunteur, DateTime? datePret = null, CancellationToken ct = default)
{
var saisie = new EnregistrementPret
{
Emprunteur = emprunteur,
DatePret = EnUtc(datePret),
};
var reponse = await http.PostAsJsonAsync($"api/livres/{livreId}/prets", saisie, Json, ct);
return await InterpreterAsync<PretDto>(reponse, ct);
}
/// <summary>Clôt un prêt. Le prêt n'est pas supprimé : il devient l'historique du livre.</summary>
public async Task<ResultatApi<PretDto>> RendreAsync(
int pretId, DateTime? dateRetour = null, CancellationToken ct = default)
{
var reponse = await http.PostAsJsonAsync(
$"api/prets/{pretId}/retour", new RetourPret { DateRetour = EnUtc(dateRetour) }, Json, ct);
return await InterpreterAsync<PretDto>(reponse, ct);
}
/// <summary>
/// Convertit une date issue d'un &lt;input type="date"&gt; en instant UTC.
/// </summary>
/// <remarks>
/// Le navigateur produit une date sans fuseau (minuit, « Unspecified ») : l'envoyer telle
/// quelle décalerait le prêt d'un jour pour la moitié du globe. On la déclare locale avant
/// de la convertir, ce qui est bien ce que l'utilisateur voulait dire en la choisissant.
/// </remarks>
private static DateTime? EnUtc(DateTime? date) =>
date is null ? null : DateTime.SpecifyKind(date.Value, DateTimeKind.Local).ToUniversalTime();
public async Task<string?> SupprimerAsync(int id, CancellationToken ct = default)
{
var reponse = await http.DeleteAsync($"api/livres/{id}", ct);
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
}
public async Task<ResultatLookupIsbn?> ChercherIsbnAsync(string isbn, CancellationToken ct = default)
{
var reponse = await http.GetAsync($"api/isbn/{Uri.EscapeDataString(isbn)}", ct);
return reponse.IsSuccessStatusCode
? await LireAsync<ResultatLookupIsbn>(reponse, ct)
: null;
}
public async Task<UtilisateurCourant> ObtenirUtilisateurAsync(CancellationToken ct = default)
{
try
{
return await http.GetFromJsonAsync<UtilisateurCourant>("api/moi", Json, ct)
?? UtilisateurCourant.Anonyme;
}
catch (HttpRequestException)
{
return UtilisateurCourant.Anonyme;
}
}
private static async Task<T?> LireAsync<T>(HttpResponseMessage reponse, CancellationToken ct)
{
reponse.EnsureSuccessStatusCode();
return await reponse.Content.ReadFromJsonAsync<T>(Json, ct);
}
private static async Task<ResultatApi<T>> InterpreterAsync<T>(HttpResponseMessage reponse, CancellationToken ct)
{
if (reponse.IsSuccessStatusCode)
{
var valeur = await reponse.Content.ReadFromJsonAsync<T>(Json, ct);
return new ResultatApi<T>(valeur, null);
}
return new ResultatApi<T>(default, await MessageErreurAsync(reponse, ct));
}
/// <summary>Récupère le message d'erreur de l'API pour l'afficher tel quel à l'utilisateur.</summary>
private static async Task<string> MessageErreurAsync(HttpResponseMessage reponse, CancellationToken ct)
{
try
{
var corps = await reponse.Content.ReadFromJsonAsync<ErreurApi>(Json, ct);
if (!string.IsNullOrWhiteSpace(corps?.Erreur))
{
return corps.Erreur;
}
}
catch (Exception)
{
// Corps illisible ou vide : on retombe sur le code HTTP.
}
return reponse.StatusCode switch
{
HttpStatusCode.NotFound => "Ce livre n'existe plus.",
_ => $"L'enregistrement a échoué ({(int)reponse.StatusCode}).",
};
}
// ─────────────────────────────────────────────────────────────────────────
// Liste d'envies et bibliographie par auteur.
// Bloc ajouté en fin de classe pour rester bien délimité.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// La liste d'envies <b>de l'utilisateur courant</b>.
/// </summary>
/// <remarks>
/// Comme pour le statut de lecture, le client ne transmet aucune identité : le serveur sait
/// qui appelle. Il n'existe volontairement pas de moyen de demander la liste d'un autre.
/// </remarks>
public async Task<IReadOnlyList<SouhaitDto>> ListerSouhaitsAsync(CancellationToken ct = default) =>
await http.GetFromJsonAsync<List<SouhaitDto>>("api/souhaits", Json, ct) ?? [];
public async Task<ResultatApi<SouhaitDto>> AjouterSouhaitAsync(
EnregistrementSouhait saisie, CancellationToken ct = default)
{
var reponse = await http.PostAsJsonAsync("api/souhaits", saisie, Json, ct);
return await InterpreterAsync<SouhaitDto>(reponse, ct);
}
public async Task<string?> SupprimerSouhaitAsync(int id, CancellationToken ct = default)
{
var reponse = await http.DeleteAsync($"api/souhaits/{id}", ct);
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
}
/// <summary>
/// Adresse de téléchargement d'un export.
/// </summary>
/// <remarks>
/// Le fichier n'est pas récupéré par ce service : un simple lien suffit, le serveur pose un
/// <c>Content-Disposition: attachment</c> et le navigateur enregistre. Passer par
/// <see cref="HttpClient"/> obligerait à fabriquer une URL d'objet en JavaScript pour un
/// résultat identique.
/// <para>
/// ⚠️ Le lien <b>doit</b> porter l'attribut <c>download</c> : sans lui, le routeur Blazor
/// intercepte le clic comme une navigation interne et affiche sa page « introuvable » au
/// lieu de laisser le navigateur télécharger.
/// </para>
/// </remarks>
public static string UrlExportSouhaits(FormatExportSouhaits format) =>
format == FormatExportSouhaits.Csv ? "api/souhaits/export.csv" : "api/souhaits/export.txt";
/// <summary>
/// Bibliographie d'un auteur d'après la BnF. <c>null</c> si l'auteur n'existe pas.
/// </summary>
public async Task<BibliographieDto?> ObtenirBibliographieAsync(
int auteurId, CancellationToken ct = default)
{
var reponse = await http.GetAsync($"api/auteurs/{auteurId}/bibliographie", ct);
return reponse.StatusCode == HttpStatusCode.NotFound
? null
: await LireAsync<BibliographieDto>(reponse, ct);
}
private sealed record ErreurApi(string? Erreur);
}
/// <summary>Résultat d'un appel d'écriture : la valeur, ou un message d'erreur affichable.</summary>
public readonly record struct ResultatApi<T>(T? Valeur, string? Erreur)
{
public bool EstOk => Erreur is null;
}