diff --git a/public/sw.js b/public/sw.js index 55e8f47..f9c13ef 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ -const CACHE_NAME = "stripstream-cache-v1"; -const IMAGES_CACHE_NAME = "stripstream-images-v1"; +const CACHE_NAME = "stripstream-cache-v3"; +const IMAGES_CACHE_NAME = "stripstream-images-v3"; const OFFLINE_PAGE = "/offline.html"; const STATIC_ASSETS = [ @@ -10,6 +10,16 @@ const STATIC_ASSETS = [ "/images/icons/icon-512x512.png", ]; +// Fonction pour obtenir l'URL de base sans les query params +const getBaseUrl = (url) => { + try { + const urlObj = new URL(url); + return urlObj.origin + urlObj.pathname; + } catch { + return url; + } +}; + // Installation du service worker self.addEventListener("install", (event) => { event.waitUntil( @@ -20,16 +30,58 @@ self.addEventListener("install", (event) => { ); }); +// Fonction pour nettoyer les doublons dans un cache +const cleanDuplicatesInCache = async (cacheName) => { + const cache = await caches.open(cacheName); + const keys = await cache.keys(); + + // Grouper par URL de base + const grouped = {}; + for (const key of keys) { + const baseUrl = getBaseUrl(key.url); + if (!grouped[baseUrl]) { + grouped[baseUrl] = []; + } + grouped[baseUrl].push(key); + } + + // Pour chaque groupe, garder seulement la version la plus récente + const deletePromises = []; + for (const baseUrl in grouped) { + const versions = grouped[baseUrl]; + if (versions.length > 1) { + // Trier par query params (version) décroissant + versions.sort((a, b) => { + const aVersion = new URL(a.url).searchParams.get('v') || '0'; + const bVersion = new URL(b.url).searchParams.get('v') || '0'; + return Number(bVersion) - Number(aVersion); + }); + // Supprimer toutes sauf la première (plus récente) + for (let i = 1; i < versions.length; i++) { + deletePromises.push(cache.delete(versions[i])); + } + } + } + + await Promise.all(deletePromises); +}; + // Activation et nettoyage des anciens caches self.addEventListener("activate", (event) => { event.waitUntil( - caches.keys().then((cacheNames) => { - return Promise.all( - cacheNames - .filter((name) => name !== CACHE_NAME && name !== IMAGES_CACHE_NAME) - .map((name) => caches.delete(name)) - ); - }) + Promise.all([ + // Supprimer les anciens caches + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME && name !== IMAGES_CACHE_NAME) + .map((name) => caches.delete(name)) + ); + }), + // Nettoyer les doublons dans les caches actuels + cleanDuplicatesInCache(CACHE_NAME), + cleanDuplicatesInCache(IMAGES_CACHE_NAME), + ]) ); }); @@ -57,6 +109,22 @@ const isImageResource = (url) => { ); }; +// Fonction pour nettoyer les anciennes versions d'un fichier +const cleanOldVersions = async (cacheName, request) => { + const cache = await caches.open(cacheName); + const baseUrl = getBaseUrl(request.url); + + // Récupérer toutes les requêtes en cache + const keys = await cache.keys(); + + // Supprimer toutes les requêtes qui ont la même URL de base + const deletePromises = keys + .filter(key => getBaseUrl(key.url) === baseUrl) + .map(key => cache.delete(key)); + + await Promise.all(deletePromises); +}; + // Stratégie Cache-First pour les images const imageCacheStrategy = async (request) => { const cache = await caches.open(IMAGES_CACHE_NAME); @@ -113,16 +181,30 @@ self.addEventListener("fetch", (event) => { // Pour les ressources statiques de Next.js et les autres requêtes : Network-First event.respondWith( fetch(event.request) - .then((response) => { + .then(async (response) => { // Mettre en cache les ressources statiques de Next.js et les pages if ( response.ok && (isNextStaticResource(event.request.url) || event.request.mode === "navigate") ) { const responseToCache = response.clone(); - caches.open(CACHE_NAME).then((cache) => { - cache.put(event.request, responseToCache); - }); + const cache = await caches.open(CACHE_NAME); + + // Nettoyer les anciennes versions avant de mettre en cache la nouvelle + if (isNextStaticResource(event.request.url)) { + try { + await cleanOldVersions(CACHE_NAME, event.request); + } catch (error) { + console.warn("Error cleaning old versions:", error); + } + } + + // Mettre en cache la nouvelle version + try { + await cache.put(event.request, responseToCache); + } catch (error) { + console.warn("Error caching response:", error); + } } return response; }) diff --git a/src/components/settings/CacheSettings.tsx b/src/components/settings/CacheSettings.tsx index 8d7878d..ee2e205 100644 --- a/src/components/settings/CacheSettings.tsx +++ b/src/components/settings/CacheSettings.tsx @@ -47,6 +47,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { const [isLoadingSwEntries, setIsLoadingSwEntries] = useState(false); const [showSwEntries, setShowSwEntries] = useState(false); const [expandedGroups, setExpandedGroups] = useState>({}); + const [expandedVersions, setExpandedVersions] = useState>({}); const [ttlConfig, setTTLConfig] = useState( initialTTLConfig || { defaultTTL: 5, @@ -197,7 +198,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { setShowSwEntries(!showSwEntries); }; - const getFirstPathSegment = (url: string): string => { + const getPathGroup = (url: string): string => { try { const urlObj = new URL(url); const path = urlObj.pathname; @@ -205,16 +206,56 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { if (segments.length === 0) return '/'; + // Pour /api/komga/images, grouper par type (series/books) + if (segments[0] === 'api' && segments[1] === 'komga' && segments[2] === 'images' && segments[3]) { + return `/${segments[0]}/${segments[1]}/${segments[2]}/${segments[3]}`; + } + + // Pour les autres, garder juste le premier segment return `/${segments[0]}`; } catch { return 'Autres'; } }; + const getBaseUrl = (url: string): string => { + try { + const urlObj = new URL(url); + return urlObj.pathname; + } catch { + return url; + } + }; + + const groupVersions = (entries: ServiceWorkerCacheEntry[]) => { + const grouped = entries.reduce( + (acc, entry) => { + const baseUrl = getBaseUrl(entry.url); + if (!acc[baseUrl]) { + acc[baseUrl] = []; + } + acc[baseUrl].push(entry); + return acc; + }, + {} as Record + ); + + // Trier par date (le plus récent en premier) basé sur le paramètre v + Object.keys(grouped).forEach((key) => { + grouped[key].sort((a, b) => { + const aVersion = new URL(a.url).searchParams.get('v') || '0'; + const bVersion = new URL(b.url).searchParams.get('v') || '0'; + return Number(bVersion) - Number(aVersion); + }); + }); + + return grouped; + }; + const groupEntriesByPath = (entries: ServiceWorkerCacheEntry[]) => { const grouped = entries.reduce( (acc, entry) => { - const pathGroup = getFirstPathSegment(entry.url); + const pathGroup = getPathGroup(entry.url); if (!acc[pathGroup]) { acc[pathGroup] = []; } @@ -229,7 +270,19 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { grouped[key].sort((a, b) => b.size - a.size); }); - return grouped; + // Trier les groupes par taille totale décroissante + const sortedGroups: Record = {}; + Object.entries(grouped) + .sort((a, b) => { + const aSize = getTotalSizeByType(a[1]); + const bSize = getTotalSizeByType(b[1]); + return bSize - aSize; + }) + .forEach(([key, value]) => { + sortedGroups[key] = value; + }); + + return sortedGroups; }; const getTotalSizeByType = (entries: ServiceWorkerCacheEntry[]): number => { @@ -243,6 +296,13 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { })); }; + const toggleVersions = (fileName: string) => { + setExpandedVersions((prev) => ({ + ...prev, + [fileName]: !prev[fileName], + })); + }; + useEffect(() => { fetchCacheSize(); }, []); @@ -291,6 +351,13 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { if ("serviceWorker" in navigator && "caches" in window) { const cacheNames = await caches.keys(); await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName))); + + // Forcer la mise à jour du service worker + const registrations = await navigator.serviceWorker.getRegistrations(); + for (const registration of registrations) { + await registration.unregister(); + } + toast({ title: t("settings.cache.title"), description: t("settings.cache.messages.serviceWorkerCleared"), @@ -304,6 +371,11 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { if (showSwEntries) { await fetchSwCacheEntries(); } + + // Recharger la page après 1 seconde pour réenregistrer le SW + setTimeout(() => { + window.location.reload(); + }, 1000); } } catch (error) { console.error("Erreur lors de la suppression des caches:", error); @@ -546,20 +618,75 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { {isExpanded && (
- {entries.map((entry, index) => ( -
-
-
-
- {entry.url.replace(/^https?:\/\/[^/]+/, "")} + {(() => { + const versionGroups = groupVersions(entries); + return Object.entries(versionGroups).map(([baseUrl, versions]) => { + const hasMultipleVersions = versions.length > 1; + const isVersionExpanded = expandedVersions[baseUrl]; + const totalSize = versions.reduce((sum, v) => sum + v.size, 0); + + if (!hasMultipleVersions) { + const entry = versions[0]; + return ( +
+
+
+
+ {entry.url.replace(/^https?:\/\/[^/]+/, "")} +
+
+
+ {formatBytes(entry.size)} +
+
+ ); + } + + return ( +
+ + {isVersionExpanded && ( +
+ {versions.map((version, vIdx) => ( +
+
+
+ {new URL(version.url).search || "(no version)"} +
+
+
+ {formatBytes(version.size)} +
+
+ ))} +
+ )}
-
- {formatBytes(entry.size)} -
-
-
- ))} + ); + }); + })()}
)}