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" })); +}