diff --git a/CLAUDE.md b/CLAUDE.md index 95d3d17..b89ab60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,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) @@ -152,6 +152,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). @@ -290,6 +379,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 | @@ -544,7 +695,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 @@ -556,3 +707,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.