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";
///
/// La version publiée et sa date de build, telles que le serveur les a annoncées.
///
///
/// ⚠️ Elle vaut la peine d'être rangée précisément parce qu'on la lit quand quelque chose ne
/// va pas — et un appareil dont on soupçonne le cache est aussi bien celui qui n'a plus de
/// réseau. Ce qui est affiché hors-ligne est la dernière version vue du serveur, et
/// l'écran le dit : c'est un fait daté, pas une supposition.
///
public const string Version = "version";
///
/// La liste d'envies de l'utilisateur courant.
///
///
/// ⚠️ Cet instantané est le seul à contenir des données personnelles : le catalogue,
/// les auteurs et les prêts sont communs au foyer. Il n'est donc écrit qu'avec ce que le
/// serveur a bien voulu rendre à l'appelant — jamais la liste d'un autre, que l'API ne sait
/// de toute façon pas produire.
///
/// Sa raison d'être : la liste s'emporte en librairie, exactement l'endroit où le
/// réseau manque. C'est le même usage que l'export .txt, lequel est produit côté
/// serveur et devient donc impossible hors-ligne.
///
///
public const string Souhaits = "souhaits";
///
/// Les revues et numéros souhaités : la seconde moitié de la liste d'envies.
///
///
/// ⚠️ Un instantané à part parce que l'API en fait une vue à part (table sœur), pas parce
/// que l'écran en ferait deux : la liste d'envies est le seul écran qu'on emporte
/// délibérément là où le réseau manque, et une moitié absente en librairie ne servirait à
/// rien. Personnel, comme — jamais paramétré par utilisateur.
///
public const string SouhaitsRevues = "souhaits-revues";
///
/// Toutes les séries, à plat, avec leurs tomes.
///
///
/// Un seul instantané sert la liste et le détail de chaque série, l'API rendant
/// l'arbre entier en une lecture. C'est ce qui évite un instantané par série consultée —
/// et surtout ce qui rend consultable hors-ligne une série qu'on n'avait pas ouverte avant
/// la coupure, exactement pour la même raison que le catalogue complet.
///
/// Communes au foyer, contrairement à .
///
///
public const string Series = "series";
///
/// Les revues, avec les numéros possédés. Communes au foyer, comme les séries.
///
///
/// Sa raison d'être est la même que celle de la liste d'envies : savoir si l'on a déjà
/// ce numéro se demande devant le présentoir d'un kiosque, là où le réseau manque.
///
public const string Revues = "revues";
}
/// 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;
}
}
///
/// Range une couverture en cache pour la consultation hors-ligne, en tâche de fond.
///
///
/// Fire-and-forget par nature (A5, CLAUDE.md) : appelé après l'affichage réussi d'une
/// couverture, sans jamais bloquer ni faire échouer ce qui s'est déjà bien passé. Une
/// couverture qui ne peut pas être mise en cache (quota, stockage refusé, 502
/// intermittent d'OpenLibrary) reste simplement absente hors-ligne — jamais une erreur
/// visible en ligne.
///
public async Task MettreEnCacheCouvertureAsync(string url)
{
try
{
var module = await ModuleAsync();
await module.InvokeVoidAsync("couvertureMettreEnCache", url);
}
catch (Exception)
{
// Voir la remarque ci-dessus : rien à propager.
}
}
///
/// Relit une couverture déjà mise en cache, sous forme d'URL d'objet (blob:) utilisable
/// directement comme src. null si elle n'a jamais été mise en cache.
///
public async Task LireCouvertureCacheeAsync(string url)
{
try
{
var module = await ModuleAsync();
return await module.InvokeAsync("couvertureLire", url);
}
catch (Exception)
{
return null;
}
}
///
/// Libère une URL d'objet obtenue par .
///
///
/// ⚠️ Nécessaire depuis que le cache sert aussi en ligne : une URL blob: retient son
/// Blob en mémoire tant qu'elle n'est pas révoquée. Avec une vignette par carte sur tous les
/// écrans, ne pas libérer ferait enfler la mémoire de l'onglet au fil de la navigation.
///
public async Task LibererCouvertureAsync(string url)
{
try
{
var module = await ModuleAsync();
await module.InvokeVoidAsync("couvertureLiberer", url);
}
catch (Exception)
{
// Voir MettreEnCacheCouvertureAsync : rien à propager.
}
}
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);
}