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>
299 lines
12 KiB
C#
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 <input type="date"> 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;
|
|
}
|