MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts, scan de code-barres, consultation hors-ligne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
// 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'<img> 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'<img> 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'<img>) : 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 <img> 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'<img> 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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user