Page « À propos » (/a-propos) : manuel annoncé à venir, contact, site de l'auteur, version publiée avec sa date de build, et licence AGPL v3 avec le lien vers le dépôt — l'AGPL attend que les utilisateurs d'un service en réseau puissent en obtenir la source, ce lien n'est donc pas un ornement. L'application ne connaissait pas sa version : elle vivait dans le manifeste du paquet et dans les tags git, jamais dans le binaire. `build/publier-release.sh` pose désormais `-p:Version` et `-p:MaBibliDateBuild` ; l'API rend les deux par `GET /api/version`. ⚠️ L'horodatage est le TÉMOIN de l'injection, et rien ne le calcule côté MSBuild. Sans lui, la version lue serait le « 1.0.0 » que le SDK pose par défaut : il se lirait comme une vraie version alors qu'il ne désigne rien, et c'est exactement la valeur qu'on ira chercher pour diagnostiquer un appareil au cache dépareillé. Mieux vaut ne rien annoncer — un binaire compilé à la main se déclare « version de développement », ce qui est vrai. Autres décisions : - l'entrée du menu est DÉTACHÉE des six destinations, par un filet au-dessus sur téléphone et à gauche en rangée sur PC : « À propos » est une annexe, pas une septième destination. Ses règles vivent en feuille GLOBALE, comme tout le menu — une règle scopée n'atteint pas ce que rend un NavLink ; - le « mailto: » reste un lien même hors-ligne : il ne charge aucune page et passe la main au client de messagerie, qui sait mettre un message en attente. Le site et le dépôt, eux, basculent en boutons désactivés portant leur motif, comme les liens d'export des envies ; - la version est un sixième instantané hors-ligne : on la lit justement quand quelque chose ne va pas, et un appareil qu'on soupçonne est souvent celui qui n'a plus de réseau. L'écran dit alors que c'est la dernière version vue du serveur. Le point de rupture de 40 rem reste identique dans les deux feuilles, et /a-propos est inscrite dans la table de remontée des routes. Vérifié en exécution, l'API lancée : sans injection `{"numero":null,"publiee":false}`, avec `-p:Version=0.4.1 -p:MaBibliDateBuild=…` `{"numero":"0.4.1","publiee":true}`. Le rendu des écrans n'a PAS été vérifié en navigateur. 618 tests au vert (605 avant). 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;
|
|
}
|