From ebc5f95d4937041de70ef92b968d1a336714da1a Mon Sep 17 00:00:00 2001 From: mathieu Date: Tue, 18 Aug 2026 13:59:19 +0200 Subject: [PATCH] Consulter la bibliotheque hors-ligne depuis un cache IndexedDB Le piege que CLAUDE.md signale : en Blazor WebAssembly le code tourne dans le navigateur alors que SQLite vit sur le serveur, et le service worker ne met en cache que les assets. Sans travail explicite, l'application demarre hors-ligne et affiche une bibliotheque vide. - js/cache-hors-ligne.js : instantanes JSON dans IndexedDB, et surveillance des bascules online/offline. Aucune logique metier. - CacheHorsLigne / EtatReseau : lecture-ecriture des instantanes, et etat reseau combinant navigator.onLine (fiable seulement par sa negation) avec le sort reel des appels HTTP. - ServiceLivresApi : les lectures retombent sur le cache, les ecritures sont refusees. Le catalogue entier est memorise, pas les reponses filtrees : c'est ce qui rend la recherche hors-ligne possible sur tout le fonds. - FiltreLivresLocal : pendant navigateur de FiltreLivres, avec un test qui confronte les deux implementations sur les memes donnees. - Interface : pastille et bandeau d'etat avec la date de synchronisation, et actions d'ecriture desactivees avec leur raison plutot que boutons morts. - js/mise-a-jour.js : les empreintes WASM etant desactivees, le service worker est le seul cache-busting du projet. L'enregistrement journalise desormais ses echecs, et un bandeau propose la nouvelle version sans attendre la fermeture de tous les onglets. Co-Authored-By: Claude Opus 5 --- .../Composants/FormulaireLivre.razor | 19 +- MaBibli.Client/Composants/PretsLivre.razor | 63 +++ MaBibli.Client/Layout/MainLayout.razor | 105 ++++- MaBibli.Client/Layout/MainLayout.razor.css | 26 ++ MaBibli.Client/Pages/AjoutIsbn.razor | 32 +- MaBibli.Client/Pages/AjoutManuel.razor | 10 + MaBibli.Client/Pages/Auteurs.razor | 22 +- MaBibli.Client/Pages/Catalogue.razor | 31 +- MaBibli.Client/Pages/FicheLivre.razor | 64 ++- MaBibli.Client/Pages/Prets.razor | 23 +- MaBibli.Client/Program.cs | 5 + MaBibli.Client/Services/CacheHorsLigne.cs | 133 ++++++ MaBibli.Client/Services/EtatReseau.cs | 122 ++++++ MaBibli.Client/Services/FiltreLivresLocal.cs | 71 ++++ MaBibli.Client/Services/ServiceLivresApi.cs | 396 +++++++++++++++--- MaBibli.Client/wwwroot/css/app.css | 35 ++ MaBibli.Client/wwwroot/index.html | 5 +- MaBibli.Client/wwwroot/js/cache-hors-ligne.js | 86 ++++ MaBibli.Client/wwwroot/js/mise-a-jour.js | 89 ++++ .../wwwroot/service-worker.published.js | 10 + MaBibli.Tests/FiltreLivresLocalTests.cs | 155 +++++++ 21 files changed, 1420 insertions(+), 82 deletions(-) create mode 100644 MaBibli.Client/Services/CacheHorsLigne.cs create mode 100644 MaBibli.Client/Services/EtatReseau.cs create mode 100644 MaBibli.Client/Services/FiltreLivresLocal.cs create mode 100644 MaBibli.Client/wwwroot/js/cache-hors-ligne.js create mode 100644 MaBibli.Client/wwwroot/js/mise-a-jour.js create mode 100644 MaBibli.Tests/FiltreLivresLocalTests.cs diff --git a/MaBibli.Client/Composants/FormulaireLivre.razor b/MaBibli.Client/Composants/FormulaireLivre.razor index 3908117..f266f12 100644 --- a/MaBibli.Client/Composants/FormulaireLivre.razor +++ b/MaBibli.Client/Composants/FormulaireLivre.razor @@ -84,9 +84,17 @@ } + @* Un enregistrement impossible se dit AVANT le clic : un bouton qui échoue en silence est + pire que pas de bouton du tout (CLAUDE.md, stratégie hors-ligne). *@ + @if (!string.IsNullOrEmpty(MessageBlocage)) + { +

@MessageBlocage

+ } +
@@ -112,6 +120,15 @@ [Parameter] public bool EnCours { get; set; } + /// + /// Raison pour laquelle l'enregistrement est impossible (typiquement l'absence de réseau). + /// Renseignée, elle désactive la validation et s'affiche : la saisie reste lisible, mais + /// l'utilisateur sait pourquoi il ne peut pas valider. + /// + [Parameter] public string? MessageBlocage { get; set; } + + private bool Bloque => !string.IsNullOrEmpty(MessageBlocage); + [Parameter] public EventCallback OnValider { get; set; } [Parameter] public EventCallback OnAnnuler { get; set; } diff --git a/MaBibli.Client/Composants/PretsLivre.razor b/MaBibli.Client/Composants/PretsLivre.razor index e5dd8d8..c9e2dc5 100644 --- a/MaBibli.Client/Composants/PretsLivre.razor +++ b/MaBibli.Client/Composants/PretsLivre.razor @@ -1,4 +1,6 @@ @inject ServiceLivresApi Api +@inject EtatReseau Reseau +@implements IDisposable @* Prêts d'un livre : l'état courant, l'action qui s'impose, puis l'historique. @@ -17,6 +19,38 @@ Un livre numérique ne se prête pas : il n'y a pas d'exemplaire à confier.

} + else if (!Reseau.EnLigne) + { + @* + L'historique des prêts n'est pas mis en cache : une requête par livre pour une + information rarement consultée. En revanche l'état COURANT vient de la fiche, donc + de l'instantané du catalogue — c'est lui qui répond à « où est ce livre ? », la + seule question qui se pose vraiment sans réseau, devant l'étagère. + + Afficher une liste vide ferait croire à « jamais prêté » : on dit ce qu'on sait, et + on dit ce qu'on ignore. + *@ + @if (PreteA is { } absent) + { +

+ Prêté + à @absent + @if (PreteDepuis is { } depuis) + { + @($" depuis le {Jour(depuis)}") + } +

+ } + else + { +

Ce livre était à la maison à la dernière synchronisation.

+ } + +

+ Hors ligne : l'historique des prêts n'est pas consultable, et prêter ou marquer un + retour demande le réseau. +

+ } else if (_prets is null) {

Chargement…

@@ -115,6 +149,15 @@ /// Format du livre : un ebook ne se prête pas. [Parameter] public Format Format { get; set; } + /// + /// Emprunteur actuel d'après la fiche. Sert hors-ligne, où l'historique n'est pas + /// consultable mais où l'état courant, lui, vient de l'instantané du catalogue. + /// + [Parameter] public string? PreteA { get; set; } + + /// Date du prêt en cours, même origine que . + [Parameter] public DateTime? PreteDepuis { get; set; } + /// Prévient la page parente qu'un prêt a changé, pour qu'elle rafraîchisse sa fiche. [Parameter] public EventCallback OnChangement { get; set; } @@ -131,6 +174,21 @@ private IReadOnlyList Historique => _prets?.Where(p => !p.EstEnCours).ToList() ?? []; + protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + + /// Au retour du réseau, l'historique devient consultable : on va le chercher. + private void SurChangementReseau() => _ = InvokeAsync(async () => + { + if (Reseau.EnLigne) + { + await ChargerAsync(); + } + + StateHasChanged(); + }); + + public void Dispose() => Reseau.Change -= SurChangementReseau; + protected override async Task OnParametersSetAsync() { if (_livreCharge != LivreId) @@ -143,6 +201,11 @@ private async Task ChargerAsync() { + if (!Reseau.EnLigne) + { + return; // L'historique n'est pas en cache : rien à demander, l'affichage le dit. + } + try { _prets = await Api.ListerPretsLivreAsync(LivreId) ?? []; diff --git a/MaBibli.Client/Layout/MainLayout.razor b/MaBibli.Client/Layout/MainLayout.razor index d27de9c..f740d01 100644 --- a/MaBibli.Client/Layout/MainLayout.razor +++ b/MaBibli.Client/Layout/MainLayout.razor @@ -1,11 +1,20 @@ -@inherits LayoutComponentBase +@inherits LayoutComponentBase +@implements IDisposable @inject ServiceLivresApi Api +@inject EtatReseau Reseau @* Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche. Le PC hérite de la même colonne, simplement centrée et limitée en largeur. *@
MaBibli + @if (!Reseau.EnLigne) + { + @* Une pastille dans le bandeau, visible sur tous les écrans : l'état du réseau change + l'usage de l'application, il ne doit pas se découvrir au premier clic qui échoue. *@ + Hors ligne + } + @if (_utilisateur?.Identifiant is not null) { @@ -14,13 +23,105 @@ }
+@if (!Reseau.EnLigne) +{ + @* + Dire d'où viennent les données et de quand elles datent. Sans cette phrase, une + bibliothèque affichée hors-ligne est indiscernable d'une bibliothèque à jour — et un + livre ajouté depuis un autre appareil manquerait sans explication. + *@ +

+ Hors ligne. + @(Reseau.DerniereSynchro is { } synchro + ? $" Données enregistrées {Quand(synchro)}. " + : " Aucune donnée n'a encore pu être enregistrée sur cet appareil. ") + Consultation et recherche fonctionnent ; les modifications sont impossibles. +

+} +
@Body
@code { private UtilisateurCourant? _utilisateur; + private bool _etaitEnLigne = true; + private bool _synchroEnCours; protected override async Task OnInitializedAsync() - => _utilisateur = await Api.ObtenirUtilisateurAsync(); + { + Reseau.Change += SurChangementReseau; + Reseau.SynchroChange += SurSynchro; + + // Écoute des bascules online/offline avant tout appel : un démarrage hors-ligne doit + // aller directement au cache, sans attendre l'échec d'une requête. + await Reseau.DemarrerAsync(); + _etaitEnLigne = Reseau.EnLigne; + + _utilisateur = await Api.ObtenirUtilisateurAsync(); + + // Rafraîchit tout le fonds, pas seulement l'écran ouvert : c'est ce qui rend la + // bibliothèque entière consultable et cherchable après la coupure. + await SynchroniserAsync(); + } + + /// + /// Au retour du réseau, on recharge : la bibliothèque a pu changer depuis un autre appareil, + /// et les actions d'écriture redeviennent disponibles dans la foulée. + /// + private void SurChangementReseau() + { + var revenu = Reseau.EnLigne && !_etaitEnLigne; + _etaitEnLigne = Reseau.EnLigne; + + _ = InvokeAsync(async () => + { + StateHasChanged(); + + if (revenu) + { + _utilisateur = await Api.ObtenirUtilisateurAsync(); + await SynchroniserAsync(); + StateHasChanged(); + } + }); + } + + private async Task SynchroniserAsync() + { + if (_synchroEnCours) + { + return; + } + + _synchroEnCours = true; + + try + { + await Api.SynchroniserAsync(); + } + finally + { + _synchroEnCours = false; + } + } + + /// Date de synchronisation en clair : l'heure suffit le jour même. + private static string Quand(DateTimeOffset instant) + { + var local = instant.ToLocalTime(); + + return local.Date == DateTimeOffset.Now.Date + ? $"aujourd'hui à {local:HH:mm}" + : $"le {local:dd/MM/yyyy} à {local:HH:mm}"; + } + + /// La date affichée vient de changer : rien à recharger, juste à redessiner. + private void SurSynchro() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() + { + Reseau.Change -= SurChangementReseau; + Reseau.SynchroChange -= SurSynchro; + } } diff --git a/MaBibli.Client/Layout/MainLayout.razor.css b/MaBibli.Client/Layout/MainLayout.razor.css index 252cba0..be2e48e 100644 --- a/MaBibli.Client/Layout/MainLayout.razor.css +++ b/MaBibli.Client/Layout/MainLayout.razor.css @@ -27,6 +27,32 @@ max-width: 55%; } +/* L'état du réseau change ce que l'application permet : il se voit dans le bandeau, en + permanence, et pas seulement au moment où une action échoue. */ +.pastille-hors-ligne { + flex: 0 0 auto; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + padding: 0.15rem 0.5rem; + border-radius: 999px; + background: #f5d76e; + color: #4a3800; + white-space: nowrap; +} + +/* Dire d'où viennent les données affichées, et de quand elles datent : sans cette phrase, un + catalogue hors-ligne est indiscernable d'un catalogue à jour. */ +.bandeau-reseau { + max-width: 46rem; + margin: 0 auto; + padding: 0.6rem 1rem; + background: #fff8e1; + border-bottom: 1px solid #e6d28a; + color: #6b5200; + font-size: 0.9rem; +} + .contenu { display: block; width: 100%; diff --git a/MaBibli.Client/Pages/AjoutIsbn.razor b/MaBibli.Client/Pages/AjoutIsbn.razor index 114ae9f..bd85885 100644 --- a/MaBibli.Client/Pages/AjoutIsbn.razor +++ b/MaBibli.Client/Pages/AjoutIsbn.razor @@ -1,6 +1,8 @@ @page "/ajout/isbn" @inject ServiceLivresApi Api @inject NavigationManager Navigation +@inject EtatReseau Reseau +@implements IDisposable MaBibli — ajouter par ISBN @@ -12,6 +14,19 @@ Scannez le code-barres, ou saisissez l'ISBN imprimé sur le livre.

+ @* + Le scan comme le lookup interrogent la BnF puis OpenLibrary : ils EXIGENT le réseau, + et rien ne peut être ajouté au catalogue hors-ligne de toute façon. Le dire ici évite + une caméra ouverte pour rien, puis un délai d'attente incompréhensible. + *@ + @if (!Reseau.EnLigne) + { +

+ Hors ligne : la recherche par ISBN interroge la BnF et OpenLibrary, et l'ajout au + catalogue passe par le serveur. Les deux redeviendront possibles au retour du réseau. +

+ } +
@* La saisie manuelle reste le recours quand le code-barres est abîmé, absent, ou que la caméra est indisponible : elle ne disparaît jamais derrière le scan. *@ - Saisir à la main @@ -108,6 +126,7 @@ LibelleValidation="Ajouter au catalogue" Erreur="@_erreurFormulaire" EnCours="_enregistrement" + MessageBlocage="@MotifBlocage" OnValider="EnregistrerAsync" OnAnnuler="Recommencer" /> } @@ -125,6 +144,15 @@ private IReadOnlyList _avertissements = []; private EnregistrementLivre _saisie = new(); + protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + + private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() => Reseau.Change -= SurChangementReseau; + + /// Raison du blocage des actions, ou null quand tout est possible. + private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; + private void OuvrirScanner() { _erreur = null; diff --git a/MaBibli.Client/Pages/AjoutManuel.razor b/MaBibli.Client/Pages/AjoutManuel.razor index 7c70759..4cf27ef 100644 --- a/MaBibli.Client/Pages/AjoutManuel.razor +++ b/MaBibli.Client/Pages/AjoutManuel.razor @@ -1,6 +1,8 @@ @page "/ajout/manuel" @inject ServiceLivresApi Api @inject NavigationManager Navigation +@inject EtatReseau Reseau +@implements IDisposable MaBibli — saisie manuelle @@ -14,11 +16,19 @@ LibelleValidation="Ajouter au catalogue" Erreur="@_erreur" EnCours="_enregistrement" + MessageBlocage="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)" OnValider="EnregistrerAsync" OnAnnuler="Retour" /> @code { private readonly EnregistrementLivre _saisie = new(); + + protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + + private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() => Reseau.Change -= SurChangementReseau; + private bool _enregistrement; private string? _erreur; diff --git a/MaBibli.Client/Pages/Auteurs.razor b/MaBibli.Client/Pages/Auteurs.razor index 0760dc8..f50b4bd 100644 --- a/MaBibli.Client/Pages/Auteurs.razor +++ b/MaBibli.Client/Pages/Auteurs.razor @@ -1,5 +1,7 @@ @page "/auteurs" @inject ServiceLivresApi Api +@inject EtatReseau Reseau +@implements IDisposable MaBibli — auteurs @@ -37,11 +39,14 @@

- - @@ -85,6 +90,19 @@ else private bool _enCours; private string? _erreur; + protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + + private void SurChangementReseau() => _ = InvokeAsync(async () => + { + await ChargerAsync(); + StateHasChanged(); + }); + + public void Dispose() => Reseau.Change -= SurChangementReseau; + + /// Raison du blocage des fusions, ou null en ligne. + private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; + protected override Task OnInitializedAsync() => ChargerAsync(); private static string Livres(int nombre) => $"{nombre} livre{(nombre > 1 ? "s" : "")}"; diff --git a/MaBibli.Client/Pages/Catalogue.razor b/MaBibli.Client/Pages/Catalogue.razor index 945471f..9265210 100644 --- a/MaBibli.Client/Pages/Catalogue.razor +++ b/MaBibli.Client/Pages/Catalogue.razor @@ -1,5 +1,6 @@ @page "/" @inject ServiceLivresApi Api +@inject EtatReseau Reseau @implements IDisposable MaBibli — catalogue @@ -121,9 +122,22 @@ else if (_livres is not null) } +@* Hors-ligne, les deux entrées d'ajout deviennent des boutons éteints plutôt que des liens qui + mèneraient à un formulaire invalidable. La consultation, elle, reste entière : recherche, + filtres et tri portent sur toute la bibliothèque, depuis l'instantané local. *@ @@ -149,6 +163,18 @@ else if (_livres is not null) !string.IsNullOrWhiteSpace(_recherche) || _format is not null || _statut is not null || AuteurId is not null; + /// + /// La liste se relit à chaque bascule du réseau : au retour, pour reprendre les données du + /// serveur ; à la coupure, pour basculer sur l'instantané local sans laisser un écran vide. + /// + protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + + private void SurChangementReseau() => _ = InvokeAsync(async () => + { + await ChargerAsync(); + StateHasChanged(); + }); + protected override async Task OnParametersSetAsync() { if (_auteurCharge != AuteurId) @@ -219,6 +245,7 @@ else if (_livres is not null) public void Dispose() { + Reseau.Change -= SurChangementReseau; _cts.Cancel(); _cts.Dispose(); } diff --git a/MaBibli.Client/Pages/FicheLivre.razor b/MaBibli.Client/Pages/FicheLivre.razor index 022adf9..373c471 100644 --- a/MaBibli.Client/Pages/FicheLivre.razor +++ b/MaBibli.Client/Pages/FicheLivre.razor @@ -2,6 +2,7 @@ @page "/livres/{Id:int}/edition" @inject ServiceLivresApi Api @inject NavigationManager Navigation +@inject EtatReseau Reseau @implements IDisposable @* @@ -24,7 +25,13 @@ @if (_introuvable) { - + @* Hors-ligne, « introuvable » ne veut pas dire « supprimé » : le livre peut simplement avoir + été ajouté après la dernière synchronisation. Le dire évite un contresens inquiétant. *@ + Retour au catalogue } else if (_livre is null || _saisie is null) @@ -37,6 +44,7 @@ else if (ModeEdition) LibelleValidation="Enregistrer les modifications" Erreur="@_erreur" EnCours="_enregistrement" + MessageBlocage="@MotifBlocage" OnValider="EnregistrerAsync" OnAnnuler="RetourFiche" /> @@ -44,7 +52,9 @@ else if (ModeEdition)
@if (!_confirmationSuppression) { - } @@ -126,10 +136,16 @@ else

Statut de lecture (le vôtre)

+ Desactive="@(_statutEnCours || !Reseau.EnLigne)" /> + @if (!Reseau.EnLigne) + { +

@EtatReseau.MotifHorsLigne

+ }
- +

Ajouté le @_livre.DateAjout.ToLocalTime().ToString("dd/MM/yyyy") @@ -141,7 +157,15 @@ else

} @@ -173,12 +197,38 @@ else /// (constaté : l'URL devenait « /edition » sans que l'écran bascule). On s'abonne donc au /// changement d'adresse pour redessiner nous-mêmes. /// - protected override void OnInitialized() => Navigation.LocationChanged += SurNavigation; + protected override void OnInitialized() + { + Navigation.LocationChanged += SurNavigation; + Reseau.Change += SurChangementReseau; + } private void SurNavigation(object? emetteur, LocationChangedEventArgs e) => InvokeAsync(StateHasChanged); - public void Dispose() => Navigation.LocationChanged -= SurNavigation; + /// Le réseau a basculé : la fiche se relit (serveur ou cache) et les actions suivent. + private void SurChangementReseau() => _ = InvokeAsync(async () => + { + _livre = await Api.ObtenirAsync(Id) ?? _livre; + + // On ne réécrit PAS le formulaire en cours d'édition : une coupure réseau au milieu + // d'une saisie effacerait ce que l'utilisateur vient de taper. + if (_livre is not null && !ModeEdition) + { + _saisie = Recopier(_livre); + } + + StateHasChanged(); + }); + + /// Raison affichable quand la fiche ne peut pas être modifiée, sinon null. + private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; + + public void Dispose() + { + Navigation.LocationChanged -= SurNavigation; + Reseau.Change -= SurChangementReseau; + } protected override async Task OnParametersSetAsync() { diff --git a/MaBibli.Client/Pages/Prets.razor b/MaBibli.Client/Pages/Prets.razor index bed7565..7f56552 100644 --- a/MaBibli.Client/Pages/Prets.razor +++ b/MaBibli.Client/Pages/Prets.razor @@ -1,5 +1,7 @@ @page "/prets" @inject ServiceLivresApi Api +@inject EtatReseau Reseau +@implements IDisposable @* Ce qui n'est pas à la maison. C'est l'usage principal des prêts : savoir d'un coup d'œil @@ -24,7 +26,13 @@ } else if (_prets.Count == 0) { -

Tous les livres sont à la maison.

+ @* Hors-ligne, une liste vide peut aussi vouloir dire « rien n'a encore été enregistré ici » : + on ne l'affirme comme un fait que si l'information vient bien d'une synchronisation. *@ +

+ @(Reseau.EnLigne || Reseau.DerniereSynchro is not null + ? "Tous les livres sont à la maison." + : "Aucune donnée enregistrée sur cet appareil : impossible de dire ce qui est prêté.") +

} else { @@ -54,7 +62,8 @@ else @* Le retour se note ici : on récupère un livre, on ouvre la liste des prêts, on coche. Passer par la fiche du livre serait un détour. *@ @@ -73,6 +82,16 @@ else private string? _erreur; private int? _enCours; + protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + + private void SurChangementReseau() => _ = InvokeAsync(async () => + { + await ChargerAsync(); + StateHasChanged(); + }); + + public void Dispose() => Reseau.Change -= SurChangementReseau; + protected override Task OnInitializedAsync() => ChargerAsync(); private async Task ChargerAsync() diff --git a/MaBibli.Client/Program.cs b/MaBibli.Client/Program.cs index db6261c..ade4846 100644 --- a/MaBibli.Client/Program.cs +++ b/MaBibli.Client/Program.cs @@ -7,6 +7,11 @@ builder.RootComponents.Add("#app"); builder.RootComponents.Add("head::after"); builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + +// Cache de consultation hors-ligne : un seul jeu d'instantanés pour toute l'application, d'où +// des services partagés (Scoped vaut Singleton dans un client WebAssembly). +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); await builder.Build().RunAsync(); diff --git a/MaBibli.Client/Services/CacheHorsLigne.cs b/MaBibli.Client/Services/CacheHorsLigne.cs new file mode 100644 index 0000000..572755c --- /dev/null +++ b/MaBibli.Client/Services/CacheHorsLigne.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using Microsoft.JSInterop; + +namespace MaBibli.Client.Services; + +/// Clés des instantanés rangés dans IndexedDB. +/// +/// Un instantané par vue de l'API, et non par requête : c'est ce qui permet de chercher et de +/// trier hors-ligne sur tout le fonds, y compris des recherches jamais tapées en ligne. +/// +public static class ClesCache +{ + /// Le catalogue entier, sans aucun filtre. La recherche hors-ligne s'y applique. + public const string Catalogue = "catalogue"; + + public const string Auteurs = "auteurs"; + + public const string PretsEnCours = "prets-en-cours"; + + public const string Utilisateur = "utilisateur"; +} + +/// Un instantané relu du cache, avec la date de la synchronisation qui l'a produit. +public sealed record Instantane(T Donnees, DateTimeOffset Date); + +/// +/// Cache de consultation hors-ligne, en lecture seule (CLAUDE.md). +/// +/// +/// Rien n'est mis en file d'attente et rien n'est synchronisé en retour : ce cache ne sert qu'à +/// afficher la bibliothèque déjà enregistrée quand le réseau manque. Les écritures sont refusées +/// en amont, par . +/// +/// Toute panne du stockage (IndexedDB indisponible, quota, navigation privée) est avalée : ne pas +/// pouvoir cacher n'est pas une raison d'empêcher l'application de fonctionner en ligne. +/// +/// +public sealed class CacheHorsLigne(IJSRuntime js) : IAsyncDisposable +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + private IJSObjectReference? _module; + + /// Vrai tant que le stockage n'a pas montré qu'il était inutilisable. + public bool Disponible { get; private set; } = true; + + private async ValueTask ModuleAsync() => + _module ??= await js.InvokeAsync("import", "./js/cache-hors-ligne.js"); + + public async Task?> LireAsync(string cle) + { + try + { + var module = await ModuleAsync(); + var brut = await module.InvokeAsync("lire", cle); + + if (brut?.Json is null) + { + return null; + } + + var donnees = JsonSerializer.Deserialize(brut.Json, Json); + return donnees is null ? null : new Instantane(donnees, brut.Date); + } + catch (Exception) + { + // Cache illisible : on se comporte comme s'il était vide. + Disponible = false; + return null; + } + } + + public async Task EcrireAsync(string cle, T valeur, DateTimeOffset date) + { + try + { + var module = await ModuleAsync(); + await module.InvokeVoidAsync( + "ecrire", cle, JsonSerializer.Serialize(valeur, Json), date.ToString("O")); + } + catch (Exception) + { + Disponible = false; + } + } + + /// État déclaré par le navigateur. Voir la nuance dans cache-hors-ligne.js. + public async Task EnLigneAsync() + { + try + { + var module = await ModuleAsync(); + return await module.InvokeAsync("enLigne"); + } + catch (Exception) + { + return true; // Sans information, on suppose le réseau : un échec d'appel corrigera. + } + } + + /// Abonne aux événements online/offline. + public async Task SurveillerAsync(DotNetObjectReference destinataire) where T : class + { + try + { + var module = await ModuleAsync(); + return await module.InvokeAsync("surveiller", destinataire); + } + catch (Exception) + { + return true; + } + } + + public async ValueTask DisposeAsync() + { + if (_module is null) + { + return; + } + + try + { + await _module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + // Page en cours de fermeture : il n'y a plus personne à qui parler. + } + } + + private sealed record EntreeCache(string? Json, DateTimeOffset Date); +} diff --git a/MaBibli.Client/Services/EtatReseau.cs b/MaBibli.Client/Services/EtatReseau.cs new file mode 100644 index 0000000..c58379d --- /dev/null +++ b/MaBibli.Client/Services/EtatReseau.cs @@ -0,0 +1,122 @@ +using Microsoft.JSInterop; + +namespace MaBibli.Client.Services; + +/// +/// État du réseau, tel que l'interface doit le montrer, et date du dernier rafraîchissement +/// des données mises en cache. +/// +/// +/// Deux sources, pas une. navigator.onLine ne vaut que par sa négation : « faux » +/// est fiable, « vrai » ne prouve rien (portail captif, serveur arrêté, Wi-Fi sans Internet). +/// L'état affiché combine donc l'événement du navigateur et le sort réel des appels HTTP — +/// un appel qui échoue bascule l'application en « hors ligne » sans attendre l'accord du +/// navigateur, et le premier appel qui repasse la rebascule en ligne. +/// +/// Ce service est le seul point d'observation de l'interface : la mise en page s'y abonne pour +/// afficher le bandeau et la date de synchronisation, les écrans pour désactiver les actions +/// d'écriture. +/// +/// +public sealed class EtatReseau(CacheHorsLigne cache) : IAsyncDisposable +{ + private DotNetObjectReference? _reference; + + private bool _navigateurEnLigne = true; + private bool _dernierAppelEchoue; + + /// Faux dès qu'un appel a échoué ou que le navigateur signale la perte du réseau. + public bool EnLigne => _navigateurEnLigne && !_dernierAppelEchoue; + + /// + /// Faut-il tenter un appel réseau ? Oui dès que le navigateur a une connexion, même + /// si le dernier appel a échoué. + /// + /// + /// C'est ce qui permet de sortir tout seul du mode hors-ligne quand la panne venait du + /// serveur et non de l'appareil : navigator.onLine n'a alors jamais changé, donc + /// aucun événement online ne viendra jamais. Sans cette distinction, l'application + /// resterait bloquée sur le cache jusqu'au rechargement de la page — constaté en essai. + /// Le prix est d'une requête qui échoue par lecture tant que le serveur est absent : elle + /// échoue vite, et l'affichage retombe sur le cache comme avant. + /// + public bool TenterLeReseau => _navigateurEnLigne; + + /// + /// Instant du dernier rafraîchissement réussi des données en cache, null si l'on n'a + /// jamais rien pu enregistrer sur cet appareil. + /// + public DateTimeOffset? DerniereSynchro { get; private set; } + + /// Vrai quand ce qui est affiché vient du cache et non du réseau. + public bool DonneesDuCache => !EnLigne && DerniereSynchro is not null; + + /// Raison affichable du blocage des actions d'écriture. + public const string MotifHorsLigne = + "Hors ligne : la bibliothèque reste consultable, mais rien ne peut être modifié."; + + /// + /// Bascule en ligne ↔ hors ligne, et elle seule. + /// + /// + /// La date de synchronisation a son propre événement () : sans + /// cette séparation, un écran qui rechargerait ses données sur Change déclencherait + /// l'écriture d'un instantané, donc un nouvel événement, donc un rechargement — une boucle + /// sans fin. + /// + public event Action? Change; + + /// Nouvelle date de synchronisation : de quoi rafraîchir un affichage, rien de plus. + public event Action? SynchroChange; + + /// À appeler une fois au démarrage : lit l'état courant et s'abonne aux bascules. + public async Task DemarrerAsync() + { + _reference ??= DotNetObjectReference.Create(this); + var enLigne = await cache.SurveillerAsync(_reference); + Definir(enLigne, false); + } + + [JSInvokable] + public void SurChangementReseau(bool enLigne) => Definir(enLigne, _dernierAppelEchoue && !enLigne); + + /// Un appel HTTP a échoué : on est hors ligne, quoi qu'en dise le navigateur. + public void SignalerEchecReseau() => Definir(_navigateurEnLigne, true); + + /// Un appel HTTP a abouti : la preuve la plus solide qu'on soit en ligne. + public void SignalerSuccesReseau() => Definir(true, false); + + /// Enregistre qu'un instantané vient d'être rangé, avec sa date. + public void SignalerSynchro(DateTimeOffset date) + { + if (DerniereSynchro is { } precedente && precedente >= date) + { + return; + } + + DerniereSynchro = date; + SynchroChange?.Invoke(); + } + + private void Definir(bool navigateurEnLigne, bool dernierAppelEchoue) + { + var avant = EnLigne; + + _navigateurEnLigne = navigateurEnLigne; + _dernierAppelEchoue = dernierAppelEchoue; + + // L'événement ne parle que de l'état VISIBLE : les écrans s'y abonnent pour se recharger, + // et le déclencher pour un changement interne les ferait boucler. + if (avant != EnLigne) + { + Change?.Invoke(); + } + } + + public ValueTask DisposeAsync() + { + _reference?.Dispose(); + _reference = null; + return ValueTask.CompletedTask; + } +} diff --git a/MaBibli.Client/Services/FiltreLivresLocal.cs b/MaBibli.Client/Services/FiltreLivresLocal.cs new file mode 100644 index 0000000..7598575 --- /dev/null +++ b/MaBibli.Client/Services/FiltreLivresLocal.cs @@ -0,0 +1,71 @@ +using MaBibli.Shared.Catalogue; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Textes; + +namespace MaBibli.Client.Services; + +/// +/// Recherche, filtrage et tri du catalogue hors-ligne, appliqués à l'instantané complet +/// rangé en IndexedDB. +/// +/// +/// C'est le pendant navigateur de , qui s'exécute côté base. Les deux +/// ne peuvent pas être le même code : le serveur travaille sur des entités aux colonnes +/// normalisées déjà calculées, le client sur des qui n'en portent pas. +/// La normalisation est donc refaite ici à la volée — sans coût perceptible sur une bibliothèque +/// de foyer, et surtout par les mêmes fonctions (, +/// ), ce qui est la seule garantie que « emile » trouve +/// « Émile » des deux côtés. +/// +/// Un test compare les deux implémentations sur les mêmes données : une divergence silencieuse +/// donnerait une bibliothèque qui ne se cherche pas pareil selon qu'on a du réseau ou non. +/// +/// +/// Aucun filtre sur AjoutePar, ici comme côté serveur : la bibliothèque est commune. +/// +/// +public static class FiltreLivresLocal +{ + public static IReadOnlyList Appliquer( + IEnumerable source, CritereLivres criteres) + { + var livres = source; + + if (criteres.Format is { } format) + { + livres = livres.Where(l => l.Format == format); + } + + if (criteres.Statut is { } statut) + { + // LivreDto.Statut est déjà celui de l'utilisateur courant : le serveur l'a résolu + // avant de l'envoyer. Le client n'a donc personne à désigner, et ne le pourrait pas. + livres = livres.Where(l => l.Statut == statut); + } + + if (criteres.AuteurId is { } auteurId) + { + livres = livres.Where(l => l.Auteurs.Any(a => a.Id == auteurId)); + } + + var terme = NormalisationTexte.Normaliser(criteres.Recherche); + if (terme.Length > 0) + { + var cle = RapprochementAuteurs.Cle(criteres.Recherche); + + livres = livres.Where(l => + NormalisationTexte.Normaliser(l.Titre).Contains(terme, StringComparison.Ordinal) + || l.Auteurs.Any(a => + NormalisationTexte.Normaliser(a.Nom).Contains(terme, StringComparison.Ordinal) + || RapprochementAuteurs.Cle(a.Nom).Contains(cle, StringComparison.Ordinal))); + } + + // Même ordre que le serveur : titre normalisé, puis identifiant pour rendre le tri + // déterministe. La comparaison est ordinale parce que c'est ce que fait SQLite sur une + // colonne sans collation — un tri « intelligent » ici ferait diverger les deux vues. + return livres + .OrderBy(l => NormalisationTexte.Normaliser(l.Titre), StringComparer.Ordinal) + .ThenBy(l => l.Id) + .ToList(); + } +} diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs index 4f468f8..0bdfdf6 100644 --- a/MaBibli.Client/Services/ServiceLivresApi.cs +++ b/MaBibli.Client/Services/ServiceLivresApi.cs @@ -8,15 +8,59 @@ using MaBibli.Shared.Entites; namespace MaBibli.Client.Services; /// -/// Accès à l'API depuis le client Blazor. +/// Accès à l'API depuis le client Blazor, avec repli sur le cache hors-ligne. /// /// -/// Aucun cache : la phase 3 suppose le réseau disponible. Le cache hors-ligne est la phase 6. +/// 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) +public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, EtatReseau reseau) { private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + /// + /// 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 ObtenirUtilisateurAsync(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(); @@ -43,56 +87,112 @@ public sealed class ServiceLivresApi(HttpClient http) var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty); - return await http.GetFromJsonAsync>(url, Json, ct) ?? []; + 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 reponse = await http.GetAsync($"api/livres/{id}", ct); - return reponse.StatusCode == HttpStatusCode.NotFound - ? null - : await LireAsync(reponse, ct); + 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); } - public async Task> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default) - { - var reponse = await http.PostAsJsonAsync("api/livres", saisie, Json, ct); - return await InterpreterAsync(reponse, ct); - } + public async Task> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default) => + await EcrireAsync(() => http.PostAsJsonAsync("api/livres", saisie, Json, ct), ct); - public async Task> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default) - { - var reponse = await http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct); - return await InterpreterAsync(reponse, ct); - } + 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) - { - var reponse = await http.PatchAsJsonAsync( - $"api/livres/{id}/statut", new ChangementStatut { Statut = statut }, Json, ct); - return await InterpreterAsync(reponse, ct); - } + 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) => - await http.GetFromJsonAsync>("api/auteurs", Json, 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 reponse = await http.GetAsync($"api/auteurs/{id}", ct); - return reponse.StatusCode == HttpStatusCode.NotFound - ? null - : await LireAsync(reponse, ct); + 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); } - /// Rapprochements d'auteurs ambigus, que le serveur refuse d'appliquer seul. + /// + /// 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) => - await http.GetFromJsonAsync>("api/auteurs/rapprochements", Json, ct) ?? []; + 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); @@ -102,29 +202,65 @@ public sealed class ServiceLivresApi(HttpClient http) private async Task DeciderAsync(string action, int sourceId, int cibleId, CancellationToken ct) { - var reponse = await http.PostAsJsonAsync( - $"api/auteurs/rapprochements/{action}", - new DecisionRapprochement { SourceId = sourceId, CibleId = cibleId }, - Json, - ct); + if (!reseau.EnLigne) + { + return EtatReseau.MotifHorsLigne; + } - return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct); + 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) => - await http.GetFromJsonAsync>("api/prets/en-cours", Json, ct) ?? []; + 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. + /// + /// 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 reponse = await http.GetAsync($"api/livres/{livreId}/prets", ct); - return reponse.StatusCode == HttpStatusCode.NotFound - ? null - : await LireAsync>(reponse, ct); + 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; } /// @@ -140,18 +276,17 @@ public sealed class ServiceLivresApi(HttpClient http) DatePret = EnUtc(datePret), }; - var reponse = await http.PostAsJsonAsync($"api/livres/{livreId}/prets", saisie, Json, ct); - return await InterpreterAsync(reponse, ct); + 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) - { - var reponse = await http.PostAsJsonAsync( - $"api/prets/{pretId}/retour", new RetourPret { DateRetour = EnUtc(dateRetour) }, Json, ct); - return await InterpreterAsync(reponse, ct); - } + 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. @@ -166,29 +301,164 @@ public sealed class ServiceLivresApi(HttpClient http) public async Task SupprimerAsync(int id, CancellationToken ct = default) { - var reponse = await http.DeleteAsync($"api/livres/{id}", ct); - return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct); + 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 reponse = await http.GetAsync($"api/isbn/{Uri.EscapeDataString(isbn)}", ct); - return reponse.IsSuccessStatusCode - ? await LireAsync(reponse, ct) - : null; + 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; + } + + /// + /// 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 { - return await http.GetFromJsonAsync("api/moi", Json, ct) - ?? UtilisateurCourant.Anonyme; + var valeur = await appel(); + reseau.SignalerSuccesReseau(); + return (true, valeur); } catch (HttpRequestException) { - return UtilisateurCourant.Anonyme; + 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) + { + if (!reseau.EnLigne) + { + return new ResultatApi(default, EtatReseau.MotifHorsLigne); + } + + try + { + var reponse = await appel(); + reseau.SignalerSuccesReseau(); + 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; + } + catch (HttpRequestException) + { + reseau.SignalerEchecReseau(); + return new ResultatApi(default, EtatReseau.MotifHorsLigne); + } + } + + /// Remet l'instantané à jour sans faire attendre l'utilisateur. + private async Task RafraichirEnFondAsync() + { + try + { + await ListerAsync(new CritereLivres()); + await ListerPretsEnCoursAsync(); + await ListerAuteursAsync(); + } + 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) diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index eb474d4..6daae12 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -738,3 +738,38 @@ body { margin: 0; font-size: 0.95rem; } + +/* --- Mise à jour de l'application --- */ + +/* Injecté par js/mise-a-jour.js, hors composants Blazor : ce bandeau doit pouvoir s'afficher + même si l'application WebAssembly n'a pas démarré. D'où une règle globale et non scopée. + Il existe parce que les empreintes des assets WASM sont désactivées (CLAUDE.md) : le service + worker est le seul cache-busting du projet, et sa mise à jour attendrait sinon la fermeture + de tous les onglets. */ +#mb-maj { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 1100; + display: flex; + gap: 0.75rem; + align-items: center; + justify-content: center; + flex-wrap: wrap; + padding: 0.7rem 1rem; + background: #1b3a5c; + color: #fff; + font-size: 0.9rem; +} + +#mb-maj button { + min-height: 2.5rem; + padding: 0.4rem 0.9rem; + border: 0; + border-radius: 6px; + background: #f5d76e; + color: #4a3800; + font-weight: 600; + cursor: pointer; +} diff --git a/MaBibli.Client/wwwroot/index.html b/MaBibli.Client/wwwroot/index.html index 34ecb87..10e1795 100644 --- a/MaBibli.Client/wwwroot/index.html +++ b/MaBibli.Client/wwwroot/index.html @@ -30,7 +30,10 @@ 🗙
- + + diff --git a/MaBibli.Client/wwwroot/js/cache-hors-ligne.js b/MaBibli.Client/wwwroot/js/cache-hors-ligne.js new file mode 100644 index 0000000..d062bae --- /dev/null +++ b/MaBibli.Client/wwwroot/js/cache-hors-ligne.js @@ -0,0 +1,86 @@ +// Cache de consultation hors-ligne (CLAUDE.md — « Stratégie hors-ligne »). +// +// Rôle unique de ce fichier : ranger et relire des instantanés JSON dans IndexedDB, et dire à +// C# quand le navigateur bascule en ligne / hors ligne. AUCUNE logique métier ici. +// +// Pourquoi IndexedDB et pas le cache du service worker : c'est la seule option qui permette de +// rechercher et trier hors-ligne sur TOUTE la bibliothèque. Un cache de réponses HTTP ne +// restituerait que les URL déjà visitées — une recherche jamais tapée ne rendrait rien. +// +// Les valeurs sont stockées telles quelles, en chaîne JSON : c'est System.Text.Json côté C# qui +// sérialise et désérialise, donc une seule forme fait autorité et le clone structuré d'IndexedDB +// n'a rien à interpréter. + +const NOM_BASE = 'mabibli'; +const MAGASIN = 'instantanes'; +const VERSION = 1; + +let promesseBase = null; + +function ouvrir() { + if (promesseBase) return promesseBase; + + promesseBase = new Promise((resoudre, rejeter) => { + // IndexedDB peut être absent ou refusé (navigation privée stricte, stockage bloqué). + // On rejette proprement : l'appelant C# retombe alors sur « pas de cache ». + if (!self.indexedDB) { rejeter(new Error('IndexedDB indisponible')); return; } + + const requete = indexedDB.open(NOM_BASE, VERSION); + requete.onupgradeneeded = () => { + const base = requete.result; + if (!base.objectStoreNames.contains(MAGASIN)) base.createObjectStore(MAGASIN); + }; + requete.onsuccess = () => resoudre(requete.result); + requete.onerror = () => rejeter(requete.error); + requete.onblocked = () => rejeter(new Error('IndexedDB bloqué')); + }).catch(e => { promesseBase = null; throw e; }); + + return promesseBase; +} + +function attendre(requete, transaction) { + return new Promise((resoudre, rejeter) => { + requete.onsuccess = () => resoudre(requete.result); + requete.onerror = () => rejeter(requete.error); + if (transaction) transaction.onabort = () => rejeter(transaction.error); + }); +} + +// Écrit un instantané. `dateIso` est l'instant de la synchronisation, pas celui de l'écriture : +// c'est cette date que l'interface affiche pour dire de quand datent les données montrées. +export async function ecrire(cle, json, dateIso) { + const base = await ouvrir(); + const tx = base.transaction(MAGASIN, 'readwrite'); + const requete = tx.objectStore(MAGASIN).put({ json, date: dateIso }, cle); + await attendre(requete, tx); + return true; +} + +// Renvoie { json, date } ou null si rien n'a jamais été rangé sous cette clé. +export async function lire(cle) { + const base = await ouvrir(); + const tx = base.transaction(MAGASIN, 'readonly'); + const valeur = await attendre(tx.objectStore(MAGASIN).get(cle), tx); + return valeur ?? null; +} + +export async function vider() { + const base = await ouvrir(); + const tx = base.transaction(MAGASIN, 'readwrite'); + await attendre(tx.objectStore(MAGASIN).clear(), tx); + return true; +} + +// navigator.onLine ne vaut que par sa négation : « false » est fiable (aucune interface réseau), +// « true » ne prouve rien (portail captif, serveur éteint). C# complète donc cet état avec le +// résultat réel de ses appels HTTP — voir EtatReseau.SignalerEchecReseau. +export function enLigne() { + return navigator.onLine !== false; +} + +export function surveiller(reference) { + const prevenir = () => reference.invokeMethodAsync('SurChangementReseau', navigator.onLine !== false); + self.addEventListener('online', prevenir); + self.addEventListener('offline', prevenir); + return navigator.onLine !== false; +} diff --git a/MaBibli.Client/wwwroot/js/mise-a-jour.js b/MaBibli.Client/wwwroot/js/mise-a-jour.js new file mode 100644 index 0000000..0c445e8 --- /dev/null +++ b/MaBibli.Client/wwwroot/js/mise-a-jour.js @@ -0,0 +1,89 @@ +// Enregistrement du service worker et bandeau de mise à jour. +// +// Pourquoi ce fichier existe (et pourquoi il n'est pas qu'une ligne `register(...)`) : +// +// 1. Les empreintes des assets WASM sont DÉSACTIVÉES (voir CLAUDE.md). `blazor.webassembly.js` +// et `dotnet.js` portent donc des noms stables, et c'est le service worker — lui seul — qui +// empêche de servir éternellement une version périmée. Le mécanisme de Blazor fonctionne, +// mais il est silencieux et différé : le nouveau worker attend que TOUS les onglets de +// l'application soient fermés. Sur mobile, un onglet oublié fige la mise à jour sans que +// personne comprenne pourquoi. D'où un bandeau explicite, avec un bouton qui l'applique. +// +// 2. `navigator.serviceWorker` n'existe pas en contexte non sécurisé (http sur une IP locale). +// L'appeler sans vérification lève une TypeError qui casse le script — et fait croire à un +// défaut de la PWA alors que c'est le contexte qui n'est pas éligible. + +(function () { + 'use strict'; + + if (!('serviceWorker' in navigator)) { + // Cas normal en http sur une IP de réseau local : rien à signaler à l'utilisateur, + // l'application fonctionne, elle n'est simplement pas installable ni hors-ligne. + console.info('Service worker indisponible (contexte non sécurisé ou navigateur sans support).'); + return; + } + + // Le rechargement n'a lieu que si l'utilisateur a cliqué : un changement de contrôleur + // survient aussi à la toute première installation, et recharger la page à ce moment-là + // serait un clignotement inexplicable. + let demandee = false; + + function proposerLaMiseAJour(enAttente) { + if (document.getElementById('mb-maj')) return; + + const barre = document.createElement('div'); + barre.id = 'mb-maj'; + barre.setAttribute('role', 'status'); + barre.textContent = 'Une nouvelle version de MaBibli est disponible. '; + + const bouton = document.createElement('button'); + bouton.type = 'button'; + bouton.textContent = 'Mettre à jour'; + bouton.addEventListener('click', function () { + bouton.disabled = true; + demandee = true; + // Le worker en attente prend la main sans qu'on ait à fermer tous les onglets. + enAttente.postMessage({ type: 'SKIP_WAITING' }); + }); + + barre.appendChild(bouton); + document.body.appendChild(barre); + } + + navigator.serviceWorker.register('service-worker.js', { updateViaCache: 'none' }) + .then(function (enregistrement) { + // Un worker déjà installé attendait peut-être depuis la visite précédente. + if (enregistrement.waiting && navigator.serviceWorker.controller) { + proposerLaMiseAJour(enregistrement.waiting); + } + + enregistrement.addEventListener('updatefound', function () { + const nouveau = enregistrement.installing; + if (!nouveau) return; + + nouveau.addEventListener('statechange', function () { + // `controller` non nul = ce n'est pas la première installation, donc il y a + // bien une version précédente à remplacer. + if (nouveau.state === 'installed' && navigator.serviceWorker.controller) { + proposerLaMiseAJour(nouveau); + } + }); + }); + + // Vérification explicite à chaque chargement : ne pas dépendre du seul rythme + // interne du navigateur pour découvrir une version plus récente. + enregistrement.update().catch(function () { /* hors-ligne : sans objet */ }); + }) + .catch(function (erreur) { + // Ne jamais laisser cet échec passer inaperçu : sans service worker, l'application + // ne démarre pas hors-ligne, et le cache IndexedDB ne sert alors à rien. + console.error("Échec de l'enregistrement du service worker :", erreur); + }); + + let recharge = false; + navigator.serviceWorker.addEventListener('controllerchange', function () { + if (!demandee || recharge) return; + recharge = true; + window.location.reload(); + }); +})(); diff --git a/MaBibli.Client/wwwroot/service-worker.published.js b/MaBibli.Client/wwwroot/service-worker.published.js index 51a0e5c..ae8705a 100644 --- a/MaBibli.Client/wwwroot/service-worker.published.js +++ b/MaBibli.Client/wwwroot/service-worker.published.js @@ -6,6 +6,16 @@ self.addEventListener('install', event => event.waitUntil(onInstall(event))); self.addEventListener('activate', event => event.waitUntil(onActivate(event))); self.addEventListener('fetch', event => event.respondWith(onFetch(event))); +// Mise à jour à la demande. Sans cela, un nouveau worker attend que TOUS les onglets de +// l'application soient fermés — un onglet oublié fige indéfiniment l'utilisateur sur l'ancienne +// version. C'est d'autant plus important ici que les empreintes des assets WASM sont désactivées +// (voir CLAUDE.md) : ce worker est le SEUL mécanisme de cache-busting du projet. +// Le message ne vient que de js/mise-a-jour.js, après un clic explicite : jamais tout seul, pour +// ne pas mélanger deux versions au milieu d'une session. +self.addEventListener('message', event => { + if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting(); +}); + const cacheNamePrefix = 'offline-cache-'; const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/, /\.webmanifest$/ ]; diff --git a/MaBibli.Tests/FiltreLivresLocalTests.cs b/MaBibli.Tests/FiltreLivresLocalTests.cs new file mode 100644 index 0000000..e271dcb --- /dev/null +++ b/MaBibli.Tests/FiltreLivresLocalTests.cs @@ -0,0 +1,155 @@ +using MaBibli.Client.Services; +using MaBibli.Shared.Catalogue; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; + +namespace MaBibli.Tests; + +/// +/// Recherche hors-ligne : le filtre appliqué dans le navigateur à l'instantané IndexedDB. +/// +/// +/// L'essentiel de ces tests confronte à +/// , celui qui s'exécute côté base. Ce sont deux implémentations +/// distinctes — l'une sur des entités aux colonnes normalisées, l'autre sur des DTO qui n'en ont +/// pas — et rien n'empêcherait leurs comportements de diverger en silence. Une bibliothèque qui +/// ne se cherche pas de la même façon selon qu'on a du réseau ou non serait pire qu'un cache +/// absent : l'utilisateur conclurait que le livre n'est pas dans sa bibliothèque. +/// +public class FiltreLivresLocalTests +{ + private const string Lecteur = "mathieu"; + + private static Auteur Auteur(int id, string nom) + { + var auteur = new Auteur { Id = id, Nom = nom }; + auteur.RecalculerFormes(); + return auteur; + } + + private static readonly Auteur Zola = Auteur(1, "Émile Zola"); + private static readonly Auteur Maupassant = Auteur(2, "Guy de Maupassant"); + + private static Livre Livre(int id, string titre, Auteur? auteur, Format format, Statut? statut) + { + var livre = new Livre { Id = id, Titre = titre, Format = format, AjoutePar = Lecteur }; + livre.RecalculerFormes(); + + if (auteur is not null) + { + livre.Auteurs.Add(new LivreAuteur { LivreId = id, AuteurId = auteur.Id, Auteur = auteur }); + } + + if (statut is { } valeur) + { + livre.Statuts.Add(new StatutLecture { LivreId = id, Utilisateur = Lecteur, Statut = valeur }); + } + + return livre; + } + + private static readonly Livre[] Entites = + [ + Livre(1, "Germinal", Zola, Format.Physique, Statut.Lu), + Livre(2, "La Bête humaine", Zola, Format.Numerique, Statut.ALire), + Livre(3, "Le Horla", Maupassant, Format.Physique, Statut.EnCours), + Livre(4, "Bel-Ami", Maupassant, Format.Numerique, null), + Livre(5, "Œuvres complètes", Zola, Format.Physique, Statut.ALire), + Livre(6, "L'Éducation sentimentale", null, Format.Physique, null), + ]; + + /// + /// Ce que l'API renvoie, et donc ce qui est rangé dans IndexedDB : le statut y est déjà + /// résolu pour l'utilisateur courant, le client n'a plus personne à désigner. + /// + private static readonly LivreDto[] Instantane = Entites + .Select(l => new LivreDto + { + Id = l.Id, + Titre = l.Titre, + Format = l.Format, + DateAjout = DateTime.UtcNow, + Statut = l.Statuts.FirstOrDefault(s => s.Utilisateur == Lecteur)?.Statut, + Auteurs = l.Auteurs + .Select(la => new AuteurDto { Id = la.AuteurId, Nom = la.Auteur!.Nom }) + .ToList(), + }) + .ToArray(); + + public static TheoryData Criteres => + [ + new CritereLivres(), + new CritereLivres { Recherche = "germinal" }, + new CritereLivres { Recherche = "GERMINAL" }, + new CritereLivres { Recherche = "emile" }, // sans accent → doit trouver « Émile » + new CritereLivres { Recherche = "Émile" }, + new CritereLivres { Recherche = "zola emile" }, // ordre inversé → clé de regroupement + new CritereLivres { Recherche = "bete" }, + new CritereLivres { Recherche = "oeuvres" }, + new CritereLivres { Recherche = "introuvable" }, + new CritereLivres { Format = Format.Physique }, + new CritereLivres { Format = Format.Numerique }, + new CritereLivres { Statut = Statut.Lu }, + new CritereLivres { Statut = Statut.ALire }, + new CritereLivres { AuteurId = 1 }, + new CritereLivres { AuteurId = 2 }, + new CritereLivres { AuteurId = 99 }, + new CritereLivres { Recherche = "zola", Format = Format.Physique, Statut = Statut.ALire }, + ]; + + [Theory] + [MemberData(nameof(Criteres))] + public void Le_filtre_hors_ligne_rend_exactement_ce_que_rend_le_serveur(CritereLivres criteres) + { + var serveur = FiltreLivres.Appliquer(Entites.AsQueryable(), criteres, Lecteur) + .Select(l => l.Id) + .ToList(); + + var local = FiltreLivresLocal.Appliquer(Instantane, criteres) + .Select(l => l.Id) + .ToList(); + + // Séquences comparées, pas ensembles : le tri fait partie du contrat. + Assert.Equal(serveur, local); + } + + [Fact] + public void Le_catalogue_entier_est_cherchable_pas_seulement_ce_qui_etait_affiche() + { + // Le cœur de la décision « IndexedDB plutôt que cache du service worker » : une recherche + // jamais tapée en ligne doit rendre un résultat hors-ligne. + var resultat = FiltreLivresLocal.Appliquer(Instantane, new CritereLivres { Recherche = "horla" }); + + Assert.Equal(3, Assert.Single(resultat).Id); + } + + [Fact] + public void La_recherche_ignore_les_accents_dans_les_deux_sens() + { + Assert.Single(FiltreLivresLocal.Appliquer(Instantane, new CritereLivres { Recherche = "bete humaine" })); + Assert.Single(FiltreLivresLocal.Appliquer(Instantane, new CritereLivres { Recherche = "Bête humaine" })); + Assert.Single(FiltreLivresLocal.Appliquer(Instantane, new CritereLivres { Recherche = "education" })); + } + + [Fact] + public void Le_tri_est_alphabetique_et_insensible_aux_accents() + => Assert.Equal( + ["Bel-Ami", "Germinal", "L'Éducation sentimentale", "La Bête humaine", "Le Horla", "Œuvres complètes"], + FiltreLivresLocal.Appliquer(Instantane, new CritereLivres()).Select(l => l.Titre)); + + [Fact] + public void Un_livre_sans_statut_ne_remonte_sous_aucun_statut_mais_reste_visible() + { + Assert.DoesNotContain( + FiltreLivresLocal.Appliquer(Instantane, new CritereLivres { Statut = Statut.Lu }), + l => l.Id == 4); + + Assert.Contains( + FiltreLivresLocal.Appliquer(Instantane, new CritereLivres()), + l => l.Id == 4); + } + + [Fact] + public void Un_instantane_vide_ne_fait_pas_echouer_la_recherche() + => Assert.Empty(FiltreLivresLocal.Appliquer([], new CritereLivres { Recherche = "zola" })); +}