Première relation entre livres du projet. Une série contient des séries (un cycle EST une série de séries) et des PLACES dans l'ordre de lecture — pas des livres : une place sans livre est le trou qu'on vient voir. L'ordre est stocké, jamais déduit d'une année : une préquelle se lit avant le livre paru dix ans plus tôt. Les tomes non possédés se saisissent à la main, aucune source ne donnant l'ordre de lecture d'une saga. Le titre de la place survit à la suppression du livre (SetNull, pas de cascade), sans quoi perdre un exemplaire effacerait le tome 3 de la série. Les séries sont communes au foyer. Seule exception : la mise en envies d'un tome manquant, qui écrit dans une liste personnelle — c'est le seul point où les deux portées se rencontrent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
164 lines
5.7 KiB
C#
164 lines
5.7 KiB
C#
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>
|
|
/// La liste d'envies de l'utilisateur courant.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// ⚠️ Cet instantané est le seul à contenir des données <b>personnelles</b> : 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.
|
|
/// <para>
|
|
/// Sa raison d'être : la liste s'emporte <b>en librairie</b>, exactement l'endroit où le
|
|
/// réseau manque. C'est le même usage que l'export <c>.txt</c>, lequel est produit côté
|
|
/// serveur et devient donc impossible hors-ligne.
|
|
/// </para>
|
|
/// </remarks>
|
|
public const string Souhaits = "souhaits";
|
|
|
|
/// <summary>
|
|
/// Toutes les séries, à plat, avec leurs tomes.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Un seul instantané sert la liste <b>et</b> 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.
|
|
/// <para>
|
|
/// Communes au foyer, contrairement à <see cref="Souhaits"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
public const string Series = "series";
|
|
}
|
|
|
|
/// <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);
|
|
}
|