// 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'; // Couvertures mises en cache (A5, IDEES.md) : magasin séparé, clé = l'URL OpenLibrary, // valeur = le Blob de l'image. Sert uniquement à la consultation hors-ligne — en ligne, // l' normale reste la voie rapide, ce cache se remplit en tâche de fond à côté. const MAGASIN_COUVERTURES = 'couvertures'; const VERSION = 2; 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); if (!base.objectStoreNames.contains(MAGASIN_COUVERTURES)) base.createObjectStore(MAGASIN_COUVERTURES); }; 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; } // --- Couvertures (A5, IDEES.md) --- // // En ligne, l' normale reste la voie d'affichage : rien ici ne doit la ralentir. Cette // fonction tourne en tâche de fond, appelée sans attendre son résultat (fire-and-forget) après // qu'une couverture s'est affichée avec succès. Le second fetch profite en général du cache HTTP // du navigateur (même URL que l') : pas de second téléchargement réel dans le cas courant. // // OpenLibrary envoie `Access-Control-Allow-Origin: *` (vérifié) : un fetch cross-origin normal // suffit, pas besoin du contournement `no-cors`/réponse opaque. // // ⚠️ Mais le formulaire livre accepte N'IMPORTE QUELLE URL de couverture, et la plupart des // hébergeurs n'envoient aucun en-tête CORS. Ces images s'affichaient bien — une n'a que // faire du CORS — sans jamais pouvoir être mises en cache, et le fetch repartait à CHAQUE // affichage puisque rien n'était jamais rangé. D'où le repli par `/api/couvertures`, qui relaie // l'image en MÊME ORIGINE (voir ServiceCouvertures côté serveur, et ses deux verrous anti-SSRF). // // ⚠️ L'ordre compte : on tente d'abord l'URL directe, qui profite du cache HTTP du navigateur // (même URL que l' déjà chargée) et n'impose rien à notre serveur. Le relais n'entre en jeu // que pour ce que le CORS a bloqué. Une réponse opaque (`mode: 'no-cors'`) ne conviendrait pas : // son corps est illisible, donc impossible à ranger en IndexedDB. export async function couvertureMettreEnCache(url) { try { const base = await ouvrir(); const tx = base.transaction(MAGASIN_COUVERTURES, 'readonly'); const existe = await attendre(tx.objectStore(MAGASIN_COUVERTURES).getKey(url), tx); if (existe !== undefined) return; // Déjà en cache : pas de re-téléchargement. let reponse = null; try { reponse = await fetch(url, { cache: 'force-cache' }); } catch (eDirect) { // Bloqué par le CORS, ou hôte injoignable : on ne sait pas lequel, et peu importe. } // ⚠️ Le relais ne rattrape QU'UN ACCÈS EMPÊCHÉ, jamais une image absente. Un 404 (ou un // 410) est une réponse : l'hôte a parlé, et il a dit qu'il n'avait rien — or le relais // ira chercher la MÊME url, il ne peut donc pas faire mieux. L'appeler quand même // produit un second 404, de notre serveur cette fois, sur toute couverture // qu'OpenLibrary ne connaît pas (« ?default=false » les rend franches, c'est voulu). // C'est ce qui se voyait en console sur la bibliographie d'un auteur, à chaque // affichage puisque rien ne se met jamais en cache. const absente = reponse !== null && (reponse.status === 404 || reponse.status === 410); if (!absente && (!reponse || !reponse.ok)) { reponse = await fetch('/api/couvertures?url=' + encodeURIComponent(url)); } // 404/502 intermittent d'OpenLibrary (CLAUDE.md), ou relais refusé : rien à ranger. if (!reponse.ok) return; const blob = await reponse.blob(); const ecriture = base.transaction(MAGASIN_COUVERTURES, 'readwrite'); await attendre(ecriture.objectStore(MAGASIN_COUVERTURES).put(blob, url), ecriture); } catch (e) { // Quota dépassé, stockage refusé : une couverture non mise en cache reste un // agrément perdu, jamais une raison de faire échouer l'affichage en ligne. } } // Renvoie une URL d'objet (`blob:`) utilisable comme `src`, ou `null` si cette couverture n'a // jamais été mise en cache — c'est alors à l'appelant de retomber sur l'URL réseau (qui échouera // simplement si hors-ligne, exactement comme aujourd'hui). export async function couvertureLire(url) { try { const base = await ouvrir(); const tx = base.transaction(MAGASIN_COUVERTURES, 'readonly'); const blob = await attendre(tx.objectStore(MAGASIN_COUVERTURES).get(url), tx); return blob ? URL.createObjectURL(blob) : null; } catch (e) { return null; } } // Libère une URL d'objet rendue par `couvertureLire`. ⚠️ Indispensable depuis que le cache sert // AUSSI en ligne : chaque `createObjectURL` retient son Blob en mémoire jusqu'à ce qu'on le // révoque ou que la page se ferme. Hors-ligne seul, la fuite restait bornée à une consultation // ponctuelle ; sur toutes les vignettes de tous les écrans, elle ne l'est plus. export function couvertureLiberer(url) { try { if (url) URL.revokeObjectURL(url); } catch (e) { // Rien à faire : libérer est un agrément, jamais une condition de bon fonctionnement. } }