Fusionner le cache hors-ligne IndexedDB
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> # Conflicts: # MaBibli.Client/wwwroot/css/app.css
This commit is contained in:
@@ -46,7 +46,7 @@ Application self-hosted de gestion de bibliothèque personnelle, à héberger su
|
||||
| Hébergement | YunoHost, installation **native** (pas Docker) | YunoHost déconseille Docker pour ses apps (moins fiable, plus lourd) ; installation native = meilleures perfs sur petit matériel |
|
||||
| Packaging YunoHost | S'inspirer de [`radarr_ynh`](https://github.com/YunoHost-Apps/radarr_ynh) | Radarr est aussi en .NET, packagé sans Docker sur YunoHost. Leur `manifest.toml` montre un déploiement **self-contained** (`dotnet publish -r linux-x64 --self-contained`), donc pas besoin d'installer `dotnet-runtime` via apt côté serveur — le binaire embarque son propre runtime |
|
||||
| Scan ISBN | **ZXing.Net** (C#, Apache 2.0) exécuté dans le WASM ; le JS ne fournit que les pixels caméra | Décodage en C#, réutilisable hors navigateur si le projet évolue en scanner de bibliothèque. Voir la section dédiée ci-dessous |
|
||||
| Consultation hors-ligne | Cache local des données côté client (mécanisme à trancher) | Le besoin est de **consulter la bibliothèque existante** sans réseau, pas d'enrichir de nouveaux livres. Voir « Stratégie hors-ligne » |
|
||||
| Consultation hors-ligne | **Instantanés JSON en IndexedDB**, lecture seule, implémenté le 2026-08-18 | Le besoin est de **consulter la bibliothèque existante** sans réseau, pas d'enrichir de nouveaux livres. Voir « Stratégie hors-ligne » |
|
||||
|
||||
## Scan du code-barres — ZXing.Net (décision actée)
|
||||
|
||||
@@ -155,6 +155,95 @@ public static class IsbnScanner
|
||||
- Hors-ligne, l'interface doit **désactiver explicitement** les actions d'écriture (ajout, édition, prêt) plutôt que de les laisser échouer silencieusement, et indiquer que les données affichées proviennent du cache.
|
||||
- SQLite compilé en WASM côté client a été **écarté** : ne se justifierait que si l'écriture hors-ligne devenait nécessaire.
|
||||
|
||||
### Ce qui a été implémenté le 2026-08-18
|
||||
|
||||
| Pièce | Rôle |
|
||||
|---|---|
|
||||
| `wwwroot/js/cache-hors-ligne.js` | IndexedDB (base `mabibli`, magasin `instantanes`) : ranger, relire, et signaler les bascules `online`/`offline`. Aucune logique métier |
|
||||
| `Services/CacheHorsLigne.cs` | Sérialisation C# des instantanés ; **avale toute panne du stockage** (navigation privée, quota) — ne pas pouvoir cacher n'empêche pas de fonctionner en ligne |
|
||||
| `Services/EtatReseau.cs` | État réseau visible + date de dernière synchronisation, avec deux événements distincts |
|
||||
| `Services/FiltreLivresLocal.cs` | Recherche, filtres et tri hors-ligne sur l'instantané |
|
||||
| `Services/ServiceLivresApi.cs` | Lectures avec repli sur le cache, écritures refusées |
|
||||
|
||||
**Quatre instantanés, un par vue de l'API**, jamais un par requête : `catalogue` (le catalogue
|
||||
**entier**, sans filtre), `auteurs`, `prets-en-cours`, `utilisateur`. C'est exactement ce qui
|
||||
justifie IndexedDB plutôt que le cache du service worker : un cache de réponses HTTP ne
|
||||
restituerait que les URL déjà visitées, donc **une recherche jamais tapée en ligne ne rendrait
|
||||
rien**. Vérifié en exécution — hors-ligne, chercher un auteur jamais affiché auparavant remonte
|
||||
bien son livre.
|
||||
|
||||
L'instantané est réécrit à chaque lecture non filtrée réussie **et après chaque écriture**
|
||||
(rafraîchissement en tâche de fond) : sans cela, une coupure juste après un ajout montrerait un
|
||||
catalogue d'avant.
|
||||
|
||||
### Le filtre existe en deux exemplaires, et c'est assumé
|
||||
|
||||
`FiltreLivres` (serveur, sur `IQueryable<Livre>`, colonnes normalisées déjà calculées) et
|
||||
`FiltreLivresLocal` (navigateur, sur des `LivreDto` qui n'en portent pas) **ne peuvent pas être
|
||||
le même code**. La normalisation est refaite à la volée côté client — sans coût perceptible sur
|
||||
une bibliothèque de foyer — mais par **les mêmes fonctions** (`NormalisationTexte`,
|
||||
`RapprochementAuteurs.Cle`), et le tri est **ordinal** pour reproduire ce que fait SQLite sur une
|
||||
colonne sans collation.
|
||||
|
||||
⚠️ Le garde-fou est un test qui **confronte les deux implémentations** sur le même jeu de données
|
||||
et 17 jeux de critères (`FiltreLivresLocalTests`). Une divergence silencieuse serait pire qu'un
|
||||
cache absent : l'utilisateur conclurait que le livre n'est pas dans sa bibliothèque.
|
||||
|
||||
### ⚠️ `navigator.onLine` ne suffit pas — le piège coûte le retour en ligne
|
||||
|
||||
`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). D'où deux notions distinctes dans
|
||||
`EtatReseau`, et il faut tenir les deux :
|
||||
|
||||
| Propriété | Sens | Usage |
|
||||
|---|---|---|
|
||||
| `EnLigne` | navigateur en ligne **et** dernier appel réussi | ce que l'interface affiche et ce qui active les boutons |
|
||||
| `TenterLeReseau` | navigateur en ligne, **même si le dernier appel a échoué** | faut-il tenter un appel HTTP |
|
||||
|
||||
**Constaté en essai avant correction** : quand la panne vient du *serveur*, `navigator.onLine`
|
||||
n'a jamais changé, donc aucun événement `online` ne viendra jamais — et une lecture qui
|
||||
court-circuitait sur `EnLigne` ne retentait plus rien. L'application restait bloquée sur le cache
|
||||
**jusqu'au rechargement de la page**. Le prix de la correction 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.
|
||||
|
||||
Vérifié après correction : serveur arrêté puis redémarré, **sans aucun événement `online`**, la
|
||||
navigation suivante a fait disparaître le bandeau, réactivé les actions et fait apparaître un
|
||||
livre ajouté côté serveur pendant la coupure.
|
||||
|
||||
### ⚠️ Deux événements, sinon la boucle infinie
|
||||
|
||||
`EtatReseau` expose `Change` (bascule en ligne ↔ hors ligne) **et** `SynchroChange` (nouvelle date
|
||||
de synchronisation). Les écrans se rechargent sur `Change` uniquement. Avec un événement unique,
|
||||
un rechargement écrirait un instantané → nouvel événement → nouveau rechargement, sans fin.
|
||||
|
||||
### Ce que le hors-ligne ne couvre pas, volontairement
|
||||
|
||||
- **L'historique des prêts d'un livre** (`GET /api/livres/{id}/prets`) : une requête par livre pour
|
||||
une information rarement consultée. En revanche l'état *courant* vient de `LivreDto.PreteA`,
|
||||
donc de l'instantané — c'est lui qui répond à « où est ce livre ? », la seule question qui se
|
||||
pose devant l'étagère. Le composant **dit** que l'historique est indisponible plutôt que
|
||||
d'afficher une liste vide, qui se lirait « jamais prêté ».
|
||||
- **Les rapprochements d'auteurs** : une liste de décisions à prendre, or aucune décision ne peut
|
||||
être enregistrée hors-ligne.
|
||||
- **Le lookup ISBN et le scan** : ils interrogent la BnF et OpenLibrary. L'écran le dit
|
||||
explicitement au lieu de laisser expirer un délai d'attente incompréhensible.
|
||||
- Une fiche absente de l'instantané affiche « pas dans les données enregistrées sur cet
|
||||
appareil », **pas** « n'existe plus » : hors-ligne, les deux ne se distinguent pas.
|
||||
|
||||
### Ce qui a été observé, réseau coupé
|
||||
|
||||
Éprouvé sur un `publish Release` servi par l'API, en coupant réellement l'accès à `/api/*`
|
||||
(même origine, donc même IndexedDB) puis en **rechargeant** la page :
|
||||
|
||||
- démarrage à froid : les 9 livres s'affichent, bandeau « Hors ligne. Données enregistrées
|
||||
aujourd'hui à 13:59. » — instantanés écrits à 13:59:32, page rechargée à 14:00 ;
|
||||
- recherche sur tout le fonds : `saint-exupery` (auteur jamais affiché auparavant), `bete` → « La
|
||||
Bête humaine », `emile` → les Zola, `zola emile` (ordre inversé) → les mêmes ;
|
||||
- filtres format et statut opérants sur l'instantané ;
|
||||
- « Ajouter par ISBN », « Saisie manuelle », « Éditer », les quatre boutons de statut, « Prêter »,
|
||||
« Rendu » et « Ajouter au catalogue » : **désactivés**, chacun portant sa raison ;
|
||||
- « Prêts en cours » et « Auteurs » servis depuis le cache, le nom d'utilisateur aussi.
|
||||
|
||||
## Sources de données ISBN — point d'attention important
|
||||
|
||||
**Ne pas dépendre d'une seule source, et éviter Google Books si possible** (préférence explicite de l'utilisateur : pas de dépendance à Google).
|
||||
@@ -293,6 +382,68 @@ Le cache-busting reste assuré par le service worker, qui compare les empreintes
|
||||
|
||||
**Piège de diagnostic à conserver** : vérifier que `/` renvoie 200 ne prouve rien — c'est ce qui a fait passer le défaut inaperçu à la phase 1. Il faut vérifier les scripts que `index.html` référence **réellement**, ou charger la page dans un navigateur.
|
||||
|
||||
### Le service worker est le SEUL cache-busting du projet — conséquences
|
||||
|
||||
Les empreintes étant désactivées, `blazor.webassembly.js` et `dotnet.js` portent des noms
|
||||
stables : rien d'autre que le service worker n'empêche de servir éternellement une version
|
||||
périmée. Vérifié que la chaîne tient, sans navigateur, en publiant deux fois avec une seule
|
||||
ligne de différence dans `app.css` :
|
||||
|
||||
| | Publication A | Publication B |
|
||||
|---|---|---|
|
||||
| `service-worker-assets.js` → `version` | `Oc+bE5e+` | `jBwHvKfd` |
|
||||
| Première ligne de `service-worker.js` | `/* Manifest version: Oc+bE5e+ */` | `/* Manifest version: jBwHvKfd */` |
|
||||
|
||||
Le point important est la **seconde ligne** : le SDK écrit la version dans le corps même de
|
||||
`service-worker.js`. Le navigateur compare ce fichier **octet à octet** à chaque vérification de
|
||||
mise à jour — il n'a donc pas à deviner que `service-worker-assets.js` a changé. Nouveau worker →
|
||||
nouveau nom de cache (`offline-cache-{version}`) → tous les assets refetchés. Un changement de
|
||||
code C# suffit aussi (empreintes des `.wasm`), constaté : `Oc+bE5e+` → `wCvu+Chi`.
|
||||
|
||||
**Ce que le mécanisme d'origine ne réglait pas** : un 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ù `wwwroot/js/mise-a-jour.js` :
|
||||
|
||||
- il **vérifie le support avant d'appeler `navigator.serviceWorker`** — absent en contexte non
|
||||
sécurisé (http sur une IP locale), où l'appel direct levait une `TypeError` ;
|
||||
- il **journalise un échec d'enregistrement** au lieu de l'avaler (c'est ce qui a permis
|
||||
d'élucider le point ci-dessous) ;
|
||||
- il appelle `registration.update()` à chaque chargement, et affiche un bandeau « Mettre à jour »
|
||||
quand une version est prête ; le clic envoie `SKIP_WAITING` au worker en attente, qui appelle
|
||||
`self.skipWaiting()` (ajouté à `service-worker.published.js`), puis `controllerchange` recharge.
|
||||
|
||||
⚠️ Le rechargement sur `controllerchange` est **conditionné à un clic** : cet événement survient
|
||||
aussi à la toute première installation, et recharger à ce moment-là serait un clignotement
|
||||
inexplicable. Ne pas retirer le drapeau.
|
||||
|
||||
### ⚠️ Le service worker ne s'enregistre pas dans le navigateur d'automatisation — c'est l'environnement
|
||||
|
||||
Symptôme constaté depuis la phase 3, cause établie le 2026-08-18. **Ne pas repartir en chasse au
|
||||
bug de configuration PWA** : les quatre observations ci-dessous vont toutes dans le même sens.
|
||||
|
||||
1. `navigator.serviceWorker.getRegistrations()` renvoie `[]`, et `register()` échoue en
|
||||
`TypeError: … An unknown error occurred when fetching the script.`
|
||||
2. Un `fetch('/service-worker.js')` **depuis la même page** renvoie `200 text/javascript`,
|
||||
3 335 octets. Le fichier est donc bien servi.
|
||||
3. **Toutes** les URL échouent identiquement, y compris `/index.html` — or un HTML *récupéré*
|
||||
échouerait avec une erreur de type MIME, pas avec « fetching the script ». L'échec est donc
|
||||
**avant** la requête.
|
||||
4. Décisif : journalisation `Microsoft.AspNetCore` en `Information`, puis un `fetch` et un
|
||||
`register` sur la même URL portant chacun un repère distinct. Le serveur journalise
|
||||
`Request starting … ?repere=fetchB` et **rien du tout** pour `?repere=registerB`. La requête
|
||||
d'enregistrement **ne quitte jamais le navigateur**.
|
||||
|
||||
Le navigateur en question n'est pas un Chrome ordinaire : `navigator.userAgent` indique
|
||||
`Claude/1.30096.1 Chrome/148 Electron/42.7.0` — un hôte Electron, dont la couche d'interception
|
||||
réseau ne sert pas les requêtes de script de service worker.
|
||||
|
||||
**Conséquence à assumer** : le démarrage hors-ligne *complet* (coquille HTML/WASM servie par le
|
||||
service worker) n'est **pas vérifiable ici**. Ce qui a été vérifié pour de bon, c'est tout le
|
||||
reste — coquille servie par un serveur statique sur la **même origine**, API réellement
|
||||
injoignable, et l'application repart de son cache IndexedDB. À confirmer dans un navigateur
|
||||
ordinaire : charger l'application, vérifier dans les outils de développement (Application →
|
||||
Service Workers) que le worker est `activated`, cocher « Offline », puis recharger.
|
||||
|
||||
### Deux dépôts distincts
|
||||
|
||||
| Dépôt | Contenu | Rôle |
|
||||
@@ -705,7 +856,7 @@ Conclusion : aucune des deux solutions existantes ne coche toutes les cases (pr
|
||||
5. CRUD livres (physique/numérique, statuts de lecture)
|
||||
6. Gestion des prêts
|
||||
7. Intégration scan caméra (**ZXing.Net** + interop caméra minimal)
|
||||
8. Cache hors-ligne pour la consultation (voir « Stratégie hors-ligne »)
|
||||
8. ~~Cache hors-ligne pour la consultation~~ — fait le 2026-08-18 (voir « Stratégie hors-ligne »)
|
||||
9. Packaging YunoHost (`manifest.toml`, `conf/systemd.service`, `conf/nginx.conf`, `scripts/install`) en s'inspirant de radarr_ynh
|
||||
|
||||
## Questions ouvertes
|
||||
@@ -717,3 +868,5 @@ Points à réévaluer en cours de route, sans blocage :
|
||||
- **AOT WASM** : mesurer le scan sur un vrai téléphone une fois fonctionnel. Activer `RunAOTCompilation` seulement si la fluidité est insuffisante.
|
||||
- **Runner Gitea Actions** : à vérifier le jour où l'utilisateur voudra automatiser les releases.
|
||||
- **Wikidata en 3ᵉ source ISBN** : uniquement si la cascade BnF → OpenLibrary montre ses limites en usage réel.
|
||||
- **Démarrage hors-ligne par le service worker** : à confirmer dans un navigateur ordinaire, l'environnement d'automatisation ne permettant pas d'enregistrer un service worker (voir la section dédiée). Le reste du hors-ligne, lui, est vérifié.
|
||||
- **Taille de l'instantané** : 10 livres pèsent ~2,4 Ko de JSON. Rien à optimiser avant plusieurs milliers de fiches ; si le jour vient, découper par pages plutôt que renoncer à l'instantané complet, qui est ce qui rend la recherche hors-ligne possible.
|
||||
|
||||
@@ -84,9 +84,17 @@
|
||||
<p class="message-erreur" role="alert">@Erreur</p>
|
||||
}
|
||||
|
||||
@* 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))
|
||||
{
|
||||
<p class="message-avertissement" role="status">@MessageBlocage</p>
|
||||
}
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(EnCours || string.IsNullOrWhiteSpace(Saisie.Titre))"
|
||||
disabled="@(EnCours || Bloque || string.IsNullOrWhiteSpace(Saisie.Titre))"
|
||||
title="@MessageBlocage"
|
||||
@onclick="Valider">
|
||||
@(EnCours ? "Enregistrement…" : LibelleValidation)
|
||||
</button>
|
||||
@@ -112,6 +120,15 @@
|
||||
|
||||
[Parameter] public bool EnCours { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Parameter] public string? MessageBlocage { get; set; }
|
||||
|
||||
private bool Bloque => !string.IsNullOrEmpty(MessageBlocage);
|
||||
|
||||
[Parameter] public EventCallback OnValider { get; set; }
|
||||
|
||||
[Parameter] public EventCallback OnAnnuler { get; set; }
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
}
|
||||
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)
|
||||
{
|
||||
<p class="bandeau-pret">
|
||||
<span class="etiquette etiquette-prete">Prêté</span>
|
||||
à <strong>@absent</strong>
|
||||
@if (PreteDepuis is { } depuis)
|
||||
{
|
||||
@($" depuis le {Jour(depuis)}")
|
||||
}
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="message-discret">Ce livre était à la maison à la dernière synchronisation.</p>
|
||||
}
|
||||
|
||||
<p class="message-discret">
|
||||
Hors ligne : l'historique des prêts n'est pas consultable, et prêter ou marquer un
|
||||
retour demande le réseau.
|
||||
</p>
|
||||
}
|
||||
else if (_prets is null)
|
||||
{
|
||||
<p class="message-discret">Chargement…</p>
|
||||
@@ -115,6 +149,15 @@
|
||||
/// <summary>Format du livre : un ebook ne se prête pas.</summary>
|
||||
[Parameter] public Format Format { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Emprunteur actuel d'après la fiche. Sert <b>hors-ligne</b>, où l'historique n'est pas
|
||||
/// consultable mais où l'état courant, lui, vient de l'instantané du catalogue.
|
||||
/// </summary>
|
||||
[Parameter] public string? PreteA { get; set; }
|
||||
|
||||
/// <summary>Date du prêt en cours, même origine que <see cref="PreteA"/>.</summary>
|
||||
[Parameter] public DateTime? PreteDepuis { get; set; }
|
||||
|
||||
/// <summary>Prévient la page parente qu'un prêt a changé, pour qu'elle rafraîchisse sa fiche.</summary>
|
||||
[Parameter] public EventCallback OnChangement { get; set; }
|
||||
|
||||
@@ -131,6 +174,21 @@
|
||||
private IReadOnlyList<PretDto> Historique =>
|
||||
_prets?.Where(p => !p.EstEnCours).ToList() ?? [];
|
||||
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
|
||||
/// <summary>Au retour du réseau, l'historique devient consultable : on va le chercher.</summary>
|
||||
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) ?? [];
|
||||
|
||||
@@ -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. *@
|
||||
<header class="bandeau">
|
||||
<a class="marque" href="">MaBibli</a>
|
||||
|
||||
@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. *@
|
||||
<span class="pastille-hors-ligne" title="@EtatReseau.MotifHorsLigne">Hors ligne</span>
|
||||
}
|
||||
|
||||
@if (_utilisateur?.Identifiant is not null)
|
||||
{
|
||||
<span class="utilisateur" title="@(_utilisateur.Simule ? "Utilisateur simulé (développement)" : "Connecté via le portail YunoHost")">
|
||||
@@ -14,13 +23,105 @@
|
||||
}
|
||||
</header>
|
||||
|
||||
@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.
|
||||
*@
|
||||
<p class="bandeau-reseau" role="status">
|
||||
<strong>Hors ligne.</strong>
|
||||
@(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.
|
||||
</p>
|
||||
}
|
||||
|
||||
<main class="contenu">
|
||||
@Body
|
||||
</main>
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Date de synchronisation en clair : l'heure suffit le jour même.</summary>
|
||||
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}";
|
||||
}
|
||||
|
||||
/// <summary>La date affichée vient de changer : rien à recharger, juste à redessiner.</summary>
|
||||
private void SurSynchro() => _ = InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Reseau.Change -= SurChangementReseau;
|
||||
Reseau.SynchroChange -= SurSynchro;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
@page "/ajout/isbn"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject NavigationManager Navigation
|
||||
@inject EtatReseau Reseau
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — ajouter par ISBN</PageTitle>
|
||||
|
||||
@@ -12,6 +14,19 @@
|
||||
Scannez le code-barres, ou saisissez l'ISBN imprimé sur le livre.
|
||||
</p>
|
||||
|
||||
@*
|
||||
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)
|
||||
{
|
||||
<p class="message-avertissement" role="status">
|
||||
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.
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="barre-recherche">
|
||||
<input class="champ-saisie" type="text" inputmode="numeric" placeholder="978…"
|
||||
@bind="_isbn" @bind:event="oninput" @onkeydown="SurTouche"
|
||||
@@ -20,13 +35,16 @@
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_chargement || string.IsNullOrWhiteSpace(_isbn))"
|
||||
disabled="@(_chargement || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_isbn))"
|
||||
title="@MotifBlocage"
|
||||
@onclick="ChercherAsync">
|
||||
@(_chargement ? "Recherche…" : "Chercher")
|
||||
</button>
|
||||
@* 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. *@
|
||||
<button type="button" class="bouton bouton-discret" @onclick="OuvrirScanner">
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
disabled="@(!Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="OuvrirScanner">
|
||||
Scanner
|
||||
</button>
|
||||
<a class="bouton bouton-discret" href="ajout/manuel">Saisir à la main</a>
|
||||
@@ -108,6 +126,7 @@
|
||||
LibelleValidation="Ajouter au catalogue"
|
||||
Erreur="@_erreurFormulaire"
|
||||
EnCours="_enregistrement"
|
||||
MessageBlocage="@MotifBlocage"
|
||||
OnValider="EnregistrerAsync"
|
||||
OnAnnuler="Recommencer" />
|
||||
}
|
||||
@@ -125,6 +144,15 @@
|
||||
private IReadOnlyList<string> _avertissements = [];
|
||||
private EnregistrementLivre _saisie = new();
|
||||
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
|
||||
private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged);
|
||||
|
||||
public void Dispose() => Reseau.Change -= SurChangementReseau;
|
||||
|
||||
/// <summary>Raison du blocage des actions, ou <c>null</c> quand tout est possible.</summary>
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
private void OuvrirScanner()
|
||||
{
|
||||
_erreur = null;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
@page "/ajout/manuel"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject NavigationManager Navigation
|
||||
@inject EtatReseau Reseau
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — saisie manuelle</PageTitle>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@page "/auteurs"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — auteurs</PageTitle>
|
||||
|
||||
@@ -37,11 +39,14 @@
|
||||
</p>
|
||||
|
||||
<div class="actions-formulaire">
|
||||
<button type="button" class="bouton bouton-principal" disabled="@_enCours"
|
||||
@* Une fusion est une écriture : elle demande le réseau, comme le reste. *@
|
||||
<button type="button" class="bouton bouton-principal"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="() => AccepterAsync(suggestion)">
|
||||
Oui, réunir sous « @suggestion.Cible.Nom »
|
||||
</button>
|
||||
<button type="button" class="bouton bouton-discret" disabled="@_enCours"
|
||||
<button type="button" class="bouton bouton-discret"
|
||||
disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="() => RefuserAsync(suggestion)">
|
||||
Non, deux personnes
|
||||
</button>
|
||||
@@ -90,6 +95,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;
|
||||
|
||||
/// <summary>Raison du blocage des fusions, ou <c>null</c> en ligne.</summary>
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
protected override Task OnInitializedAsync() => ChargerAsync();
|
||||
|
||||
private static string Livres(int nombre) => $"{nombre} livre{(nombre > 1 ? "s" : "")}";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/"
|
||||
@inject ServiceLivresApi Api
|
||||
@inject EtatReseau Reseau
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>MaBibli — catalogue</PageTitle>
|
||||
@@ -121,9 +122,22 @@ else if (_livres is not null)
|
||||
</ul>
|
||||
}
|
||||
|
||||
@* 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. *@
|
||||
<nav class="actions-flottantes">
|
||||
<a class="bouton bouton-principal" href="ajout/isbn">Ajouter par ISBN</a>
|
||||
<a class="bouton bouton-discret" href="ajout/manuel">Saisie manuelle</a>
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<a class="bouton bouton-principal" href="ajout/isbn">Ajouter par ISBN</a>
|
||||
<a class="bouton bouton-discret" href="ajout/manuel">Saisie manuelle</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-principal" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Ajouter par ISBN</button>
|
||||
<button type="button" class="bouton bouton-discret" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Saisie manuelle</button>
|
||||
}
|
||||
<a class="bouton bouton-discret" href="prets">Prêts</a>
|
||||
<a class="bouton bouton-discret" href="auteurs">Auteurs</a>
|
||||
<a class="bouton bouton-discret" href="souhaits">Envies</a>
|
||||
@@ -150,6 +164,18 @@ else if (_livres is not null)
|
||||
!string.IsNullOrWhiteSpace(_recherche) || _format is not null || _statut is not null
|
||||
|| AuteurId is not null;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
protected override void OnInitialized() => Reseau.Change += SurChangementReseau;
|
||||
|
||||
private void SurChangementReseau() => _ = InvokeAsync(async () =>
|
||||
{
|
||||
await ChargerAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (_auteurCharge != AuteurId)
|
||||
@@ -220,6 +246,7 @@ else if (_livres is not null)
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Reseau.Change -= SurChangementReseau;
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
<p class="message-erreur" role="alert">Ce livre n'existe pas (ou plus).</p>
|
||||
@* 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. *@
|
||||
<p class="message-erreur" role="alert">
|
||||
@(Reseau.EnLigne
|
||||
? "Ce livre n'existe pas (ou plus)."
|
||||
: "Ce livre n'est pas dans les données enregistrées sur cet appareil. Il a peut-être été ajouté depuis la dernière synchronisation.")
|
||||
</p>
|
||||
<a class="bouton bouton-discret" href="">Retour au catalogue</a>
|
||||
}
|
||||
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)
|
||||
<section class="zone-danger">
|
||||
@if (!_confirmationSuppression)
|
||||
{
|
||||
<button type="button" class="bouton bouton-danger" @onclick="() => _confirmationSuppression = true">
|
||||
<button type="button" class="bouton bouton-danger"
|
||||
disabled="@(!Reseau.EnLigne)" title="@MotifBlocage"
|
||||
@onclick="() => _confirmationSuppression = true">
|
||||
Supprimer ce livre
|
||||
</button>
|
||||
}
|
||||
@@ -126,10 +136,16 @@ else
|
||||
<h2 class="titre-section">Statut de lecture <span class="champ-aide">(le vôtre)</span></h2>
|
||||
<ChoixStatut Valeur="_livre.Statut"
|
||||
ValeurChanged="ChangerStatutAsync"
|
||||
Desactive="_statutEnCours" />
|
||||
Desactive="@(_statutEnCours || !Reseau.EnLigne)" />
|
||||
@if (!Reseau.EnLigne)
|
||||
{
|
||||
<p class="message-discret">@EtatReseau.MotifHorsLigne</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<PretsLivre LivreId="Id" Format="_livre.Format" OnChangement="RechargerAsync" />
|
||||
<PretsLivre LivreId="Id" Format="_livre.Format"
|
||||
PreteA="@_livre.PreteA" PreteDepuis="_livre.PreteDepuis"
|
||||
OnChangement="RechargerAsync" />
|
||||
|
||||
<p class="message-discret">
|
||||
Ajouté le @_livre.DateAjout.ToLocalTime().ToString("dd/MM/yyyy")
|
||||
@@ -141,7 +157,15 @@ else
|
||||
</p>
|
||||
|
||||
<nav class="actions-flottantes">
|
||||
<a class="bouton bouton-principal" href="@($"livres/{Id}/edition")">Éditer</a>
|
||||
@if (Reseau.EnLigne)
|
||||
{
|
||||
<a class="bouton bouton-principal" href="@($"livres/{Id}/edition")">Éditer</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="bouton bouton-principal" disabled
|
||||
title="@EtatReseau.MotifHorsLigne">Éditer</button>
|
||||
}
|
||||
<a class="bouton bouton-discret" href="">Catalogue</a>
|
||||
</nav>
|
||||
}
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
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;
|
||||
/// <summary>Le réseau a basculé : la fiche se relit (serveur ou cache) et les actions suivent.</summary>
|
||||
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();
|
||||
});
|
||||
|
||||
/// <summary>Raison affichable quand la fiche ne peut pas être modifiée, sinon <c>null</c>.</summary>
|
||||
private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Navigation.LocationChanged -= SurNavigation;
|
||||
Reseau.Change -= SurChangementReseau;
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
<p class="message-discret">Tous les livres sont à la maison.</p>
|
||||
@* 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. *@
|
||||
<p class="message-discret">
|
||||
@(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é.")
|
||||
</p>
|
||||
}
|
||||
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. *@
|
||||
<button type="button" class="bouton bouton-discret bouton-compact"
|
||||
disabled="@(_enCours == pret.Id)"
|
||||
disabled="@(_enCours == pret.Id || !Reseau.EnLigne)"
|
||||
title="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
|
||||
@onclick="() => RendreAsync(pret)">
|
||||
@(_enCours == pret.Id ? "Enregistrement…" : "Rendu")
|
||||
</button>
|
||||
@@ -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()
|
||||
|
||||
@@ -7,6 +7,11 @@ builder.RootComponents.Add<App>("#app");
|
||||
builder.RootComponents.Add<HeadOutlet>("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<MaBibli.Client.Services.CacheHorsLigne>();
|
||||
builder.Services.AddScoped<MaBibli.Client.Services.EtatReseau>();
|
||||
builder.Services.AddScoped<MaBibli.Client.Services.ServiceLivresApi>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace MaBibli.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// État du réseau, tel que l'interface doit le montrer, et date du dernier rafraîchissement
|
||||
/// des données mises en cache.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Deux sources, pas une.</b> <c>navigator.onLine</c> 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.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class EtatReseau(CacheHorsLigne cache) : IAsyncDisposable
|
||||
{
|
||||
private DotNetObjectReference<EtatReseau>? _reference;
|
||||
|
||||
private bool _navigateurEnLigne = true;
|
||||
private bool _dernierAppelEchoue;
|
||||
|
||||
/// <summary>Faux dès qu'un appel a échoué ou que le navigateur signale la perte du réseau.</summary>
|
||||
public bool EnLigne => _navigateurEnLigne && !_dernierAppelEchoue;
|
||||
|
||||
/// <summary>
|
||||
/// Faut-il tenter un appel réseau ? <b>Oui dès que le navigateur a une connexion</b>, même
|
||||
/// si le dernier appel a échoué.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C'est ce qui permet de sortir tout seul du mode hors-ligne quand la panne venait du
|
||||
/// <b>serveur</b> et non de l'appareil : <c>navigator.onLine</c> n'a alors jamais changé, donc
|
||||
/// aucun événement <c>online</c> 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.
|
||||
/// </remarks>
|
||||
public bool TenterLeReseau => _navigateurEnLigne;
|
||||
|
||||
/// <summary>
|
||||
/// Instant du dernier rafraîchissement réussi des données en cache, <c>null</c> si l'on n'a
|
||||
/// jamais rien pu enregistrer sur cet appareil.
|
||||
/// </summary>
|
||||
public DateTimeOffset? DerniereSynchro { get; private set; }
|
||||
|
||||
/// <summary>Vrai quand ce qui est affiché vient du cache et non du réseau.</summary>
|
||||
public bool DonneesDuCache => !EnLigne && DerniereSynchro is not null;
|
||||
|
||||
/// <summary>Raison affichable du blocage des actions d'écriture.</summary>
|
||||
public const string MotifHorsLigne =
|
||||
"Hors ligne : la bibliothèque reste consultable, mais rien ne peut être modifié.";
|
||||
|
||||
/// <summary>
|
||||
/// Bascule en ligne ↔ hors ligne, et <b>elle seule</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// La date de synchronisation a son propre événement (<see cref="SynchroChange"/>) : sans
|
||||
/// cette séparation, un écran qui rechargerait ses données sur <c>Change</c> déclencherait
|
||||
/// l'écriture d'un instantané, donc un nouvel événement, donc un rechargement — une boucle
|
||||
/// sans fin.
|
||||
/// </remarks>
|
||||
public event Action? Change;
|
||||
|
||||
/// <summary>Nouvelle date de synchronisation : de quoi rafraîchir un affichage, rien de plus.</summary>
|
||||
public event Action? SynchroChange;
|
||||
|
||||
/// <summary>À appeler une fois au démarrage : lit l'état courant et s'abonne aux bascules.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Un appel HTTP a échoué : on est hors ligne, quoi qu'en dise le navigateur.</summary>
|
||||
public void SignalerEchecReseau() => Definir(_navigateurEnLigne, true);
|
||||
|
||||
/// <summary>Un appel HTTP a abouti : la preuve la plus solide qu'on soit en ligne.</summary>
|
||||
public void SignalerSuccesReseau() => Definir(true, false);
|
||||
|
||||
/// <summary>Enregistre qu'un instantané vient d'être rangé, avec sa date.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using MaBibli.Shared.Catalogue;
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Textes;
|
||||
|
||||
namespace MaBibli.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Recherche, filtrage et tri du catalogue <b>hors-ligne</b>, appliqués à l'instantané complet
|
||||
/// rangé en IndexedDB.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// C'est le pendant navigateur de <see cref="FiltreLivres"/>, 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 <see cref="LivreDto"/> 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 <b>les mêmes fonctions</b> (<see cref="NormalisationTexte"/>,
|
||||
/// <see cref="RapprochementAuteurs.Cle"/>), ce qui est la seule garantie que « emile » trouve
|
||||
/// « Émile » des deux côtés.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Aucun filtre sur <c>AjoutePar</c>, ici comme côté serveur : la bibliothèque est commune.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class FiltreLivresLocal
|
||||
{
|
||||
public static IReadOnlyList<LivreDto> Appliquer(
|
||||
IEnumerable<LivreDto> 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();
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,59 @@ using MaBibli.Shared.Entites;
|
||||
namespace MaBibli.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Accès à l'API depuis le client Blazor.
|
||||
/// Accès à l'API depuis le client Blazor, avec repli sur le cache hors-ligne.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Aucun cache : la phase 3 suppose le réseau disponible. Le cache hors-ligne est la phase 6.
|
||||
/// <b>Le piège que ce service existe pour éviter</b> (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 <i>assets</i> — 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.
|
||||
/// <para>
|
||||
/// La règle est simple et sans exception : <b>les lectures retombent sur le cache, les écritures
|
||||
/// sont refusées</b>. 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 ».
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ServiceLivresApi(HttpClient http)
|
||||
public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, EtatReseau reseau)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Recharge tous les instantanés depuis le réseau.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <returns>Vrai si les données ont pu être rafraîchies.</returns>
|
||||
public async Task<bool> 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<IReadOnlyList<LivreDto>> ListerAsync(CritereLivres criteres, CancellationToken ct = default)
|
||||
{
|
||||
var parametres = new List<string>();
|
||||
@@ -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<List<LivreDto>>(url, Json, ct) ?? [];
|
||||
var (ok, liste) = await EssayerAsync(() => http.GetFromJsonAsync<List<LivreDto>>(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<List<LivreDto>>(ClesCache.Catalogue);
|
||||
return instantane is null ? [] : FiltreLivresLocal.Appliquer(instantane.Donnees, criteres);
|
||||
}
|
||||
|
||||
public async Task<LivreDto?> ObtenirAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var reponse = await http.GetAsync($"api/livres/{id}", ct);
|
||||
return reponse.StatusCode == HttpStatusCode.NotFound
|
||||
? null
|
||||
: await LireAsync<LivreDto>(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<LivreDto>(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<List<LivreDto>>(ClesCache.Catalogue);
|
||||
return instantane?.Donnees.FirstOrDefault(l => l.Id == id);
|
||||
}
|
||||
|
||||
public async Task<ResultatApi<LivreDto>> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default)
|
||||
{
|
||||
var reponse = await http.PostAsJsonAsync("api/livres", saisie, Json, ct);
|
||||
return await InterpreterAsync<LivreDto>(reponse, ct);
|
||||
}
|
||||
public async Task<ResultatApi<LivreDto>> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default) =>
|
||||
await EcrireAsync<LivreDto>(() => http.PostAsJsonAsync("api/livres", saisie, Json, ct), ct);
|
||||
|
||||
public async Task<ResultatApi<LivreDto>> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default)
|
||||
{
|
||||
var reponse = await http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct);
|
||||
return await InterpreterAsync<LivreDto>(reponse, ct);
|
||||
}
|
||||
public async Task<ResultatApi<LivreDto>> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default) =>
|
||||
await EcrireAsync<LivreDto>(() => http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Change le statut de lecture <b>de l'utilisateur courant</b> ; <c>null</c> le remet à
|
||||
/// « non commencé ». Le serveur sait qui appelle : le client ne transmet jamais d'identité.
|
||||
/// </summary>
|
||||
public async Task<ResultatApi<LivreDto>> 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<LivreDto>(reponse, ct);
|
||||
}
|
||||
int id, Statut? statut, CancellationToken ct = default) =>
|
||||
await EcrireAsync<LivreDto>(
|
||||
() => http.PatchAsJsonAsync(
|
||||
$"api/livres/{id}/statut", new ChangementStatut { Statut = statut }, Json, ct),
|
||||
ct);
|
||||
|
||||
public async Task<IReadOnlyList<AuteurDto>> ListerAuteursAsync(CancellationToken ct = default) =>
|
||||
await http.GetFromJsonAsync<List<AuteurDto>>("api/auteurs", Json, ct) ?? [];
|
||||
public async Task<IReadOnlyList<AuteurDto>> ListerAuteursAsync(CancellationToken ct = default)
|
||||
{
|
||||
var (ok, auteurs) = await EssayerAsync(
|
||||
() => http.GetFromJsonAsync<List<AuteurDto>>("api/auteurs", Json, ct));
|
||||
|
||||
if (ok)
|
||||
{
|
||||
await MemoriserAsync(ClesCache.Auteurs, auteurs ?? []);
|
||||
return auteurs ?? [];
|
||||
}
|
||||
|
||||
var instantane = await LireCacheAsync<List<AuteurDto>>(ClesCache.Auteurs);
|
||||
return instantane?.Donnees ?? [];
|
||||
}
|
||||
|
||||
public async Task<AuteurDto?> ObtenirAuteurAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var reponse = await http.GetAsync($"api/auteurs/{id}", ct);
|
||||
return reponse.StatusCode == HttpStatusCode.NotFound
|
||||
? null
|
||||
: await LireAsync<AuteurDto>(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<AuteurDto>(reponse, ct);
|
||||
});
|
||||
|
||||
if (ok)
|
||||
{
|
||||
return auteur;
|
||||
}
|
||||
|
||||
var instantane = await LireCacheAsync<List<AuteurDto>>(ClesCache.Auteurs);
|
||||
return instantane?.Donnees.FirstOrDefault(a => a.Id == id);
|
||||
}
|
||||
|
||||
/// <summary>Rapprochements d'auteurs ambigus, que le serveur refuse d'appliquer seul.</summary>
|
||||
/// <summary>
|
||||
/// Rapprochements d'auteurs ambigus, que le serveur refuse d'appliquer seul.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Volontairement <b>pas</b> 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.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<SuggestionRapprochement>> ListerRapprochementsAsync(
|
||||
CancellationToken ct = default) =>
|
||||
await http.GetFromJsonAsync<List<SuggestionRapprochement>>("api/auteurs/rapprochements", Json, ct) ?? [];
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var (ok, suggestions) = await EssayerAsync(
|
||||
() => http.GetFromJsonAsync<List<SuggestionRapprochement>>("api/auteurs/rapprochements", Json, ct));
|
||||
|
||||
return ok ? suggestions ?? [] : [];
|
||||
}
|
||||
|
||||
public Task<string?> 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<string?> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Les livres actuellement dehors, du prêt le plus ancien au plus récent.</summary>
|
||||
public async Task<IReadOnlyList<PretDto>> ListerPretsEnCoursAsync(CancellationToken ct = default) =>
|
||||
await http.GetFromJsonAsync<List<PretDto>>("api/prets/en-cours", Json, ct) ?? [];
|
||||
public async Task<IReadOnlyList<PretDto>> ListerPretsEnCoursAsync(CancellationToken ct = default)
|
||||
{
|
||||
var (ok, prets) = await EssayerAsync(
|
||||
() => http.GetFromJsonAsync<List<PretDto>>("api/prets/en-cours", Json, ct));
|
||||
|
||||
if (ok)
|
||||
{
|
||||
await MemoriserAsync(ClesCache.PretsEnCours, prets ?? []);
|
||||
return prets ?? [];
|
||||
}
|
||||
|
||||
var instantane = await LireCacheAsync<List<PretDto>>(ClesCache.PretsEnCours);
|
||||
return instantane?.Donnees ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Historique complet des prêts d'un livre, du plus récent au plus ancien.
|
||||
/// </summary>
|
||||
/// <remarks>Liste vide = jamais prêté ; <c>null</c> = livre inconnu.</remarks>
|
||||
/// <remarks>
|
||||
/// Liste vide = jamais prêté ; <c>null</c> = livre inconnu <b>ou</b> réseau absent. L'appelant
|
||||
/// doit consulter <see cref="EtatReseau.EnLigne"/> 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.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<PretDto>?> ListerPretsLivreAsync(int livreId, CancellationToken ct = default)
|
||||
{
|
||||
var reponse = await http.GetAsync($"api/livres/{livreId}/prets", ct);
|
||||
return reponse.StatusCode == HttpStatusCode.NotFound
|
||||
? null
|
||||
: await LireAsync<List<PretDto>>(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<List<PretDto>>(reponse, ct);
|
||||
});
|
||||
|
||||
return ok ? prets : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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<PretDto>(reponse, ct);
|
||||
return await EcrireAsync<PretDto>(
|
||||
() => http.PostAsJsonAsync($"api/livres/{livreId}/prets", saisie, Json, ct), ct);
|
||||
}
|
||||
|
||||
/// <summary>Clôt un prêt. Le prêt n'est pas supprimé : il devient l'historique du livre.</summary>
|
||||
public async Task<ResultatApi<PretDto>> 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<PretDto>(reponse, ct);
|
||||
}
|
||||
int pretId, DateTime? dateRetour = null, CancellationToken ct = default) =>
|
||||
await EcrireAsync<PretDto>(
|
||||
() => http.PostAsJsonAsync(
|
||||
$"api/prets/{pretId}/retour", new RetourPret { DateRetour = EnUtc(dateRetour) }, Json, ct),
|
||||
ct);
|
||||
|
||||
/// <summary>
|
||||
/// 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<string?> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interroge la cascade BnF → OpenLibrary. <b>Exige le réseau</b> : rien n'est mis en cache,
|
||||
/// et hors-ligne l'appel n'est même pas tenté (<c>null</c> immédiat).
|
||||
/// </summary>
|
||||
public async Task<ResultatLookupIsbn?> ChercherIsbnAsync(string isbn, CancellationToken ct = default)
|
||||
{
|
||||
var reponse = await http.GetAsync($"api/isbn/{Uri.EscapeDataString(isbn)}", ct);
|
||||
return reponse.IsSuccessStatusCode
|
||||
? await LireAsync<ResultatLookupIsbn>(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<ResultatLookupIsbn>(reponse, ct)
|
||||
: null;
|
||||
});
|
||||
|
||||
return ok ? resultat : null;
|
||||
}
|
||||
|
||||
public async Task<UtilisateurCourant> ObtenirUtilisateurAsync(CancellationToken ct = default)
|
||||
{
|
||||
var (ok, utilisateur) = await EssayerAsync(
|
||||
() => http.GetFromJsonAsync<UtilisateurCourant>("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<UtilisateurCourant>(ClesCache.Utilisateur);
|
||||
return instantane?.Donnees ?? UtilisateurCourant.Anonyme;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exécute un appel de lecture. Renvoie <c>false</c> — 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.
|
||||
/// </summary>
|
||||
private async Task<(bool Ok, T? Valeur)> EssayerAsync<T>(Func<Task<T?>> 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<UtilisateurCourant>("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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exécute une écriture, ou la <b>refuse</b> hors-ligne avec un motif affichable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private async Task<ResultatApi<T>> EcrireAsync<T>(
|
||||
Func<Task<HttpResponseMessage>> appel, CancellationToken ct)
|
||||
{
|
||||
if (!reseau.EnLigne)
|
||||
{
|
||||
return new ResultatApi<T>(default, EtatReseau.MotifHorsLigne);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var reponse = await appel();
|
||||
reseau.SignalerSuccesReseau();
|
||||
var resultat = await InterpreterAsync<T>(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<T>(default, EtatReseau.MotifHorsLigne);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Remet l'instantané à jour sans faire attendre l'utilisateur.</summary>
|
||||
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<T>(string cle, T valeur)
|
||||
{
|
||||
var date = DateTimeOffset.Now;
|
||||
await cache.EcrireAsync(cle, valeur, date);
|
||||
reseau.SignalerSynchro(date);
|
||||
}
|
||||
|
||||
/// <summary>Relit un instantané et fait remonter sa date, celle que l'interface affiche.</summary>
|
||||
private async Task<Instantane<T>?> LireCacheAsync<T>(string cle)
|
||||
{
|
||||
var instantane = await cache.LireAsync<T>(cle);
|
||||
|
||||
if (instantane is not null)
|
||||
{
|
||||
reseau.SignalerSynchro(instantane.Date);
|
||||
}
|
||||
|
||||
return instantane;
|
||||
}
|
||||
|
||||
private static async Task<T?> LireAsync<T>(HttpResponseMessage reponse, CancellationToken ct)
|
||||
|
||||
@@ -833,4 +833,37 @@ body {
|
||||
background: #e6e6f5;
|
||||
color: #3b3b7a;
|
||||
font-weight: 600;
|
||||
/* --- 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;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
<script src="_framework/blazor.webassembly.js"></script>
|
||||
<script>navigator.serviceWorker.register('service-worker.js', { updateViaCache: 'none' });</script>
|
||||
<!-- Enregistrement du service worker déporté : il vérifie le support avant d'appeler
|
||||
navigator.serviceWorker (absent hors contexte sécurisé), journalise un échec au lieu de
|
||||
l'avaler, et propose la mise à jour quand une nouvelle version est prête. -->
|
||||
<script src="js/mise-a-jour.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
@@ -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$/ ];
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using MaBibli.Client.Services;
|
||||
using MaBibli.Shared.Catalogue;
|
||||
using MaBibli.Shared.Dtos;
|
||||
using MaBibli.Shared.Entites;
|
||||
|
||||
namespace MaBibli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Recherche hors-ligne : le filtre appliqué dans le navigateur à l'instantané IndexedDB.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// L'essentiel de ces tests confronte <see cref="FiltreLivresLocal"/> à
|
||||
/// <see cref="FiltreLivres"/>, 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.
|
||||
/// </remarks>
|
||||
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),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<CritereLivres> 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" }));
|
||||
}
|
||||
Reference in New Issue
Block a user