loko/streetup/controls/static/controls/offline-manager.js
2026-07-22 14:48:40 +02:00

672 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Gestion du stockage offline pour les remarques
class OfflineManager {
constructor() {
this.dbName = 'StreetUpOffline';
this.dbVersion = 2; // Incrémenté pour forcer la création des stores
this.db = null;
this.isOnline = navigator.onLine;
this.isReady = false;
// Seuils de qualité réseau en-dessous desquels le mode hors ligne est activé.
// Équivalent 4G faible : effectiveType != '4g', downlink < 0.5 Mbps ou rtt > 500 ms.
this.connectionThreshold = {
minEffectiveType: '4g', // '3g' et inférieurs déclenchent le mode offline
minDownlink: 0.5, // Mbps
maxRtt: 500 // ms
};
this.setupEventListeners();
this._evaluateConnectionQuality();
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => {
reject(request.error);
};
request.onsuccess = () => {
this.db = request.result;
// Vérifier que les stores existent
const hasRemarks = this.db.objectStoreNames.contains('pendingRemarks');
const hasPhotos = this.db.objectStoreNames.contains('pendingPhotos');
if (!hasRemarks || !hasPhotos) {
this.db.close();
// Supprimer et recréer
const deleteRequest = indexedDB.deleteDatabase(this.dbName);
deleteRequest.onsuccess = () => {
// Réessayer
this.init().then(resolve).catch(reject);
};
deleteRequest.onerror = () => reject(deleteRequest.error);
return;
}
this.isReady = true;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Store pour les remarques en attente
if (!db.objectStoreNames.contains('pendingRemarks')) {
const store = db.createObjectStore('pendingRemarks', {
keyPath: 'id',
autoIncrement: true
});
store.createIndex('timestamp', 'timestamp', { unique: false });
store.createIndex('controlId', 'controlId', { unique: false });
}
// Store pour les photos (blob)
if (!db.objectStoreNames.contains('pendingPhotos')) {
db.createObjectStore('pendingPhotos', {
keyPath: 'id',
autoIncrement: true
});
}
};
});
}
setupEventListeners() {
// Écouter les changements de connectivité réseau (binaire)
window.addEventListener('online', () => {
this._evaluateConnectionQuality();
if (this.isOnline) {
this.showNotification('Connexion rétablie', 'success');
this.syncPendingData();
} else {
this.showNotification('Connexion trop faible – mode hors ligne maintenu', 'warning');
}
});
window.addEventListener('offline', () => {
this.isOnline = false;
this.showNotification('Mode hors ligne activé', 'warning');
});
// Network Information API : détecter les changements de qualité réseau
const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (conn) {
conn.addEventListener('change', () => {
const wasOnline = this.isOnline;
this._evaluateConnectionQuality();
if (!wasOnline && this.isOnline) {
this.showNotification('Connexion suffisante – retour en ligne', 'success');
this.syncPendingData();
} else if (wasOnline && !this.isOnline) {
this.showNotification('Connexion trop faible – mode hors ligne activé', 'warning');
}
});
}
}
/**
* Retourne true si la connexion actuelle est en-dessous du seuil configuré.
* Utilise l'API Network Information si disponible, sinon retourne false (pas de dégradation).
*/
_isConnectionPoor() {
const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (!conn) return false; // API non disponible : conserver le comportement par défaut
const poorTypes = ['slow-2g', '2g', '3g'];
if (poorTypes.includes(conn.effectiveType)) return true;
if (conn.rtt > 0 && conn.rtt > this.connectionThreshold.maxRtt) return true;
if (conn.downlink > 0 && conn.downlink < this.connectionThreshold.minDownlink) return true;
return false;
}
/**
* Évalue la qualité de connexion et met à jour isOnline en conséquence.
* Dispatch un événement CustomEvent pour notifier les templates.
*/
_evaluateConnectionQuality() {
if (!navigator.onLine) {
this.isOnline = false;
return;
}
const isPoor = this._isConnectionPoor();
const wasOnline = this.isOnline;
this.isOnline = !isPoor;
if (!wasOnline && this.isOnline) {
window.dispatchEvent(new CustomEvent('effectiveOnline'));
} else if (wasOnline && !this.isOnline) {
window.dispatchEvent(new CustomEvent('effectiveOffline', { detail: { reason: 'poor_connection' } }));
}
}
// Sauvegarder une remarque localement
async saveRemarkOffline(remarkData, photos = []) {
if (!this.db || !this.isReady) {
throw new Error('IndexedDB non initialisé');
}
// IMPORTANT : Convertir les photos en blobs AVANT d'ouvrir la transaction
// pour éviter que la transaction expire
const photoRecords = [];
for (const photo of photos) {
const photoBlob = await this.fileToBlob(photo);
photoRecords.push({
filename: photo.name,
blob: photoBlob,
type: photo.type
});
}
// Maintenant ouvrir la transaction et faire toutes les opérations de manière synchrone
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['pendingRemarks', 'pendingPhotos'], 'readwrite');
const remarksStore = transaction.objectStore('pendingRemarks');
const photosStore = transaction.objectStore('pendingPhotos');
const remarkRecord = {
controlId: remarkData.controlId,
description: remarkData.description,
status: remarkData.status,
latitude: remarkData.latitude,
longitude: remarkData.longitude,
coord_x: remarkData.coord_x,
coord_y: remarkData.coord_y,
reference_plan: remarkData.reference_plan,
timestamp: Date.now(),
photoIds: []
};
// Gérer les erreurs de transaction
transaction.onerror = () => {
reject(transaction.error);
};
transaction.oncomplete = () => {
this.showNotification(
`Remarque sauvegardée localement. Elle sera synchronisée dès le retour du réseau.`,
'info'
);
resolve(remarkId);
};
// Sauvegarder les photos de manière synchrone
let photosAdded = 0;
let remarkId = null;
if (photoRecords.length > 0) {
photoRecords.forEach((photoRecord) => {
const photoRequest = photosStore.add(photoRecord);
photoRequest.onsuccess = () => {
remarkRecord.photoIds.push(photoRequest.result);
photosAdded++;
// Une fois toutes les photos ajoutées, ajouter la remarque
if (photosAdded === photoRecords.length) {
const remarkRequest = remarksStore.add(remarkRecord);
remarkRequest.onsuccess = () => {
remarkId = remarkRequest.result;
};
remarkRequest.onerror = () => reject(remarkRequest.error);
}
};
photoRequest.onerror = () => reject(photoRequest.error);
});
} else {
// Pas de photos, ajouter directement la remarque
const remarkRequest = remarksStore.add(remarkRecord);
remarkRequest.onsuccess = () => {
remarkId = remarkRequest.result;
};
remarkRequest.onerror = () => reject(remarkRequest.error);
}
});
}
async fileToBlob(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve(new Blob([e.target.result], { type: file.type }));
reader.readAsArrayBuffer(file);
});
}
addRecord(store, record) {
return new Promise((resolve, reject) => {
const request = store.add(record);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Synchroniser les données en attente
async syncPendingData() {
if (!this.isOnline) {
return;
}
const transaction = this.db.transaction(['pendingRemarks', 'pendingPhotos'], 'readonly');
const remarksStore = transaction.objectStore('pendingRemarks');
const remarks = await this.getAllRecords(remarksStore);
if (remarks.length === 0) {
return;
}
this.showNotification(
`Synchronisation de ${remarks.length} remarque(s) en cours...`,
'info'
);
let syncedCount = 0;
let failedCount = 0;
for (const remark of remarks) {
try {
await this.uploadRemark(remark);
await this.deleteRemark(remark.id);
syncedCount++;
} catch (error) {
failedCount++;
}
}
if (syncedCount > 0) {
this.showNotification(
`${syncedCount} remarque(s) synchronisée(s) avec succès`,
'success'
);
// Recharger la page pour afficher les nouvelles remarques
setTimeout(() => {
window.location.reload();
}, 1500);
}
if (failedCount > 0) {
this.showNotification(
`${failedCount} remarque(s) n'ont pas pu être synchronisées`,
'error'
);
}
}
async getAllRecords(store) {
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async uploadRemark(remark) {
const photosStore = this.db.transaction('pendingPhotos', 'readonly')
.objectStore('pendingPhotos');
// Récupérer les photos
const photos = [];
for (const photoId of remark.photoIds) {
const photo = await this.getRecord(photosStore, photoId);
if (photo) {
photos.push(new File([photo.blob], photo.filename, { type: photo.type }));
}
}
// Créer FormData
const formData = new FormData();
// IMPORTANT: Ajouter le paramètre submit_remark pour que le backend traite la requête
formData.append('submit_remark', 'true');
// Champ obligatoire : control ID
formData.append('control', remark.controlId);
formData.append('description', remark.description);
formData.append('status', remark.status);
formData.append('latitude', remark.latitude || '');
formData.append('longitude', remark.longitude || '');
formData.append('coord_x', remark.coord_x || '');
formData.append('coord_y', remark.coord_y || '');
formData.append('reference_plan', remark.reference_plan || '');
formData.append('categories', ''); // Catégories vides par défaut
// Ajouter les champs du management form pour le formset (quantity)
// Django s'attend à ces champs même si le formset est vide
formData.append('qty-TOTAL_FORMS', '0');
formData.append('qty-INITIAL_FORMS', '0');
formData.append('qty-MIN_NUM_FORMS', '0');
formData.append('qty-MAX_NUM_FORMS', '1000');
photos.forEach(photo => {
formData.append('images', photo);
});
// Envoyer au serveur
const csrftoken = this.getCookie('csrftoken');
const currentUrl = window.location.pathname;
const response = await fetch(currentUrl, {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
},
body: formData
});
if (!response.ok) {
throw new Error(`Erreur HTTP: ${response.status} - ${response.statusText}`);
}
// Supprimer les photos associées
const photosTransaction = this.db.transaction('pendingPhotos', 'readwrite');
const photosWriteStore = photosTransaction.objectStore('pendingPhotos');
for (const photoId of remark.photoIds) {
await this.deleteRecord(photosWriteStore, photoId);
}
return response;
}
async getRecord(store, id) {
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async deleteRemark(remarkId) {
const transaction = this.db.transaction('pendingRemarks', 'readwrite');
const store = transaction.objectStore('pendingRemarks');
return this.deleteRecord(store, remarkId);
}
deleteRecord(store, id) {
return new Promise((resolve, reject) => {
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
showNotification(message, type = 'info') {
// Créer un toast Bootstrap
const bgClass = type === 'success' ? 'success' :
type === 'error' ? 'danger' :
type === 'warning' ? 'warning' : 'info';
const toastHtml = `
<div class="toast align-items-center text-white bg-${bgClass} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
// Ajouter au container de toasts
let container = document.getElementById('toast-container');
if (!container) {
container = this.createToastContainer();
}
container.insertAdjacentHTML('beforeend', toastHtml);
const toastEl = container.lastElementChild;
if (typeof bootstrap !== 'undefined' && bootstrap.Toast) {
const toast = new bootstrap.Toast(toastEl, { delay: 5000 });
toast.show();
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
}
}
createToastContainer() {
const container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast-container position-fixed top-0 end-0 p-3';
container.style.zIndex = '9999';
document.body.appendChild(container);
return container;
}
// Obtenir le nombre de remarques en attente
async getPendingCount() {
if (!this.db) return 0;
const transaction = this.db.transaction('pendingRemarks', 'readonly');
const store = transaction.objectStore('pendingRemarks');
return new Promise((resolve, reject) => {
const request = store.count();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Obtenir toutes les remarques en attente pour un contrôle
async getPendingRemarks(controlId) {
if (!this.db) return [];
const transaction = this.db.transaction(['pendingRemarks', 'pendingPhotos'], 'readonly');
const remarksStore = transaction.objectStore('pendingRemarks');
const photosStore = transaction.objectStore('pendingPhotos');
const allRemarks = await this.getAllRecords(remarksStore);
const controlRemarks = allRemarks.filter(r => r.controlId == controlId);
// Charger les photos pour chaque remarque
for (const remark of controlRemarks) {
remark.photos = [];
for (const photoId of remark.photoIds || []) {
const photo = await this.getRecord(photosStore, photoId);
if (photo) {
// Créer une URL pour afficher la photo
remark.photos.push({
id: photoId,
url: URL.createObjectURL(photo.blob),
filename: photo.filename
});
}
}
}
return controlRemarks;
}
// Mettre à jour une remarque offline
async updateRemarkOffline(remarkId, remarkData, photos = []) {
if (!this.db || !this.isReady) {
throw new Error('IndexedDB non initialisé');
}
// Convertir les nouvelles photos en blobs
const photoRecords = [];
for (const photo of photos) {
const photoBlob = await this.fileToBlob(photo);
photoRecords.push({
filename: photo.name,
blob: photoBlob,
type: photo.type
});
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(['pendingRemarks', 'pendingPhotos'], 'readwrite');
const remarksStore = transaction.objectStore('pendingRemarks');
const photosStore = transaction.objectStore('pendingPhotos');
transaction.onerror = () => reject(transaction.error);
transaction.oncomplete = () => {
this.showNotification('Remarque mise à jour localement', 'info');
resolve();
};
// Récupérer la remarque existante
const getRequest = remarksStore.get(remarkId);
getRequest.onsuccess = () => {
const existingRemark = getRequest.result;
if (!existingRemark) {
reject(new Error('Remarque non trouvée'));
return;
}
// Mettre à jour les données
existingRemark.description = remarkData.description;
existingRemark.status = remarkData.status;
existingRemark.latitude = remarkData.latitude;
existingRemark.longitude = remarkData.longitude;
existingRemark.coord_x = remarkData.coord_x;
existingRemark.coord_y = remarkData.coord_y;
existingRemark.reference_plan = remarkData.reference_plan;
// Ajouter les nouvelles photos
let photosAdded = 0;
if (photoRecords.length > 0) {
photoRecords.forEach((photoRecord) => {
const photoRequest = photosStore.add(photoRecord);
photoRequest.onsuccess = () => {
existingRemark.photoIds.push(photoRequest.result);
photosAdded++;
if (photosAdded === photoRecords.length) {
remarksStore.put(existingRemark);
}
};
});
} else {
remarksStore.put(existingRemark);
}
};
getRequest.onerror = () => reject(getRequest.error);
});
}
// Supprimer une remarque offline
async deleteRemarkOffline(remarkId) {
if (!this.db || !this.isReady) {
throw new Error('IndexedDB non initialisé');
}
const transaction = this.db.transaction(['pendingRemarks', 'pendingPhotos'], 'readwrite');
const remarksStore = transaction.objectStore('pendingRemarks');
const photosStore = transaction.objectStore('pendingPhotos');
return new Promise((resolve, reject) => {
// Récupérer la remarque pour obtenir les photoIds
const getRequest = remarksStore.get(remarkId);
getRequest.onsuccess = async () => {
const remark = getRequest.result;
if (remark && remark.photoIds) {
// Supprimer les photos associées
for (const photoId of remark.photoIds) {
await this.deleteRecord(photosStore, photoId);
}
}
// Supprimer la remarque
const deleteRequest = remarksStore.delete(remarkId);
deleteRequest.onsuccess = () => {
this.showNotification('Remarque supprimée localement', 'info');
resolve();
};
deleteRequest.onerror = () => reject(deleteRequest.error);
};
getRequest.onerror = () => reject(getRequest.error);
});
}
// Supprimer une photo d'une remarque offline
async deletePhotoOffline(remarkId, photoId) {
if (!this.db || !this.isReady) {
throw new Error('IndexedDB non initialisé');
}
const transaction = this.db.transaction(['pendingRemarks', 'pendingPhotos'], 'readwrite');
const remarksStore = transaction.objectStore('pendingRemarks');
const photosStore = transaction.objectStore('pendingPhotos');
return new Promise((resolve, reject) => {
const getRequest = remarksStore.get(remarkId);
getRequest.onsuccess = () => {
const remark = getRequest.result;
if (!remark) {
reject(new Error('Remarque non trouvée'));
return;
}
// Retirer l'ID de la photo
remark.photoIds = remark.photoIds.filter(id => id !== photoId);
// Mettre à jour la remarque
const putRequest = remarksStore.put(remark);
putRequest.onsuccess = () => {
// Supprimer la photo
photosStore.delete(photoId);
resolve();
};
putRequest.onerror = () => reject(putRequest.error);
};
getRequest.onerror = () => reject(getRequest.error);
});
}
}
// Instance globale
let offlineManager = null;
let offlineManagerReady = false;
// Initialiser immédiatement, pas dans DOMContentLoaded
(async function initOfflineManager() {
try {
offlineManager = new OfflineManager();
// Attendre que la DB soit initialisée
await offlineManager.init();
offlineManagerReady = true;
// Tenter une synchronisation au chargement si en ligne
if (navigator.onLine) {
setTimeout(() => {
if (offlineManager) {
offlineManager.syncPendingData();
}
}, 1000);
}
// Mettre à jour le badge après initialisation
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
if (typeof updatePendingBadge === 'function') {
updatePendingBadge();
}
});
} else {
if (typeof updatePendingBadge === 'function') {
updatePendingBadge();
}
}
} catch (error) {
console.error('Erreur initialisation offline manager:', error);
}
})();