- Cataloguer en rafale — nouvel écran /ajout/rafale : on scanne une pile de livres à la suite dans une zone de texte, chaque code est traité (BnF puis OpenLibrary), les doublons connus sont passés automatiquement. La collecte marche même hors-ligne. Le compte rendu liste maintenant les livres créés, en lien vers leur fiche, et reste consultable en revenant sur l'écran même après une rafale entièrement réussie. Le catalogue reconnaît un ISBN dans sa barre de recherche (13 ou 10 chiffres, avec ou sans tirets) : scanner un livre en main ouvre directement sa fiche s'il n'y en a qu'un. Un bouton « Scanner » l'alimente, actif hors-ligne. - Ajouter un tome à une série accepte aussi un ISBN dans le champ manuel : le catalogue est cherché d'abord (rattachement direct si un seul exemplaire), sinon la BnF prend le relais. Séries et sagas - Numéro de tome distinct de la position de lecture : on peut indiquer « c'est le tome 7 » même si on ne possède pas les six premiers ; l'ordre de lecture reste un réglage séparé (utile pour les préquelles). - Tri par numéro en plus du tri par ordre de lecture quand des tomes en portent un. - Panneau « Ajouter » regroupé et repliable sur la fiche d'une série (manuellement / en rafale / depuis le catalogue / depuis les envies), au lieu de quatre formulaires ouverts en permanence. - Filtre catalogue « sans couverture » pour repérer les livres à illustrer. Le catalogue groupe les tomes d'une même série sous un bloc repliable, avec un décompte plus clair (affichés / possédés / total). - Corrections directes sur la fiche - Effacer un prêt saisi par erreur (bouton ✕ sur chaque ligne, avec confirmation), sans passer par « rendre ». - Corriger une couverture manquante ou cassée en cliquant dessus : le champ d'adresse s'ouvre focalisé, Entrée enregistre. Étendu aux numéros de revue dans la dernière modification. - Les thèmes déjà utilisés dans la bibliothèque sont proposés à la frappe. - La recherche d'un livre à rattacher montre des suggestions dès le focus, sans attendre de taper. - Visuel : un rendu manquant après une écriture asynchrone dans le formulaire de livre, une bascule de rôle cassée, des débordements à 320 px, et le style d'un bouton-lien qui restait souligné.
212 lines
10 KiB
JavaScript
212 lines
10 KiB
JavaScript
// 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';
|
|
// File de catalogage en rafale (lot AB). ⚠️ MAGASIN SÉPARÉ, et ce n'est pas un détail de
|
|
// rangement : `instantanes` porte ce que le SERVEUR a rendu, donc effaçable sans rien perdre,
|
|
// alors que cette file porte ce que l'UTILISATEUR a scanné et qui n'existe nulle part ailleurs.
|
|
// Les mêler ferait disparaître cinquante scans le jour où l'on ajouterait un « vider le cache ».
|
|
const MAGASIN_RAFALE = 'rafale';
|
|
const VERSION = 3;
|
|
|
|
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);
|
|
if (!base.objectStoreNames.contains(MAGASIN_RAFALE)) base.createObjectStore(MAGASIN_RAFALE);
|
|
};
|
|
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.
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// File de catalogue en rafale (lot AB)
|
|
// ---------------------------------------------------------------------------
|
|
// Une seule entrée, sous une clé fixe : il n'y a jamais qu'une rafale en cours. La valeur est
|
|
// la chaîne JSON produite par C#, comme pour les instantanés — une seule forme fait autorité.
|
|
|
|
const CLE_RAFALE = 'en-cours';
|
|
|
|
export async function rafaleEcrire(json) {
|
|
const base = await ouvrir();
|
|
const tx = base.transaction(MAGASIN_RAFALE, 'readwrite');
|
|
const requete = tx.objectStore(MAGASIN_RAFALE).put(json, CLE_RAFALE);
|
|
await attendre(requete, tx);
|
|
}
|
|
|
|
export async function rafaleLire() {
|
|
const base = await ouvrir();
|
|
const tx = base.transaction(MAGASIN_RAFALE, 'readonly');
|
|
const valeur = await attendre(tx.objectStore(MAGASIN_RAFALE).get(CLE_RAFALE), tx);
|
|
return valeur ?? null;
|
|
}
|
|
|
|
export async function rafaleEffacer() {
|
|
const base = await ouvrir();
|
|
const tx = base.transaction(MAGASIN_RAFALE, 'readwrite');
|
|
await attendre(tx.objectStore(MAGASIN_RAFALE).delete(CLE_RAFALE), tx);
|
|
}
|