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 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-18 13:59:19 +02:00
co-authored by Claude Opus 5
parent 3a5a3a7763
commit ebc5f95d49
21 changed files with 1420 additions and 82 deletions
+133
View File
@@ -0,0 +1,133 @@
using System.Text.Json;
using Microsoft.JSInterop;
namespace MaBibli.Client.Services;
/// <summary>Clés des instantanés rangés dans IndexedDB.</summary>
/// <remarks>
/// 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 <b>tout</b> le fonds, y compris des recherches jamais tapées en ligne.
/// </remarks>
public static class ClesCache
{
/// <summary>Le catalogue <b>entier</b>, sans aucun filtre. La recherche hors-ligne s'y applique.</summary>
public const string Catalogue = "catalogue";
public const string Auteurs = "auteurs";
public const string PretsEnCours = "prets-en-cours";
public const string Utilisateur = "utilisateur";
}
/// <summary>Un instantané relu du cache, avec la date de la synchronisation qui l'a produit.</summary>
public sealed record Instantane<T>(T Donnees, DateTimeOffset Date);
/// <summary>
/// Cache de consultation hors-ligne, en <b>lecture seule</b> (CLAUDE.md).
/// </summary>
/// <remarks>
/// 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 <see cref="ServiceLivresApi"/>.
/// <para>
/// 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.
/// </para>
/// </remarks>
public sealed class CacheHorsLigne(IJSRuntime js) : IAsyncDisposable
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private IJSObjectReference? _module;
/// <summary>Vrai tant que le stockage n'a pas montré qu'il était inutilisable.</summary>
public bool Disponible { get; private set; } = true;
private async ValueTask<IJSObjectReference> ModuleAsync() =>
_module ??= await js.InvokeAsync<IJSObjectReference>("import", "./js/cache-hors-ligne.js");
public async Task<Instantane<T>?> LireAsync<T>(string cle)
{
try
{
var module = await ModuleAsync();
var brut = await module.InvokeAsync<EntreeCache?>("lire", cle);
if (brut?.Json is null)
{
return null;
}
var donnees = JsonSerializer.Deserialize<T>(brut.Json, Json);
return donnees is null ? null : new Instantane<T>(donnees, brut.Date);
}
catch (Exception)
{
// Cache illisible : on se comporte comme s'il était vide.
Disponible = false;
return null;
}
}
public async Task EcrireAsync<T>(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;
}
}
/// <summary>État déclaré par le navigateur. Voir la nuance dans <c>cache-hors-ligne.js</c>.</summary>
public async Task<bool> EnLigneAsync()
{
try
{
var module = await ModuleAsync();
return await module.InvokeAsync<bool>("enLigne");
}
catch (Exception)
{
return true; // Sans information, on suppose le réseau : un échec d'appel corrigera.
}
}
/// <summary>Abonne <paramref name="destinataire"/> aux événements <c>online</c>/<c>offline</c>.</summary>
public async Task<bool> SurveillerAsync<T>(DotNetObjectReference<T> destinataire) where T : class
{
try
{
var module = await ModuleAsync();
return await module.InvokeAsync<bool>("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);
}