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>
1187 lines
47 KiB
C#
1187 lines
47 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;
|
|
using MaBibli.Shared.Textes;
|
|
|
|
namespace MaBibli.Client.Services;
|
|
|
|
/// <summary>
|
|
/// Accès à l'API depuis le client Blazor, avec repli sur le cache hors-ligne.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Le piège que ce service existe pour éviter</b> (CLAUDE.md) : en Blazor WebAssembly le code
|
|
/// tourne dans le navigateur alors que SQLite vit sur le serveur. Le service worker met en cache
|
|
/// les <i>assets</i> — HTML, CSS, WASM — mais jamais les réponses de l'API. Sans le travail fait
|
|
/// ici, l'application démarre parfaitement hors-ligne… et affiche une bibliothèque vide.
|
|
/// <para>
|
|
/// La règle est simple et sans exception : <b>les lectures retombent sur le cache, les écritures
|
|
/// sont refusées</b>. Aucune file d'attente, aucune synchronisation en retour, aucun conflit à
|
|
/// résoudre — c'est la décision actée, et c'est ce qui rend le hors-ligne fiable plutôt que
|
|
/// « presque bon ».
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, EtatReseau reseau)
|
|
{
|
|
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
|
private readonly Dictionary<int, BibliographieDto> _bibliographies = [];
|
|
|
|
/// <summary>
|
|
/// Recharge tous les instantanés depuis le réseau.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Appelé au démarrage et à chaque retour du réseau : c'est ce qui garantit que la
|
|
/// bibliothèque entière est disponible hors-ligne, et pas seulement les écrans déjà ouverts.
|
|
/// </remarks>
|
|
/// <returns>Vrai si les données ont pu être rafraîchies.</returns>
|
|
public async Task<bool> SynchroniserAsync(CancellationToken ct = default)
|
|
{
|
|
if (!reseau.TenterLeReseau)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
await ListerAsync(new CritereLivres(), ct);
|
|
await ListerAuteursAsync(ct);
|
|
await ListerPretsEnCoursAsync(ct);
|
|
await ListerSouhaitsAsync(ct);
|
|
await ListerRevuesSouhaiteesAsync(ct);
|
|
await ListerSeriesAsync(ct);
|
|
await ListerRevuesAsync(ct);
|
|
await ObtenirUtilisateurAsync(ct);
|
|
await ObtenirVersionAsync(ct);
|
|
return reseau.EnLigne;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return false;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Une synchronisation ratée n'est pas une panne de l'application : l'écran affichera
|
|
// ce qu'il a, et la date de synchronisation dira qu'il n'est plus tout frais.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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.TypeDocument is { } type)
|
|
{
|
|
parametres.Add($"typeDocument={type}");
|
|
}
|
|
|
|
if (criteres.Statut is { } statut)
|
|
{
|
|
parametres.Add($"statut={statut}");
|
|
}
|
|
|
|
if (criteres.AuteurId is { } auteurId)
|
|
{
|
|
parametres.Add($"auteurId={auteurId}");
|
|
}
|
|
|
|
if (criteres.Prete is { } prete)
|
|
{
|
|
// Minuscules : c'est ce que le liage de ASP.NET Core attend d'un bool?.
|
|
parametres.Add($"prete={(prete ? "true" : "false")}");
|
|
}
|
|
|
|
var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty);
|
|
|
|
var (ok, liste) = await EssayerAsync(() => http.GetFromJsonAsync<List<LivreDto>>(url, Json, ct));
|
|
|
|
if (ok)
|
|
{
|
|
// Seule la liste NON filtrée fait un instantané valable : c'est elle qui permettra
|
|
// de chercher hors-ligne autre chose que ce qui était affiché au moment de la coupure.
|
|
if (parametres.Count == 0)
|
|
{
|
|
await MemoriserAsync(ClesCache.Catalogue, liste ?? []);
|
|
}
|
|
|
|
return liste ?? [];
|
|
}
|
|
|
|
var instantane = await LireCacheAsync<List<LivreDto>>(ClesCache.Catalogue);
|
|
return instantane is null ? [] : FiltreLivresLocal.Appliquer(instantane.Donnees, criteres);
|
|
}
|
|
|
|
public async Task<LivreDto?> ObtenirAsync(int id, CancellationToken ct = default)
|
|
{
|
|
var (ok, livre) = await EssayerAsync(async () =>
|
|
{
|
|
var reponse = await http.GetAsync($"api/livres/{id}", ct);
|
|
return reponse.StatusCode == HttpStatusCode.NotFound
|
|
? null
|
|
: await LireAsync<LivreDto>(reponse, ct);
|
|
});
|
|
|
|
if (ok)
|
|
{
|
|
return livre;
|
|
}
|
|
|
|
// Hors-ligne, la fiche se relit dans l'instantané du catalogue : pas d'appel par livre à
|
|
// mettre en cache, donc rien à maintenir en plus.
|
|
var instantane = await LireCacheAsync<List<LivreDto>>(ClesCache.Catalogue);
|
|
return instantane?.Donnees.FirstOrDefault(l => l.Id == id);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ajoute un livre, en rapportant le cas échéant ce que le catalogue a déjà de semblable.
|
|
/// </summary>
|
|
/// <param name="confirmerDoublon">
|
|
/// Vrai quand l'utilisateur a vu l'avertissement et veut ajouter le livre malgré tout —
|
|
/// second exemplaire, autre édition, ou rapprochement à côté de la plaque.
|
|
/// </param>
|
|
public async Task<ResultatCreation> CreerAsync(
|
|
EnregistrementLivre saisie, bool confirmerDoublon = false, CancellationToken ct = default)
|
|
{
|
|
var url = "api/livres" + (confirmerDoublon ? "?confirmerDoublon=true" : string.Empty);
|
|
var (reponse, motif) = await EnvoyerAsync(() => http.PostAsJsonAsync(url, saisie, Json, ct));
|
|
|
|
if (reponse is null)
|
|
{
|
|
return new ResultatCreation(null, motif, null);
|
|
}
|
|
|
|
// Le 409 n'est PAS une erreur : le serveur demande une confirmation, et l'écran doit
|
|
// proposer d'ajouter quand même. Le faire passer par le message d'erreur générique le
|
|
// transformerait en échec, avec le livre perdu au passage.
|
|
if (reponse.StatusCode == HttpStatusCode.Conflict)
|
|
{
|
|
var doublons = await LireDoublonsAsync(reponse, ct);
|
|
if (doublons is not null)
|
|
{
|
|
return new ResultatCreation(null, null, doublons);
|
|
}
|
|
}
|
|
|
|
var resultat = await InterpreterAsync<LivreDto>(reponse, ct);
|
|
|
|
if (resultat.EstOk)
|
|
{
|
|
_ = RafraichirEnFondAsync();
|
|
}
|
|
|
|
return new ResultatCreation(resultat.Valeur, resultat.Erreur, null);
|
|
}
|
|
|
|
/// <summary>Lit le corps d'un 409, ou <c>null</c> s'il est illisible.</summary>
|
|
private static async Task<DoublonsLivre?> LireDoublonsAsync(
|
|
HttpResponseMessage reponse, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var doublons = await reponse.Content.ReadFromJsonAsync<DoublonsLivre>(Json, ct);
|
|
return string.IsNullOrWhiteSpace(doublons?.Message) ? null : doublons;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Corps inattendu : on retombe sur le traitement d'erreur ordinaire, qui dira au
|
|
// moins que l'enregistrement n'a pas eu lieu.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task<ResultatApi<LivreDto>> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default) =>
|
|
await EcrireAsync<LivreDto>(() => http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct), 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) =>
|
|
await EcrireAsync<LivreDto>(
|
|
() => http.PatchAsJsonAsync(
|
|
$"api/livres/{id}/statut", new ChangementStatut { Statut = statut }, Json, ct),
|
|
ct);
|
|
|
|
public async Task<IReadOnlyList<AuteurDto>> ListerAuteursAsync(CancellationToken ct = default)
|
|
{
|
|
var (ok, auteurs) = await EssayerAsync(
|
|
() => http.GetFromJsonAsync<List<AuteurDto>>("api/auteurs", Json, ct));
|
|
|
|
if (ok)
|
|
{
|
|
await MemoriserAsync(ClesCache.Auteurs, auteurs ?? []);
|
|
return auteurs ?? [];
|
|
}
|
|
|
|
var instantane = await LireCacheAsync<List<AuteurDto>>(ClesCache.Auteurs);
|
|
return instantane?.Donnees ?? [];
|
|
}
|
|
|
|
public async Task<AuteurDto?> ObtenirAuteurAsync(int id, CancellationToken ct = default)
|
|
{
|
|
var (ok, auteur) = await EssayerAsync(async () =>
|
|
{
|
|
var reponse = await http.GetAsync($"api/auteurs/{id}", ct);
|
|
return reponse.StatusCode == HttpStatusCode.NotFound
|
|
? null
|
|
: await LireAsync<AuteurDto>(reponse, ct);
|
|
});
|
|
|
|
if (ok)
|
|
{
|
|
return auteur;
|
|
}
|
|
|
|
var instantane = await LireCacheAsync<List<AuteurDto>>(ClesCache.Auteurs);
|
|
return instantane?.Donnees.FirstOrDefault(a => a.Id == id);
|
|
}
|
|
|
|
public async Task<ResultatRenommageAuteur?> RenommerAuteurAsync(
|
|
int id, string nom, bool confirmerFusion = false, CancellationToken ct = default)
|
|
{
|
|
if (!reseau.EnLigne)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var suffixe = confirmerFusion ? "?confirmerFusion=true" : string.Empty;
|
|
try
|
|
{
|
|
var reponse = await http.PutAsJsonAsync(
|
|
$"api/auteurs/{id}{suffixe}", new RenommerAuteur(nom), Json, ct);
|
|
reseau.SignalerSuccesReseau();
|
|
|
|
if (reponse.StatusCode == HttpStatusCode.NotFound)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (reponse.StatusCode == HttpStatusCode.Conflict
|
|
|| reponse.IsSuccessStatusCode)
|
|
{
|
|
return await LireAsync<ResultatRenommageAuteur>(reponse, ct);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
reseau.SignalerEchecReseau();
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rapprochements d'auteurs ambigus, que le serveur refuse d'appliquer seul.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Volontairement <b>pas</b> mis en cache : c'est une liste de décisions à prendre, or aucune
|
|
/// décision ne peut être enregistrée hors-ligne. L'afficher sans pouvoir y répondre
|
|
/// n'apporterait rien.
|
|
/// </remarks>
|
|
public async Task<IReadOnlyList<SuggestionRapprochement>> ListerRapprochementsAsync(
|
|
CancellationToken ct = default)
|
|
{
|
|
var (ok, suggestions) = await EssayerAsync(
|
|
() => http.GetFromJsonAsync<List<SuggestionRapprochement>>("api/auteurs/rapprochements", Json, ct));
|
|
|
|
return ok ? suggestions ?? [] : [];
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (!reseau.EnLigne)
|
|
{
|
|
return EtatReseau.MotifHorsLigne;
|
|
}
|
|
|
|
try
|
|
{
|
|
var reponse = await http.PostAsJsonAsync(
|
|
$"api/auteurs/rapprochements/{action}",
|
|
new DecisionRapprochement { SourceId = sourceId, CibleId = cibleId },
|
|
Json,
|
|
ct);
|
|
|
|
reseau.SignalerSuccesReseau();
|
|
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
reseau.SignalerEchecReseau();
|
|
return EtatReseau.MotifHorsLigne;
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
var (ok, prets) = await EssayerAsync(
|
|
() => http.GetFromJsonAsync<List<PretDto>>("api/prets/en-cours", Json, ct));
|
|
|
|
if (ok)
|
|
{
|
|
await MemoriserAsync(ClesCache.PretsEnCours, prets ?? []);
|
|
return prets ?? [];
|
|
}
|
|
|
|
var instantane = await LireCacheAsync<List<PretDto>>(ClesCache.PretsEnCours);
|
|
return instantane?.Donnees ?? [];
|
|
}
|
|
|
|
/// <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 <b>ou</b> réseau absent. L'appelant
|
|
/// doit consulter <see cref="EtatReseau.EnLigne"/> pour distinguer les deux : l'historique
|
|
/// n'est pas mis en cache (une ligne par livre, pour une information rarement consultée),
|
|
/// et afficher « jamais prêté » hors-ligne serait un mensonge.
|
|
/// </remarks>
|
|
public async Task<IReadOnlyList<PretDto>?> ListerPretsLivreAsync(int livreId, CancellationToken ct = default)
|
|
{
|
|
var (ok, prets) = await EssayerAsync(async () =>
|
|
{
|
|
var reponse = await http.GetAsync($"api/livres/{livreId}/prets", ct);
|
|
return reponse.StatusCode == HttpStatusCode.NotFound
|
|
? null
|
|
: await LireAsync<List<PretDto>>(reponse, ct);
|
|
});
|
|
|
|
return ok ? prets : null;
|
|
}
|
|
|
|
/// <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),
|
|
};
|
|
|
|
return await EcrireAsync<PretDto>(
|
|
() => http.PostAsJsonAsync($"api/livres/{livreId}/prets", saisie, Json, ct), 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) =>
|
|
await EcrireAsync<PretDto>(
|
|
() => http.PostAsJsonAsync(
|
|
$"api/prets/{pretId}/retour", new RetourPret { DateRetour = EnUtc(dateRetour) }, Json, ct),
|
|
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)
|
|
{
|
|
if (!reseau.EnLigne)
|
|
{
|
|
return EtatReseau.MotifHorsLigne;
|
|
}
|
|
|
|
try
|
|
{
|
|
var reponse = await http.DeleteAsync($"api/livres/{id}", ct);
|
|
reseau.SignalerSuccesReseau();
|
|
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
reseau.SignalerEchecReseau();
|
|
return EtatReseau.MotifHorsLigne;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Interroge la cascade BnF → OpenLibrary. <b>Exige le réseau</b> : rien n'est mis en cache,
|
|
/// et hors-ligne l'appel n'est même pas tenté (<c>null</c> immédiat).
|
|
/// </summary>
|
|
public async Task<ResultatLookupIsbn?> ChercherIsbnAsync(string isbn, CancellationToken ct = default)
|
|
{
|
|
var (ok, resultat) = await EssayerAsync(async () =>
|
|
{
|
|
var reponse = await http.GetAsync($"api/isbn/{Uri.EscapeDataString(isbn)}", ct);
|
|
return reponse.IsSuccessStatusCode
|
|
? await LireAsync<ResultatLookupIsbn>(reponse, ct)
|
|
: null;
|
|
});
|
|
|
|
return ok ? resultat : null;
|
|
}
|
|
|
|
public async Task<UtilisateurCourant> ObtenirUtilisateurAsync(CancellationToken ct = default)
|
|
{
|
|
var (ok, utilisateur) = await EssayerAsync(
|
|
() => http.GetFromJsonAsync<UtilisateurCourant>("api/moi", Json, ct));
|
|
|
|
if (ok && utilisateur is not null)
|
|
{
|
|
await MemoriserAsync(ClesCache.Utilisateur, utilisateur);
|
|
return utilisateur;
|
|
}
|
|
|
|
// Hors-ligne, le bandeau garde le nom de la dernière session plutôt que de faire croire
|
|
// à une déconnexion.
|
|
var instantane = await LireCacheAsync<UtilisateurCourant>(ClesCache.Utilisateur);
|
|
return instantane?.Donnees ?? UtilisateurCourant.Anonyme;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Version publiée du serveur, avec repli sur la dernière connue de cet appareil.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Même forme que <see cref="ObtenirUtilisateurAsync"/>. Hors-ligne on rend l'instantané
|
|
/// plutôt que rien : c'est un fait daté (« voilà ce que le serveur annonçait »), et c'est
|
|
/// justement quand l'application se comporte mal qu'on veut ce numéro. L'écran distingue les
|
|
/// deux cas ; ici on ne fabrique aucune valeur.
|
|
/// </remarks>
|
|
public async Task<VersionApplication> ObtenirVersionAsync(CancellationToken ct = default)
|
|
{
|
|
var (ok, version) = await EssayerAsync(
|
|
() => http.GetFromJsonAsync<VersionApplication>("api/version", Json, ct));
|
|
|
|
if (ok && version is not null)
|
|
{
|
|
await MemoriserAsync(ClesCache.Version, version);
|
|
return version;
|
|
}
|
|
|
|
var instantane = await LireCacheAsync<VersionApplication>(ClesCache.Version);
|
|
return instantane?.Donnees ?? VersionApplication.Developpement;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exécute un appel de lecture. Renvoie <c>false</c> — sans exception — quand le réseau
|
|
/// manque, ce qui est la bascule vers le cache. Une annulation demandée par l'appelant,
|
|
/// elle, continue de remonter : c'est une frappe au clavier, pas une panne.
|
|
/// </summary>
|
|
private async Task<(bool Ok, T? Valeur)> EssayerAsync<T>(Func<Task<T?>> appel)
|
|
{
|
|
// TenterLeReseau et non EnLigne : quand la panne vient du serveur, le navigateur n'a
|
|
// jamais quitté le réseau et n'émettra donc aucun événement « online ». C'est la lecture
|
|
// suivante qui doit constater le retour, sans quoi l'application resterait sur le cache
|
|
// jusqu'au rechargement de la page.
|
|
if (!reseau.TenterLeReseau)
|
|
{
|
|
return (false, default);
|
|
}
|
|
|
|
try
|
|
{
|
|
var valeur = await appel();
|
|
reseau.SignalerSuccesReseau();
|
|
return (true, valeur);
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
reseau.SignalerEchecReseau();
|
|
return (false, default);
|
|
}
|
|
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
|
|
{
|
|
// Délai dépassé sans annulation de l'appelant : le serveur ne répond pas.
|
|
reseau.SignalerEchecReseau();
|
|
return (false, default);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exécute une écriture, ou la <b>refuse</b> hors-ligne avec un motif affichable.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// L'interface doit désactiver ces actions bien avant d'arriver ici (CLAUDE.md : pas de
|
|
/// bouton qui échoue silencieusement). Ce refus est la seconde ligne, pour le cas où le
|
|
/// réseau tombe entre l'affichage et le clic.
|
|
/// </remarks>
|
|
private async Task<ResultatApi<T>> EcrireAsync<T>(
|
|
Func<Task<HttpResponseMessage>> appel, CancellationToken ct)
|
|
{
|
|
var (reponse, motif) = await EnvoyerAsync(appel);
|
|
|
|
if (reponse is null)
|
|
{
|
|
return new ResultatApi<T>(default, motif);
|
|
}
|
|
|
|
var resultat = await InterpreterAsync<T>(reponse, ct);
|
|
|
|
if (resultat.EstOk)
|
|
{
|
|
// La base a changé : l'instantané doit suivre, sinon une coupure juste après une
|
|
// modification montrerait un catalogue d'avant.
|
|
_ = RafraichirEnFondAsync();
|
|
}
|
|
|
|
return resultat;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Envoie une écriture : soit une réponse à interpréter, soit le motif de son impossibilité.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Séparé de <see cref="EcrireAsync{T}"/> parce que toutes les écritures ne s'interprètent
|
|
/// pas de la même façon : la création doit reconnaître le 409 des doublons, qui n'est pas un
|
|
/// échec. Le refus hors-ligne et le signalement de l'état du réseau, eux, sont communs.
|
|
/// </remarks>
|
|
private async Task<(HttpResponseMessage? Reponse, string? Motif)> EnvoyerAsync(
|
|
Func<Task<HttpResponseMessage>> appel)
|
|
{
|
|
if (!reseau.EnLigne)
|
|
{
|
|
return (null, EtatReseau.MotifHorsLigne);
|
|
}
|
|
|
|
try
|
|
{
|
|
var reponse = await appel();
|
|
reseau.SignalerSuccesReseau();
|
|
return (reponse, null);
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
reseau.SignalerEchecReseau();
|
|
return (null, EtatReseau.MotifHorsLigne);
|
|
}
|
|
}
|
|
|
|
/// <summary>Remet l'instantané à jour sans faire attendre l'utilisateur.</summary>
|
|
private async Task RafraichirEnFondAsync()
|
|
{
|
|
try
|
|
{
|
|
await ListerAsync(new CritereLivres());
|
|
await ListerPretsEnCoursAsync();
|
|
await ListerAuteursAsync();
|
|
await ListerSouhaitsAsync();
|
|
await ListerRevuesSouhaiteesAsync();
|
|
await ListerSeriesAsync();
|
|
await ListerRevuesAsync();
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Le cache restera daté de la synchronisation précédente : l'interface le dit.
|
|
}
|
|
}
|
|
|
|
private async Task MemoriserAsync<T>(string cle, T valeur)
|
|
{
|
|
var date = DateTimeOffset.Now;
|
|
await cache.EcrireAsync(cle, valeur, date);
|
|
reseau.SignalerSynchro(date);
|
|
}
|
|
|
|
/// <summary>Relit un instantané et fait remonter sa date, celle que l'interface affiche.</summary>
|
|
private async Task<Instantane<T>?> LireCacheAsync<T>(string cle)
|
|
{
|
|
var instantane = await cache.LireAsync<T>(cle);
|
|
|
|
if (instantane is not null)
|
|
{
|
|
reseau.SignalerSynchro(instantane.Date);
|
|
}
|
|
|
|
return instantane;
|
|
}
|
|
|
|
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)
|
|
{
|
|
var (ok, souhaits) = await EssayerAsync(
|
|
() => http.GetFromJsonAsync<List<SouhaitDto>>("api/souhaits", Json, ct));
|
|
|
|
if (ok)
|
|
{
|
|
await MemoriserAsync(ClesCache.Souhaits, souhaits ?? []);
|
|
return souhaits ?? [];
|
|
}
|
|
|
|
var instantane = await LireCacheAsync<List<SouhaitDto>>(ClesCache.Souhaits);
|
|
return instantane?.Donnees ?? [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Corrige une envie. Passe par <see cref="EcrireAsync{T}"/> : la réponse porte l'envie
|
|
/// modifiée, donc il y a bien quelque chose à désérialiser — contrairement au 204 de la
|
|
/// suppression.
|
|
/// </summary>
|
|
public Task<ResultatApi<SouhaitDto>> ModifierSouhaitAsync(
|
|
int id, EnregistrementSouhait saisie, CancellationToken ct = default) =>
|
|
EcrireAsync<SouhaitDto>(
|
|
() => http.PutAsJsonAsync($"api/souhaits/{id}", saisie, Json, ct), ct);
|
|
|
|
/// <summary>
|
|
/// Les revues et numéros souhaités <b>de l'utilisateur courant</b>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Second instantané de la liste d'envies, et il n'est pas facultatif : c'est l'écran qu'on
|
|
/// emporte en librairie ou au kiosque, donc là où le réseau manque.
|
|
/// </remarks>
|
|
public async Task<IReadOnlyList<RevueSouhaiteeDto>> ListerRevuesSouhaiteesAsync(
|
|
CancellationToken ct = default)
|
|
{
|
|
var (ok, envies) = await EssayerAsync(
|
|
() => http.GetFromJsonAsync<List<RevueSouhaiteeDto>>("api/souhaits/revues", Json, ct));
|
|
|
|
if (ok)
|
|
{
|
|
await MemoriserAsync(ClesCache.SouhaitsRevues, envies ?? []);
|
|
return envies ?? [];
|
|
}
|
|
|
|
var instantane = await LireCacheAsync<List<RevueSouhaiteeDto>>(ClesCache.SouhaitsRevues);
|
|
return instantane?.Donnees ?? [];
|
|
}
|
|
|
|
public Task<ResultatApi<RevueSouhaiteeDto>> AjouterRevueSouhaiteeAsync(
|
|
EnregistrementRevueSouhaitee saisie, CancellationToken ct = default) =>
|
|
EcrireAsync<RevueSouhaiteeDto>(
|
|
() => http.PostAsJsonAsync("api/souhaits/revues", saisie, Json, ct), ct);
|
|
|
|
public Task<ResultatApi<RevueSouhaiteeDto>> ModifierRevueSouhaiteeAsync(
|
|
int id, EnregistrementRevueSouhaitee saisie, CancellationToken ct = default) =>
|
|
EcrireAsync<RevueSouhaiteeDto>(
|
|
() => http.PutAsJsonAsync($"api/souhaits/revues/{id}", saisie, Json, ct), ct);
|
|
|
|
/// <summary>
|
|
/// Retire une revue souhaitée. <c>null</c> si c'est fait, sinon le motif à afficher.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Comme la suppression d'une envie de livre, elle répond <c>204 No Content</c> : la
|
|
/// passer par <see cref="EcrireAsync{T}"/> lèverait une exception sur un corps vide, là où
|
|
/// tout s'est bien passé.
|
|
/// </remarks>
|
|
public async Task<string?> SupprimerRevueSouhaiteeAsync(int id, CancellationToken ct = default)
|
|
{
|
|
if (!reseau.EnLigne)
|
|
{
|
|
return EtatReseau.MotifHorsLigne;
|
|
}
|
|
|
|
try
|
|
{
|
|
var reponse = await http.DeleteAsync($"api/souhaits/revues/{id}", ct);
|
|
reseau.SignalerSuccesReseau();
|
|
|
|
if (!reponse.IsSuccessStatusCode)
|
|
{
|
|
return await MessageErreurAsync(reponse, ct);
|
|
}
|
|
|
|
_ = RafraichirEnFondAsync();
|
|
return null;
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
reseau.SignalerEchecReseau();
|
|
return EtatReseau.MotifHorsLigne;
|
|
}
|
|
}
|
|
|
|
public async Task<ResultatApi<SouhaitDto>> AjouterSouhaitAsync(
|
|
EnregistrementSouhait saisie, CancellationToken ct = default)
|
|
{
|
|
var resultat = await EcrireAsync<SouhaitDto>(
|
|
() => http.PostAsJsonAsync("api/souhaits", saisie, Json, ct), ct);
|
|
if (resultat.EstOk)
|
|
{
|
|
// ⚠️ Surtout PAS _bibliographies.Clear() : la bibliographie suivante repartirait
|
|
// interroger la BnF (~1 s par page, jusqu'à dix pages pour les nouveautés). C'est
|
|
// exactement ce qui rendait « Je le veux » interminable — l'écran attendait une
|
|
// source distante pour apprendre une chose qu'il savait déjà.
|
|
PatcherOeuvres(
|
|
o => CleOeuvre.Cle(o.Titre) == CleOeuvre.Cle(saisie.Titre),
|
|
o => o with { Souhaite = true, SouhaitId = resultat.Valeur?.Id });
|
|
}
|
|
|
|
return resultat;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<ResultatApi<SouhaitDto>>> AjouterSouhaitsAsync(
|
|
IReadOnlyList<EnregistrementSouhait> saisies, CancellationToken ct = default)
|
|
{
|
|
var resultats = new List<ResultatApi<SouhaitDto>>(saisies.Count);
|
|
foreach (var saisie in saisies)
|
|
{
|
|
resultats.Add(await AjouterSouhaitAsync(saisie, ct));
|
|
}
|
|
|
|
return resultats;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retire une envie. <c>null</c> si c'est fait, sinon le motif à afficher.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Ne <b>pas</b> passer par <see cref="EcrireAsync{T}"/> : la suppression répond
|
|
/// <c>204 No Content</c>, et désérialiser un corps vide lèverait une exception là où tout
|
|
/// s'est bien passé. Même raison que pour la suppression d'un livre, dont ceci reprend
|
|
/// exactement la forme.
|
|
/// </remarks>
|
|
public async Task<string?> SupprimerSouhaitAsync(int id, CancellationToken ct = default)
|
|
{
|
|
if (!reseau.EnLigne)
|
|
{
|
|
return EtatReseau.MotifHorsLigne;
|
|
}
|
|
|
|
try
|
|
{
|
|
var reponse = await http.DeleteAsync($"api/souhaits/{id}", ct);
|
|
reseau.SignalerSuccesReseau();
|
|
return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
reseau.SignalerEchecReseau();
|
|
return EtatReseau.MotifHorsLigne;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fixe l'ordre de la liste d'envies. <c>null</c> si c'est fait, sinon le motif à afficher.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Envoie la liste <b>entière</b> des identifiants : c'est ce que réclame le glisser-déposer,
|
|
/// et les flèches s'y ramènent. Comme la suppression, elle répond <c>204 No Content</c> et ne
|
|
/// passe donc pas par <see cref="EcrireAsync{T}"/>.
|
|
/// </remarks>
|
|
public async Task<string?> ReordonnerSouhaitsAsync(
|
|
IReadOnlyList<int> ids, CancellationToken ct = default)
|
|
{
|
|
if (!reseau.EnLigne)
|
|
{
|
|
return EtatReseau.MotifHorsLigne;
|
|
}
|
|
|
|
try
|
|
{
|
|
var reponse = await http.PutAsJsonAsync("api/souhaits/ordre", ids, Json, ct);
|
|
reseau.SignalerSuccesReseau();
|
|
|
|
if (!reponse.IsSuccessStatusCode)
|
|
{
|
|
return await MessageErreurAsync(reponse, ct);
|
|
}
|
|
|
|
// L'instantané porte l'ordre : sans ce rafraîchissement, une coupure juste après un
|
|
// déplacement rendrait la liste d'avant.
|
|
_ = RafraichirEnFondAsync();
|
|
return null;
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
reseau.SignalerEchecReseau();
|
|
return EtatReseau.MotifHorsLigne;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cherche des notices par titre, éventuellement restreint à un auteur.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>Exige le réseau</b>, comme le lookup ISBN : rien n'est mis en cache. Sert l'ajout
|
|
/// d'une envie quand on n'a pas le livre en main — c'est-à-dire le cas normal.
|
|
/// </remarks>
|
|
public async Task<ResultatLookupIsbn?> ChercherNoticesAsync(
|
|
string titre, string? auteur, CancellationToken ct = default)
|
|
{
|
|
var parametres = $"titre={Uri.EscapeDataString(titre.Trim())}";
|
|
if (!string.IsNullOrWhiteSpace(auteur))
|
|
{
|
|
parametres += $"&auteur={Uri.EscapeDataString(auteur.Trim())}";
|
|
}
|
|
|
|
var (ok, resultat) = await EssayerAsync(async () =>
|
|
{
|
|
var reponse = await http.GetAsync($"api/notices?{parametres}", ct);
|
|
return reponse.IsSuccessStatusCode
|
|
? await LireAsync<ResultatLookupIsbn>(reponse, ct)
|
|
: null;
|
|
});
|
|
|
|
return ok ? resultat : null;
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
if (_bibliographies.TryGetValue(auteurId, out var cachee)
|
|
&& cachee.Etat == EtatSourceBibliographie.Ok)
|
|
{
|
|
return cachee;
|
|
}
|
|
|
|
var reponse = await http.GetAsync($"api/auteurs/{auteurId}/bibliographie", ct);
|
|
var bibliographie = reponse.StatusCode == HttpStatusCode.NotFound
|
|
? null
|
|
: await LireAsync<BibliographieDto>(reponse, ct);
|
|
if (bibliographie?.Etat == EtatSourceBibliographie.Ok)
|
|
{
|
|
_bibliographies[auteurId] = bibliographie;
|
|
}
|
|
|
|
return bibliographie;
|
|
}
|
|
|
|
public async Task<BibliographieDto?> ObtenirNouveautesAsync(
|
|
int auteurId, CancellationToken ct = default)
|
|
{
|
|
var reponse = await http.GetAsync(
|
|
$"api/auteurs/{auteurId}/bibliographie/nouveautes", ct);
|
|
return reponse.StatusCode == HttpStatusCode.NotFound
|
|
? null
|
|
: await LireAsync<BibliographieDto>(reponse, ct);
|
|
}
|
|
|
|
public async Task<string?> MasquerOeuvreBibliographieAsync(
|
|
int auteurId, string titre, CancellationToken ct = default) =>
|
|
await ModifierBibliographieAsync(
|
|
() => http.PostAsJsonAsync(
|
|
$"api/auteurs/{auteurId}/bibliographie/masque",
|
|
new MasquageBibliographie { Titre = titre }, Json, ct), titre, true, ct);
|
|
|
|
public async Task<string?> DemasquerOeuvreBibliographieAsync(
|
|
int auteurId, string titre, CancellationToken ct = default) =>
|
|
await ModifierBibliographieAsync(
|
|
() => http.DeleteAsync(
|
|
$"api/auteurs/{auteurId}/bibliographie/masque?titre={Uri.EscapeDataString(titre)}"),
|
|
titre, false, ct);
|
|
|
|
private async Task<string?> ModifierBibliographieAsync(
|
|
Func<Task<HttpResponseMessage>> appel, string titre, bool masquee, CancellationToken ct)
|
|
{
|
|
var (reponse, motif) = await EnvoyerAsync(appel);
|
|
if (reponse is null)
|
|
{
|
|
return motif;
|
|
}
|
|
|
|
if (!reponse.IsSuccessStatusCode)
|
|
{
|
|
return await MessageErreurAsync(reponse, ct);
|
|
}
|
|
|
|
PatcherOeuvres(o => o.Titre == titre, o => o with { Masquee = masquee });
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Corrige en place les œuvres des bibliographies déjà en mémoire.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ C'est ce qui remplace l'ancien <c>_bibliographies.Clear()</c>, et la différence est
|
|
/// une seconde d'attente contre plusieurs. Masquer une œuvre ou l'ajouter à ses envies ne
|
|
/// change <b>rien</b> à ce que la BnF connaît de l'auteur : seuls des drapeaux
|
|
/// <i>personnels</i> basculent, et le client sait lesquels sans redemander quoi que ce soit.
|
|
/// <para>
|
|
/// Ce cache reste, comme depuis le lot D, en mémoire et pour la seule session : rien n'est
|
|
/// écrit dans l'instantané hors-ligne, une bibliographie n'étant pas une donnée à nous.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void PatcherOeuvres(
|
|
Func<OeuvreBibliographie, bool> cible,
|
|
Func<OeuvreBibliographie, OeuvreBibliographie> correction)
|
|
{
|
|
foreach (var (auteurId, biblio) in _bibliographies.ToList())
|
|
{
|
|
if (!biblio.Oeuvres.Any(cible))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
_bibliographies[auteurId] = biblio with
|
|
{
|
|
Oeuvres = biblio.Oeuvres.Select(o => cible(o) ? correction(o) : o).ToList(),
|
|
};
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Sagas, cycles et séries. COMMUNES au foyer, comme le catalogue : aucune identité ne
|
|
// transite, sauf pour la mise en envies, qui écrit dans une liste personnelle.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Toutes les séries, avec leurs tomes. Une seule lecture sert la liste et chaque détail.
|
|
/// </summary>
|
|
public async Task<IReadOnlyList<SerieDto>> ListerSeriesAsync(CancellationToken ct = default)
|
|
{
|
|
var (ok, series) = await EssayerAsync(
|
|
() => http.GetFromJsonAsync<List<SerieDto>>("api/series", Json, ct));
|
|
|
|
if (ok)
|
|
{
|
|
await MemoriserAsync(ClesCache.Series, series ?? []);
|
|
return series ?? [];
|
|
}
|
|
|
|
var instantane = await LireCacheAsync<List<SerieDto>>(ClesCache.Series);
|
|
return instantane?.Donnees ?? [];
|
|
}
|
|
|
|
public async Task<ResultatApi<SerieDto>> CreerSerieAsync(
|
|
EnregistrementSerie saisie, CancellationToken ct = default) =>
|
|
await EcrireAsync<SerieDto>(() => http.PostAsJsonAsync("api/series", saisie, Json, ct), ct);
|
|
|
|
public async Task<ResultatApi<SerieDto>> ModifierSerieAsync(
|
|
int id, EnregistrementSerie saisie, CancellationToken ct = default) =>
|
|
await EcrireAsync<SerieDto>(() => http.PutAsJsonAsync($"api/series/{id}", saisie, Json, ct), ct);
|
|
|
|
public async Task<ResultatApi<SerieDto>> AjouterElementSerieAsync(
|
|
int serieId, AjoutElementSerie saisie, CancellationToken ct = default) =>
|
|
await EcrireAsync<SerieDto>(
|
|
() => http.PostAsJsonAsync($"api/series/{serieId}/elements", saisie, Json, ct), ct);
|
|
|
|
/// <summary>Rattache un livre possédé à une place, ou l'en détache avec <c>null</c>.</summary>
|
|
public async Task<ResultatApi<SerieDto>> RattacherLivreSerieAsync(
|
|
int elementId, int? livreId, CancellationToken ct = default) =>
|
|
await EcrireAsync<SerieDto>(
|
|
() => http.PutAsJsonAsync(
|
|
$"api/series/elements/{elementId}/livre", new { livreId }, Json, ct), ct);
|
|
|
|
public async Task<ResultatApi<SerieDto>> ReordonnerSerieAsync(
|
|
int serieId, IReadOnlyList<int> ids, CancellationToken ct = default) =>
|
|
await EcrireAsync<SerieDto>(
|
|
() => http.PutAsJsonAsync(
|
|
$"api/series/{serieId}/ordre", new OrdreElementsSerie { Ids = [.. ids] }, Json, ct), ct);
|
|
|
|
public async Task<ResultatApi<SerieDto>> ReordonnerSousSeriesAsync(
|
|
int serieId, IReadOnlyList<int> ids, CancellationToken ct = default) =>
|
|
await EcrireAsync<SerieDto>(
|
|
() => http.PutAsJsonAsync(
|
|
$"api/series/{serieId}/sous-series/ordre",
|
|
new OrdreSeries { Ids = [.. ids] }, Json, ct), ct);
|
|
|
|
/// <summary>Met un tome manquant dans <b>ma</b> liste d'envies — la série, elle, est commune.</summary>
|
|
public async Task<ResultatApi<SouhaitDto>> SouhaiterTomeAsync(
|
|
int elementId, CancellationToken ct = default) =>
|
|
await EcrireAsync<SouhaitDto>(
|
|
() => http.PostAsync($"api/series/elements/{elementId}/souhait", null, ct), ct);
|
|
|
|
/// <summary>
|
|
/// Retire une place de la série, ou la série entière. <c>null</c> si c'est fait.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Comme les autres suppressions : réponse <c>204 No Content</c>, donc pas de
|
|
/// désérialisation, donc pas <see cref="EcrireAsync{T}"/>.
|
|
/// </remarks>
|
|
public Task<string?> RetirerElementSerieAsync(int elementId, CancellationToken ct = default) =>
|
|
SupprimerAsync($"api/series/elements/{elementId}", ct);
|
|
|
|
public Task<string?> SupprimerSerieAsync(int id, CancellationToken ct = default) =>
|
|
SupprimerAsync($"api/series/{id}", ct);
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Revues et magazines. Communes au foyer ; une revue n'est pas un livre et ne passe donc
|
|
// jamais par le catalogue.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
public async Task<IReadOnlyList<RevueDto>> ListerRevuesAsync(CancellationToken ct = default)
|
|
{
|
|
var (ok, revues) = await EssayerAsync(
|
|
() => http.GetFromJsonAsync<List<RevueDto>>("api/revues", Json, ct));
|
|
|
|
if (ok)
|
|
{
|
|
await MemoriserAsync(ClesCache.Revues, revues ?? []);
|
|
return revues ?? [];
|
|
}
|
|
|
|
var instantane = await LireCacheAsync<List<RevueDto>>(ClesCache.Revues);
|
|
return instantane?.Donnees ?? [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Crée la revue, ou rend celle qui existe déjà — même ISSN, ou même titre.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// C'est ce que réclame le scan d'un code-barres <c>977</c> : le second numéro d'un
|
|
/// magazine retombe forcément sur la même revue, et l'utilisateur ne fait jamais qu'un
|
|
/// geste, « voici un numéro de plus ».
|
|
/// </remarks>
|
|
public async Task<ResultatApi<RevueDto>> CreerRevueAsync(
|
|
EnregistrementRevue saisie, CancellationToken ct = default) =>
|
|
await EcrireAsync<RevueDto>(() => http.PostAsJsonAsync("api/revues", saisie, Json, ct), ct);
|
|
|
|
public async Task<ResultatApi<RevueDto>> ModifierRevueAsync(
|
|
int id, EnregistrementRevue saisie, CancellationToken ct = default) =>
|
|
await EcrireAsync<RevueDto>(() => http.PutAsJsonAsync($"api/revues/{id}", saisie, Json, ct), ct);
|
|
|
|
public async Task<ResultatApi<RevueDto>> AjouterNumeroAsync(
|
|
int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default) =>
|
|
await EcrireAsync<RevueDto>(
|
|
() => http.PostAsJsonAsync($"api/revues/{revueId}/numeros", saisie, Json, ct), ct);
|
|
|
|
/// <summary>
|
|
/// Modifie un numéro déjà recensé, couverture et articles à la une compris.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Ces deux-là ne viennent d'aucune source — l'ISSN désigne la revue, pas la parution — et
|
|
/// se saisissent donc après coup, une fois le magazine en main.
|
|
/// </remarks>
|
|
public async Task<ResultatApi<RevueDto>> ModifierNumeroAsync(
|
|
int numeroId, AjoutNumeroRevue saisie, CancellationToken ct = default) =>
|
|
await EcrireAsync<RevueDto>(
|
|
() => http.PutAsJsonAsync($"api/revues/numeros/{numeroId}", saisie, Json, ct), ct);
|
|
|
|
public Task<string?> RetirerNumeroAsync(int numeroId, CancellationToken ct = default) =>
|
|
SupprimerAsync($"api/revues/numeros/{numeroId}", ct);
|
|
|
|
public Task<string?> SupprimerRevueAsync(int id, CancellationToken ct = default) =>
|
|
SupprimerAsync($"api/revues/{id}", ct);
|
|
|
|
/// <summary>Suppression générique : refus hors-ligne, 204 attendu, instantanés rafraîchis.</summary>
|
|
private async Task<string?> SupprimerAsync(string url, CancellationToken ct)
|
|
{
|
|
if (!reseau.EnLigne)
|
|
{
|
|
return EtatReseau.MotifHorsLigne;
|
|
}
|
|
|
|
try
|
|
{
|
|
var reponse = await http.DeleteAsync(url, ct);
|
|
reseau.SignalerSuccesReseau();
|
|
|
|
if (!reponse.IsSuccessStatusCode)
|
|
{
|
|
return await MessageErreurAsync(reponse, ct);
|
|
}
|
|
|
|
_ = RafraichirEnFondAsync();
|
|
return null;
|
|
}
|
|
catch (HttpRequestException)
|
|
{
|
|
reseau.SignalerEchecReseau();
|
|
return EtatReseau.MotifHorsLigne;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Résultat d'un ajout au catalogue : le livre, une erreur, ou des doublons à confirmer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Trois issues et non deux : un doublon signalé n'est <b>pas</b> une erreur. La même saisie,
|
|
/// confirmée, s'enregistrera telle quelle — l'écran doit donc offrir « Ajouter quand même »
|
|
/// plutôt qu'un message rouge qui laisserait croire à un refus.
|
|
/// </remarks>
|
|
public readonly record struct ResultatCreation(LivreDto? Livre, string? Erreur, DoublonsLivre? Doublons)
|
|
{
|
|
public bool EstOk => Livre is not null;
|
|
}
|