diff --git a/CLAUDE.md b/CLAUDE.md index 617d844..16131ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`, 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. diff --git a/MaBibli.Client/Composants/FormulaireLivre.razor b/MaBibli.Client/Composants/FormulaireLivre.razor index 3908117..f266f12 100644 --- a/MaBibli.Client/Composants/FormulaireLivre.razor +++ b/MaBibli.Client/Composants/FormulaireLivre.razor @@ -84,9 +84,17 @@ } + @* 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)) + { +

@MessageBlocage

+ } +
@@ -112,6 +120,15 @@ [Parameter] public bool EnCours { get; set; } + /// + /// 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. + /// + [Parameter] public string? MessageBlocage { get; set; } + + private bool Bloque => !string.IsNullOrEmpty(MessageBlocage); + [Parameter] public EventCallback OnValider { get; set; } [Parameter] public EventCallback OnAnnuler { get; set; } diff --git a/MaBibli.Client/Composants/PretsLivre.razor b/MaBibli.Client/Composants/PretsLivre.razor index e5dd8d8..c9e2dc5 100644 --- a/MaBibli.Client/Composants/PretsLivre.razor +++ b/MaBibli.Client/Composants/PretsLivre.razor @@ -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.

} + 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) + { +

+ Prêté + à @absent + @if (PreteDepuis is { } depuis) + { + @($" depuis le {Jour(depuis)}") + } +

+ } + else + { +

Ce livre était à la maison à la dernière synchronisation.

+ } + +

+ Hors ligne : l'historique des prêts n'est pas consultable, et prêter ou marquer un + retour demande le réseau. +

+ } else if (_prets is null) {

Chargement…

@@ -115,6 +149,15 @@ /// Format du livre : un ebook ne se prête pas. [Parameter] public Format Format { get; set; } + /// + /// Emprunteur actuel d'après la fiche. Sert hors-ligne, où l'historique n'est pas + /// consultable mais où l'état courant, lui, vient de l'instantané du catalogue. + /// + [Parameter] public string? PreteA { get; set; } + + /// Date du prêt en cours, même origine que . + [Parameter] public DateTime? PreteDepuis { get; set; } + /// Prévient la page parente qu'un prêt a changé, pour qu'elle rafraîchisse sa fiche. [Parameter] public EventCallback OnChangement { get; set; } @@ -131,6 +174,21 @@ private IReadOnlyList Historique => _prets?.Where(p => !p.EstEnCours).ToList() ?? []; + protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + + /// Au retour du réseau, l'historique devient consultable : on va le chercher. + 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) ?? []; diff --git a/MaBibli.Client/Layout/MainLayout.razor b/MaBibli.Client/Layout/MainLayout.razor index d27de9c..f740d01 100644 --- a/MaBibli.Client/Layout/MainLayout.razor +++ b/MaBibli.Client/Layout/MainLayout.razor @@ -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. *@
MaBibli + @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. *@ + Hors ligne + } + @if (_utilisateur?.Identifiant is not null) { @@ -14,13 +23,105 @@ }
+@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. + *@ +

+ Hors ligne. + @(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. +

+} +
@Body
@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(); + } + + /// + /// 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. + /// + 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; + } + } + + /// Date de synchronisation en clair : l'heure suffit le jour même. + 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}"; + } + + /// La date affichée vient de changer : rien à recharger, juste à redessiner. + private void SurSynchro() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() + { + Reseau.Change -= SurChangementReseau; + Reseau.SynchroChange -= SurSynchro; + } } diff --git a/MaBibli.Client/Layout/MainLayout.razor.css b/MaBibli.Client/Layout/MainLayout.razor.css index 252cba0..be2e48e 100644 --- a/MaBibli.Client/Layout/MainLayout.razor.css +++ b/MaBibli.Client/Layout/MainLayout.razor.css @@ -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%; diff --git a/MaBibli.Client/Pages/AjoutIsbn.razor b/MaBibli.Client/Pages/AjoutIsbn.razor index 114ae9f..bd85885 100644 --- a/MaBibli.Client/Pages/AjoutIsbn.razor +++ b/MaBibli.Client/Pages/AjoutIsbn.razor @@ -1,6 +1,8 @@ @page "/ajout/isbn" @inject ServiceLivresApi Api @inject NavigationManager Navigation +@inject EtatReseau Reseau +@implements IDisposable MaBibli — ajouter par ISBN @@ -12,6 +14,19 @@ Scannez le code-barres, ou saisissez l'ISBN imprimé sur le livre.

+ @* + 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) + { +

+ 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. +

+ } +
@* 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. *@ - Saisir à la main @@ -108,6 +126,7 @@ LibelleValidation="Ajouter au catalogue" Erreur="@_erreurFormulaire" EnCours="_enregistrement" + MessageBlocage="@MotifBlocage" OnValider="EnregistrerAsync" OnAnnuler="Recommencer" /> } @@ -125,6 +144,15 @@ private IReadOnlyList _avertissements = []; private EnregistrementLivre _saisie = new(); + protected override void OnInitialized() => Reseau.Change += SurChangementReseau; + + private void SurChangementReseau() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() => Reseau.Change -= SurChangementReseau; + + /// Raison du blocage des actions, ou null quand tout est possible. + private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; + private void OuvrirScanner() { _erreur = null; diff --git a/MaBibli.Client/Pages/AjoutManuel.razor b/MaBibli.Client/Pages/AjoutManuel.razor index 7c70759..4cf27ef 100644 --- a/MaBibli.Client/Pages/AjoutManuel.razor +++ b/MaBibli.Client/Pages/AjoutManuel.razor @@ -1,6 +1,8 @@ @page "/ajout/manuel" @inject ServiceLivresApi Api @inject NavigationManager Navigation +@inject EtatReseau Reseau +@implements IDisposable MaBibli — saisie manuelle @@ -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; diff --git a/MaBibli.Client/Pages/Auteurs.razor b/MaBibli.Client/Pages/Auteurs.razor index cb72cc4..4fa749a 100644 --- a/MaBibli.Client/Pages/Auteurs.razor +++ b/MaBibli.Client/Pages/Auteurs.razor @@ -1,5 +1,7 @@ @page "/auteurs" @inject ServiceLivresApi Api +@inject EtatReseau Reseau +@implements IDisposable MaBibli — auteurs @@ -37,11 +39,14 @@

- - @@ -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; + + /// Raison du blocage des fusions, ou null en ligne. + private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne; + protected override Task OnInitializedAsync() => ChargerAsync(); private static string Livres(int nombre) => $"{nombre} livre{(nombre > 1 ? "s" : "")}"; diff --git a/MaBibli.Client/Pages/Catalogue.razor b/MaBibli.Client/Pages/Catalogue.razor index 7f9c907..a20f22a 100644 --- a/MaBibli.Client/Pages/Catalogue.razor +++ b/MaBibli.Client/Pages/Catalogue.razor @@ -1,5 +1,6 @@ @page "/" @inject ServiceLivresApi Api +@inject EtatReseau Reseau @implements IDisposable MaBibli — catalogue @@ -121,9 +122,22 @@ else if (_livres is not null) } +@* 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. *@
- + + diff --git a/MaBibli.Client/wwwroot/js/cache-hors-ligne.js b/MaBibli.Client/wwwroot/js/cache-hors-ligne.js new file mode 100644 index 0000000..d062bae --- /dev/null +++ b/MaBibli.Client/wwwroot/js/cache-hors-ligne.js @@ -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; +} diff --git a/MaBibli.Client/wwwroot/js/mise-a-jour.js b/MaBibli.Client/wwwroot/js/mise-a-jour.js new file mode 100644 index 0000000..0c445e8 --- /dev/null +++ b/MaBibli.Client/wwwroot/js/mise-a-jour.js @@ -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(); + }); +})(); diff --git a/MaBibli.Client/wwwroot/service-worker.published.js b/MaBibli.Client/wwwroot/service-worker.published.js index 51a0e5c..ae8705a 100644 --- a/MaBibli.Client/wwwroot/service-worker.published.js +++ b/MaBibli.Client/wwwroot/service-worker.published.js @@ -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$/ ]; diff --git a/MaBibli.Tests/FiltreLivresLocalTests.cs b/MaBibli.Tests/FiltreLivresLocalTests.cs new file mode 100644 index 0000000..e271dcb --- /dev/null +++ b/MaBibli.Tests/FiltreLivresLocalTests.cs @@ -0,0 +1,155 @@ +using MaBibli.Client.Services; +using MaBibli.Shared.Catalogue; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; + +namespace MaBibli.Tests; + +/// +/// Recherche hors-ligne : le filtre appliqué dans le navigateur à l'instantané IndexedDB. +/// +/// +/// L'essentiel de ces tests confronte à +/// , 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. +/// +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), + ]; + + /// + /// 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. + /// + 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 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" })); +}