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