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; /// /// Accès à l'API depuis le client Blazor, avec repli sur le cache hors-ligne. /// /// /// Le piège que ce service existe pour éviter (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 assets — 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. /// /// La règle est simple et sans exception : les lectures retombent sur le cache, les écritures /// sont refusées. 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 ». /// /// public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, EtatReseau reseau) { private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); private readonly Dictionary _bibliographies = []; /// /// Recharge tous les instantanés depuis le réseau. /// /// /// 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. /// /// Vrai si les données ont pu être rafraîchies. public async Task 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> ListerAsync(CritereLivres criteres, CancellationToken ct = default) { var parametres = new List(); 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>(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>(ClesCache.Catalogue); return instantane is null ? [] : FiltreLivresLocal.Appliquer(instantane.Donnees, criteres); } public async Task 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(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>(ClesCache.Catalogue); return instantane?.Donnees.FirstOrDefault(l => l.Id == id); } /// /// Ajoute un livre, en rapportant le cas échéant ce que le catalogue a déjà de semblable. /// /// /// 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. /// public async Task 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(reponse, ct); if (resultat.EstOk) { _ = RafraichirEnFondAsync(); } return new ResultatCreation(resultat.Valeur, resultat.Erreur, null); } /// Lit le corps d'un 409, ou null s'il est illisible. private static async Task LireDoublonsAsync( HttpResponseMessage reponse, CancellationToken ct) { try { var doublons = await reponse.Content.ReadFromJsonAsync(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> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default) => await EcrireAsync(() => http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct), ct); /// /// Change le statut de lecture de l'utilisateur courant ; null le remet à /// « non commencé ». Le serveur sait qui appelle : le client ne transmet jamais d'identité. /// public async Task> ChangerStatutAsync( int id, Statut? statut, CancellationToken ct = default) => await EcrireAsync( () => http.PatchAsJsonAsync( $"api/livres/{id}/statut", new ChangementStatut { Statut = statut }, Json, ct), ct); public async Task> ListerAuteursAsync(CancellationToken ct = default) { var (ok, auteurs) = await EssayerAsync( () => http.GetFromJsonAsync>("api/auteurs", Json, ct)); if (ok) { await MemoriserAsync(ClesCache.Auteurs, auteurs ?? []); return auteurs ?? []; } var instantane = await LireCacheAsync>(ClesCache.Auteurs); return instantane?.Donnees ?? []; } public async Task 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(reponse, ct); }); if (ok) { return auteur; } var instantane = await LireCacheAsync>(ClesCache.Auteurs); return instantane?.Donnees.FirstOrDefault(a => a.Id == id); } public async Task 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(reponse, ct); } return null; } catch (HttpRequestException) { reseau.SignalerEchecReseau(); return null; } } /// /// Rapprochements d'auteurs ambigus, que le serveur refuse d'appliquer seul. /// /// /// Volontairement pas 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. /// public async Task> ListerRapprochementsAsync( CancellationToken ct = default) { var (ok, suggestions) = await EssayerAsync( () => http.GetFromJsonAsync>("api/auteurs/rapprochements", Json, ct)); return ok ? suggestions ?? [] : []; } public Task AccepterRapprochementAsync(int sourceId, int cibleId, CancellationToken ct = default) => DeciderAsync("accepter", sourceId, cibleId, ct); public Task RefuserRapprochementAsync(int sourceId, int cibleId, CancellationToken ct = default) => DeciderAsync("refuser", sourceId, cibleId, ct); private async Task 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; } } /// Les livres actuellement dehors, du prêt le plus ancien au plus récent. public async Task> ListerPretsEnCoursAsync(CancellationToken ct = default) { var (ok, prets) = await EssayerAsync( () => http.GetFromJsonAsync>("api/prets/en-cours", Json, ct)); if (ok) { await MemoriserAsync(ClesCache.PretsEnCours, prets ?? []); return prets ?? []; } var instantane = await LireCacheAsync>(ClesCache.PretsEnCours); return instantane?.Donnees ?? []; } /// /// Historique complet des prêts d'un livre, du plus récent au plus ancien. /// /// /// Liste vide = jamais prêté ; null = livre inconnu ou réseau absent. L'appelant /// doit consulter 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. /// public async Task?> 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>(reponse, ct); }); return ok ? prets : null; } /// /// Prête un livre. est une date locale, convertie ici en /// UTC : tout ce que l'API stocke est en UTC, comme le reste des dates du projet. /// public async Task> PreterAsync( int livreId, string emprunteur, DateTime? datePret = null, CancellationToken ct = default) { var saisie = new EnregistrementPret { Emprunteur = emprunteur, DatePret = EnUtc(datePret), }; return await EcrireAsync( () => http.PostAsJsonAsync($"api/livres/{livreId}/prets", saisie, Json, ct), ct); } /// Clôt un prêt. Le prêt n'est pas supprimé : il devient l'historique du livre. public async Task> RendreAsync( int pretId, DateTime? dateRetour = null, CancellationToken ct = default) => await EcrireAsync( () => http.PostAsJsonAsync( $"api/prets/{pretId}/retour", new RetourPret { DateRetour = EnUtc(dateRetour) }, Json, ct), ct); /// /// Convertit une date issue d'un <input type="date"> en instant UTC. /// /// /// 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. /// private static DateTime? EnUtc(DateTime? date) => date is null ? null : DateTime.SpecifyKind(date.Value, DateTimeKind.Local).ToUniversalTime(); public async Task 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; } } /// /// Interroge la cascade BnF → OpenLibrary. Exige le réseau : rien n'est mis en cache, /// et hors-ligne l'appel n'est même pas tenté (null immédiat). /// public async Task 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(reponse, ct) : null; }); return ok ? resultat : null; } public async Task ObtenirUtilisateurAsync(CancellationToken ct = default) { var (ok, utilisateur) = await EssayerAsync( () => http.GetFromJsonAsync("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(ClesCache.Utilisateur); return instantane?.Donnees ?? UtilisateurCourant.Anonyme; } /// /// Version publiée du serveur, avec repli sur la dernière connue de cet appareil. /// /// /// Même forme que . 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. /// public async Task ObtenirVersionAsync(CancellationToken ct = default) { var (ok, version) = await EssayerAsync( () => http.GetFromJsonAsync("api/version", Json, ct)); if (ok && version is not null) { await MemoriserAsync(ClesCache.Version, version); return version; } var instantane = await LireCacheAsync(ClesCache.Version); return instantane?.Donnees ?? VersionApplication.Developpement; } /// /// Exécute un appel de lecture. Renvoie false — 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. /// private async Task<(bool Ok, T? Valeur)> EssayerAsync(Func> 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); } } /// /// Exécute une écriture, ou la refuse hors-ligne avec un motif affichable. /// /// /// 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. /// private async Task> EcrireAsync( Func> appel, CancellationToken ct) { var (reponse, motif) = await EnvoyerAsync(appel); if (reponse is null) { return new ResultatApi(default, motif); } var resultat = await InterpreterAsync(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; } /// /// Envoie une écriture : soit une réponse à interpréter, soit le motif de son impossibilité. /// /// /// Séparé de 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. /// private async Task<(HttpResponseMessage? Reponse, string? Motif)> EnvoyerAsync( Func> 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); } } /// Remet l'instantané à jour sans faire attendre l'utilisateur. 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(string cle, T valeur) { var date = DateTimeOffset.Now; await cache.EcrireAsync(cle, valeur, date); reseau.SignalerSynchro(date); } /// Relit un instantané et fait remonter sa date, celle que l'interface affiche. private async Task?> LireCacheAsync(string cle) { var instantane = await cache.LireAsync(cle); if (instantane is not null) { reseau.SignalerSynchro(instantane.Date); } return instantane; } private static async Task LireAsync(HttpResponseMessage reponse, CancellationToken ct) { reponse.EnsureSuccessStatusCode(); return await reponse.Content.ReadFromJsonAsync(Json, ct); } private static async Task> InterpreterAsync(HttpResponseMessage reponse, CancellationToken ct) { if (reponse.IsSuccessStatusCode) { var valeur = await reponse.Content.ReadFromJsonAsync(Json, ct); return new ResultatApi(valeur, null); } return new ResultatApi(default, await MessageErreurAsync(reponse, ct)); } /// Récupère le message d'erreur de l'API pour l'afficher tel quel à l'utilisateur. private static async Task MessageErreurAsync(HttpResponseMessage reponse, CancellationToken ct) { try { var corps = await reponse.Content.ReadFromJsonAsync(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é. // ───────────────────────────────────────────────────────────────────────── /// /// La liste d'envies de l'utilisateur courant. /// /// /// 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. /// public async Task> ListerSouhaitsAsync(CancellationToken ct = default) { var (ok, souhaits) = await EssayerAsync( () => http.GetFromJsonAsync>("api/souhaits", Json, ct)); if (ok) { await MemoriserAsync(ClesCache.Souhaits, souhaits ?? []); return souhaits ?? []; } var instantane = await LireCacheAsync>(ClesCache.Souhaits); return instantane?.Donnees ?? []; } /// /// Corrige une envie. Passe par : la réponse porte l'envie /// modifiée, donc il y a bien quelque chose à désérialiser — contrairement au 204 de la /// suppression. /// public Task> ModifierSouhaitAsync( int id, EnregistrementSouhait saisie, CancellationToken ct = default) => EcrireAsync( () => http.PutAsJsonAsync($"api/souhaits/{id}", saisie, Json, ct), ct); /// /// Les revues et numéros souhaités de l'utilisateur courant. /// /// /// 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. /// public async Task> ListerRevuesSouhaiteesAsync( CancellationToken ct = default) { var (ok, envies) = await EssayerAsync( () => http.GetFromJsonAsync>("api/souhaits/revues", Json, ct)); if (ok) { await MemoriserAsync(ClesCache.SouhaitsRevues, envies ?? []); return envies ?? []; } var instantane = await LireCacheAsync>(ClesCache.SouhaitsRevues); return instantane?.Donnees ?? []; } public Task> AjouterRevueSouhaiteeAsync( EnregistrementRevueSouhaitee saisie, CancellationToken ct = default) => EcrireAsync( () => http.PostAsJsonAsync("api/souhaits/revues", saisie, Json, ct), ct); public Task> ModifierRevueSouhaiteeAsync( int id, EnregistrementRevueSouhaitee saisie, CancellationToken ct = default) => EcrireAsync( () => http.PutAsJsonAsync($"api/souhaits/revues/{id}", saisie, Json, ct), ct); /// /// Retire une revue souhaitée. null si c'est fait, sinon le motif à afficher. /// /// /// ⚠️ Comme la suppression d'une envie de livre, elle répond 204 No Content : la /// passer par lèverait une exception sur un corps vide, là où /// tout s'est bien passé. /// public async Task 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> AjouterSouhaitAsync( EnregistrementSouhait saisie, CancellationToken ct = default) { var resultat = await EcrireAsync( () => 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>> AjouterSouhaitsAsync( IReadOnlyList saisies, CancellationToken ct = default) { var resultats = new List>(saisies.Count); foreach (var saisie in saisies) { resultats.Add(await AjouterSouhaitAsync(saisie, ct)); } return resultats; } /// /// Retire une envie. null si c'est fait, sinon le motif à afficher. /// /// /// ⚠️ Ne pas passer par : la suppression répond /// 204 No Content, 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. /// public async Task 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; } } /// /// Fixe l'ordre de la liste d'envies. null si c'est fait, sinon le motif à afficher. /// /// /// Envoie la liste entière 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 204 No Content et ne /// passe donc pas par . /// public async Task ReordonnerSouhaitsAsync( IReadOnlyList 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; } } /// /// Cherche des notices par titre, éventuellement restreint à un auteur. /// /// /// Exige le réseau, 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. /// public async Task 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(reponse, ct) : null; }); return ok ? resultat : null; } /// /// Adresse de téléchargement d'un export. /// /// /// Le fichier n'est pas récupéré par ce service : un simple lien suffit, le serveur pose un /// Content-Disposition: attachment et le navigateur enregistre. Passer par /// obligerait à fabriquer une URL d'objet en JavaScript pour un /// résultat identique. /// /// ⚠️ Le lien doit porter l'attribut download : 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. /// /// public static string UrlExportSouhaits(FormatExportSouhaits format) => format == FormatExportSouhaits.Csv ? "api/souhaits/export.csv" : "api/souhaits/export.txt"; /// /// Bibliographie d'un auteur d'après la BnF. null si l'auteur n'existe pas. /// public async Task 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(reponse, ct); if (bibliographie?.Etat == EtatSourceBibliographie.Ok) { _bibliographies[auteurId] = bibliographie; } return bibliographie; } public async Task 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(reponse, ct); } public async Task 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 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 ModifierBibliographieAsync( Func> 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; } /// /// Corrige en place les œuvres des bibliographies déjà en mémoire. /// /// /// ⚠️ C'est ce qui remplace l'ancien _bibliographies.Clear(), et la différence est /// une seconde d'attente contre plusieurs. Masquer une œuvre ou l'ajouter à ses envies ne /// change rien à ce que la BnF connaît de l'auteur : seuls des drapeaux /// personnels basculent, et le client sait lesquels sans redemander quoi que ce soit. /// /// 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. /// /// private void PatcherOeuvres( Func cible, Func 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. // ───────────────────────────────────────────────────────────────────────── /// /// Toutes les séries, avec leurs tomes. Une seule lecture sert la liste et chaque détail. /// public async Task> ListerSeriesAsync(CancellationToken ct = default) { var (ok, series) = await EssayerAsync( () => http.GetFromJsonAsync>("api/series", Json, ct)); if (ok) { await MemoriserAsync(ClesCache.Series, series ?? []); return series ?? []; } var instantane = await LireCacheAsync>(ClesCache.Series); return instantane?.Donnees ?? []; } public async Task> CreerSerieAsync( EnregistrementSerie saisie, CancellationToken ct = default) => await EcrireAsync(() => http.PostAsJsonAsync("api/series", saisie, Json, ct), ct); public async Task> ModifierSerieAsync( int id, EnregistrementSerie saisie, CancellationToken ct = default) => await EcrireAsync(() => http.PutAsJsonAsync($"api/series/{id}", saisie, Json, ct), ct); public async Task> AjouterElementSerieAsync( int serieId, AjoutElementSerie saisie, CancellationToken ct = default) => await EcrireAsync( () => http.PostAsJsonAsync($"api/series/{serieId}/elements", saisie, Json, ct), ct); /// Rattache un livre possédé à une place, ou l'en détache avec null. public async Task> RattacherLivreSerieAsync( int elementId, int? livreId, CancellationToken ct = default) => await EcrireAsync( () => http.PutAsJsonAsync( $"api/series/elements/{elementId}/livre", new { livreId }, Json, ct), ct); public async Task> ReordonnerSerieAsync( int serieId, IReadOnlyList ids, CancellationToken ct = default) => await EcrireAsync( () => http.PutAsJsonAsync( $"api/series/{serieId}/ordre", new OrdreElementsSerie { Ids = [.. ids] }, Json, ct), ct); public async Task> ReordonnerSousSeriesAsync( int serieId, IReadOnlyList ids, CancellationToken ct = default) => await EcrireAsync( () => http.PutAsJsonAsync( $"api/series/{serieId}/sous-series/ordre", new OrdreSeries { Ids = [.. ids] }, Json, ct), ct); /// Met un tome manquant dans ma liste d'envies — la série, elle, est commune. public async Task> SouhaiterTomeAsync( int elementId, CancellationToken ct = default) => await EcrireAsync( () => http.PostAsync($"api/series/elements/{elementId}/souhait", null, ct), ct); /// /// Retire une place de la série, ou la série entière. null si c'est fait. /// /// /// Comme les autres suppressions : réponse 204 No Content, donc pas de /// désérialisation, donc pas . /// public Task RetirerElementSerieAsync(int elementId, CancellationToken ct = default) => SupprimerAsync($"api/series/elements/{elementId}", ct); public Task 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> ListerRevuesAsync(CancellationToken ct = default) { var (ok, revues) = await EssayerAsync( () => http.GetFromJsonAsync>("api/revues", Json, ct)); if (ok) { await MemoriserAsync(ClesCache.Revues, revues ?? []); return revues ?? []; } var instantane = await LireCacheAsync>(ClesCache.Revues); return instantane?.Donnees ?? []; } /// /// Crée la revue, ou rend celle qui existe déjà — même ISSN, ou même titre. /// /// /// C'est ce que réclame le scan d'un code-barres 977 : 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 ». /// public async Task> CreerRevueAsync( EnregistrementRevue saisie, CancellationToken ct = default) => await EcrireAsync(() => http.PostAsJsonAsync("api/revues", saisie, Json, ct), ct); public async Task> ModifierRevueAsync( int id, EnregistrementRevue saisie, CancellationToken ct = default) => await EcrireAsync(() => http.PutAsJsonAsync($"api/revues/{id}", saisie, Json, ct), ct); public async Task> AjouterNumeroAsync( int revueId, AjoutNumeroRevue saisie, CancellationToken ct = default) => await EcrireAsync( () => http.PostAsJsonAsync($"api/revues/{revueId}/numeros", saisie, Json, ct), ct); /// /// Modifie un numéro déjà recensé, couverture et articles à la une compris. /// /// /// 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. /// public async Task> ModifierNumeroAsync( int numeroId, AjoutNumeroRevue saisie, CancellationToken ct = default) => await EcrireAsync( () => http.PutAsJsonAsync($"api/revues/numeros/{numeroId}", saisie, Json, ct), ct); public Task RetirerNumeroAsync(int numeroId, CancellationToken ct = default) => SupprimerAsync($"api/revues/numeros/{numeroId}", ct); public Task SupprimerRevueAsync(int id, CancellationToken ct = default) => SupprimerAsync($"api/revues/{id}", ct); /// Suppression générique : refus hors-ligne, 204 attendu, instantanés rafraîchis. private async Task 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); } /// Résultat d'un appel d'écriture : la valeur, ou un message d'erreur affichable. public readonly record struct ResultatApi(T? Valeur, string? Erreur) { public bool EstOk => Erreur is null; } /// /// Résultat d'un ajout au catalogue : le livre, une erreur, ou des doublons à confirmer. /// /// /// Trois issues et non deux : un doublon signalé n'est pas 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. /// public readonly record struct ResultatCreation(LivreDto? Livre, string? Erreur, DoublonsLivre? Doublons) { public bool EstOk => Livre is not null; }