From bc3da12fbbf5f096c0c7ead036d242b640aed22a Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sun, 19 Oct 2025 20:23:37 +0200 Subject: [PATCH] feat: enhance service worker caching strategies and implement offline accessibility checks for books --- public/sw.js | 420 +++++++++++----------- src/components/home/MediaRow.tsx | 19 +- src/components/series/BookGrid.tsx | 84 +++-- src/components/settings/CacheSettings.tsx | 19 +- src/components/ui/book-cover.tsx | 17 +- src/hooks/useBookOfflineStatus.ts | 86 +++++ src/i18n/messages/en/common.json | 6 +- src/i18n/messages/fr/common.json | 6 +- 8 files changed, 417 insertions(+), 240 deletions(-) create mode 100644 src/hooks/useBookOfflineStatus.ts diff --git a/public/sw.js b/public/sw.js index f9c13ef..e4fbc52 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,230 +1,240 @@ -const CACHE_NAME = "stripstream-cache-v3"; -const IMAGES_CACHE_NAME = "stripstream-images-v3"; +// StripStream Service Worker - Version 1 +// Architecture: Cache-as-you-go with Stale-While-Revalidate for data + +const VERSION = "v1"; +const STATIC_CACHE = `stripstream-static-${VERSION}`; +const IMAGES_CACHE = `stripstream-images-${VERSION}`; +const DATA_CACHE = `stripstream-data-${VERSION}`; +const RSC_CACHE = `stripstream-rsc-${VERSION}`; +const BOOKS_CACHE = "stripstream-books"; // Never version this - managed by DownloadManager + const OFFLINE_PAGE = "/offline.html"; +const PRECACHE_ASSETS = [OFFLINE_PAGE, "/manifest.json"]; -const STATIC_ASSETS = [ - "/offline.html", - "/manifest.json", - "/favicon.svg", - "/images/icons/icon-192x192.png", - "/images/icons/icon-512x512.png", -]; +// ============================================================================ +// Utility Functions - Request Detection +// ============================================================================ -// 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; - } -}; +function isNextStaticResource(url) { + return url.includes("/_next/static/"); +} -// Installation du service worker -self.addEventListener("install", (event) => { - event.waitUntil( - Promise.all([ - caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)), - caches.open(IMAGES_CACHE_NAME), - ]) - ); -}); +function isImageRequest(url) { + return url.includes("/api/komga/images/"); +} -// Fonction pour nettoyer les doublons dans un cache -const cleanDuplicatesInCache = async (cacheName) => { +function isApiDataRequest(url) { + return url.includes("/api/komga/") && !isImageRequest(url); +} + +function isNextRSCRequest(request) { + const url = new URL(request.url); + return url.searchParams.has("_rsc") || request.headers.get("RSC") === "1"; +} + +function shouldCacheApiData(url) { + // Exclude dynamic/auth endpoints that should always be fresh + return !url.includes("/api/auth/session") && !url.includes("/api/preferences"); +} + +// ============================================================================ +// Cache Strategies +// ============================================================================ + +/** + * Cache-First: Serve from cache, fallback to network + * Used for: Images, Next.js static resources + */ +async function cacheFirstStrategy(request, cacheName, options = {}) { 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); -}; + const cached = await cache.match(request, options); -// Activation et nettoyage des anciens caches -self.addEventListener("activate", (event) => { - event.waitUntil( - 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), - ]) - ); -}); - -// Fonction pour vérifier si c'est une ressource webpack -const isWebpackResource = (url) => { - return ( - url.includes("/_next/webpack") || - url.includes("webpack-hmr") || - url.includes("webpack.js") || - url.includes("webpack-runtime") || - url.includes("hot-update") - ); -}; - -// Fonction pour vérifier si c'est une ressource statique de Next.js -const isNextStaticResource = (url) => { - return url.includes("/_next/static") && !isWebpackResource(url); -}; - -// Fonction pour vérifier si c'est une image (couvertures ou pages de livres) -const isImageResource = (url) => { - return ( - (url.includes("/api/v1/books/") && (url.includes("/pages") || url.includes("/thumbnail") || url.includes("/cover"))) || - (url.includes("/api/komga/images/") && (url.includes("/series/") || url.includes("/books/")) && url.includes("/thumbnail")) - ); -}; - -// 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); - const cachedResponse = await cache.match(request); - - if (cachedResponse) { - return cachedResponse; + if (cached) { + return cached; } try { const response = await fetch(request); if (response.ok) { - await cache.put(request, response.clone()); - return response; + cache.put(request, response.clone()); } - // Si 404, retourner une réponse vide sans throw (pas d'erreur console) - if (response.status === 404) { - return new Response("", { - status: 404, - statusText: "Not Found", - headers: { - "Content-Type": "text/plain", - }, - }); - } - // Pour les autres erreurs, throw - throw new Error(`Network response error: ${response.status}`); + return response; } catch (error) { - // Erreurs réseau (offline, timeout, etc.) - console.warn("Image fetch failed:", error); - return new Response("", { - status: 503, - statusText: "Service Unavailable", - headers: { - "Content-Type": "text/plain", - }, - }); + // Network failed - try cache without ignoreSearch as fallback + if (options.ignoreSearch) { + const fallback = await cache.match(request, { ignoreSearch: false }); + if (fallback) return fallback; + } + throw error; } -}; +} + +/** + * Stale-While-Revalidate: Serve from cache immediately, update in background + * Used for: API data, RSC payloads + */ +async function staleWhileRevalidateStrategy(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + + // Start network request (don't await) + const fetchPromise = fetch(request) + .then((response) => { + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + }) + .catch(() => null); + + // Return cached version immediately if available + if (cached) { + return cached; + } + + // Otherwise wait for network + const response = await fetchPromise; + if (response) { + return response; + } + + throw new Error("Network failed and no cache available"); +} + +/** + * Navigation Strategy: Network-First with SPA fallback + * Used for: Page navigations + */ +async function navigationStrategy(request) { + const cache = await caches.open(STATIC_CACHE); + + try { + // Try network first + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + } catch (error) { + // Network failed - try cache + const cached = await cache.match(request); + if (cached) { + return cached; + } + + // Try to serve root page for SPA client-side routing + const rootPage = await cache.match("/"); + if (rootPage) { + return rootPage; + } + + // Last resort: offline page + const offlinePage = await cache.match(OFFLINE_PAGE); + if (offlinePage) { + return offlinePage; + } + + throw error; + } +} + +// ============================================================================ +// Service Worker Lifecycle +// ============================================================================ + +self.addEventListener("install", (event) => { + // eslint-disable-next-line no-console + console.log("[SW] Installing version", VERSION); + + event.waitUntil( + (async () => { + const cache = await caches.open(STATIC_CACHE); + try { + await cache.addAll(PRECACHE_ASSETS); + // eslint-disable-next-line no-console + console.log("[SW] Precached assets"); + } catch (error) { + // eslint-disable-next-line no-console + console.error("[SW] Precache failed:", error); + } + await self.skipWaiting(); + })() + ); +}); + +self.addEventListener("activate", (event) => { + // eslint-disable-next-line no-console + console.log("[SW] Activating version", VERSION); + + event.waitUntil( + (async () => { + // Clean up old caches, but preserve BOOKS_CACHE + const cacheNames = await caches.keys(); + const cachesToDelete = cacheNames.filter( + (name) => + name.startsWith("stripstream-") && + name !== BOOKS_CACHE && + !name.endsWith(`-${VERSION}`) + ); + + await Promise.all(cachesToDelete.map((name) => caches.delete(name))); + + if (cachesToDelete.length > 0) { + // eslint-disable-next-line no-console + console.log("[SW] Deleted old caches:", cachesToDelete); + } + + await self.clients.claim(); + // eslint-disable-next-line no-console + console.log("[SW] Activated and claimed clients"); + })() + ); +}); + +// ============================================================================ +// Fetch Handler - Request Routing +// ============================================================================ self.addEventListener("fetch", (event) => { - // Ignorer les requêtes non GET - if (event.request.method !== "GET") return; + const { request } = event; + const { method } = request; + const url = new URL(request.url); - // Ignorer les ressources webpack - if (isWebpackResource(event.request.url)) return; - - // Gérer les images avec Cache-First - if (isImageResource(event.request.url)) { - event.respondWith(imageCacheStrategy(event.request)); + // Only handle GET requests + if (method !== "GET") { return; } - // Pour les ressources statiques de Next.js et les autres requêtes : Network-First - event.respondWith( - fetch(event.request) - .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(); - 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; - }) - .catch(async () => { - const cache = await caches.open(CACHE_NAME); - const cachedResponse = await cache.match(event.request); + // Route 1: Images → Cache-First with ignoreSearch + if (isImageRequest(url.href)) { + event.respondWith(cacheFirstStrategy(request, IMAGES_CACHE, { ignoreSearch: true })); + return; + } - if (cachedResponse) { - return cachedResponse; - } + // Route 2: Next.js RSC payloads → Stale-While-Revalidate + if (isNextRSCRequest(request)) { + event.respondWith(staleWhileRevalidateStrategy(request, RSC_CACHE)); + return; + } - // Si c'est une navigation, renvoyer la page hors ligne - if (event.request.mode === "navigate") { - return cache.match(OFFLINE_PAGE); - } + // Route 3: API data → Stale-While-Revalidate (if cacheable) + if (isApiDataRequest(url.href) && shouldCacheApiData(url.href)) { + event.respondWith(staleWhileRevalidateStrategy(request, DATA_CACHE)); + return; + } - return new Response(JSON.stringify({ error: "Hors ligne" }), { - status: 503, - headers: { "Content-Type": "application/json" }, - }); - }) - ); + // Route 4: Next.js static resources → Cache-First with ignoreSearch + if (isNextStaticResource(url.href)) { + event.respondWith(cacheFirstStrategy(request, STATIC_CACHE, { ignoreSearch: true })); + return; + } + + // Route 5: Navigation → Network-First with SPA fallback + if (request.mode === "navigate") { + event.respondWith(navigationStrategy(request)); + return; + } + + // Route 6: Everything else → Network only (no caching) + // This includes: API auth, preferences, and other dynamic content }); diff --git a/src/components/home/MediaRow.tsx b/src/components/home/MediaRow.tsx index aac0f5c..c4e325b 100644 --- a/src/components/home/MediaRow.tsx +++ b/src/components/home/MediaRow.tsx @@ -9,6 +9,8 @@ import { ScrollContainer } from "@/components/ui/scroll-container"; import { Section } from "@/components/ui/section"; import type { LucideIcon } from "lucide-react"; import { Card } from "@/components/ui/card"; +import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus"; +import { cn } from "@/lib/utils"; interface BaseItem { id: string; @@ -76,6 +78,8 @@ interface MediaCardProps { function MediaCard({ item, onClick }: MediaCardProps) { const { t } = useTranslate(); const isSeries = "booksCount" in item; + const { isAccessible } = useBookOfflineStatus(isSeries ? "" : item.id); + const title = isSeries ? item.metadata.title : item.metadata.title || @@ -83,10 +87,21 @@ function MediaCard({ item, onClick }: MediaCardProps) { ? t("navigation.volume", { number: item.metadata.number }) : ""); + const handleClick = () => { + // Pour les séries, toujours autoriser le clic + // Pour les livres, vérifier si accessible + if (isSeries || isAccessible) { + onClick?.(); + } + }; + return (
{isSeries ? ( diff --git a/src/components/series/BookGrid.tsx b/src/components/series/BookGrid.tsx index a3d5148..04fc637 100644 --- a/src/components/series/BookGrid.tsx +++ b/src/components/series/BookGrid.tsx @@ -5,6 +5,7 @@ import { BookCover } from "@/components/ui/book-cover"; import { useState, useEffect } from "react"; import { useTranslate } from "@/hooks/useTranslate"; import { cn } from "@/lib/utils"; +import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus"; interface BookGridProps { books: KomgaBook[]; @@ -12,6 +13,53 @@ interface BookGridProps { isCompact?: boolean; } +interface BookCardProps { + book: KomgaBook; + onBookClick: (book: KomgaBook) => void; + onSuccess: (book: KomgaBook, action: "read" | "unread") => void; + isCompact: boolean; +} + +function BookCard({ book, onBookClick, onSuccess, isCompact }: BookCardProps) { + const { t } = useTranslate(); + const { isAccessible } = useBookOfflineStatus(book.id); + + const handleClick = () => { + // Ne pas permettre le clic si le livre n'est pas accessible + if (!isAccessible) return; + onBookClick(book); + }; + + return ( +
+
+ onSuccess(book, action)} + /> +
+
+ ); +} + export function BookGrid({ books, onBookClick, isCompact = false }: BookGridProps) { const [localBooks, setLocalBooks] = useState(books); const { t } = useTranslate(); @@ -69,33 +117,15 @@ export function BookGrid({ books, onBookClick, isCompact = false }: BookGridProp : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5" )} > - {localBooks.map((book) => { - return ( -
-
onBookClick(book)} - className="w-full h-full hover:opacity-100 transition-all cursor-pointer" - > - handleOnSuccess(book, action)} - /> -
-
- ); - })} + {localBooks.map((book) => ( + + ))}
); } diff --git a/src/components/settings/CacheSettings.tsx b/src/components/settings/CacheSettings.tsx index 4b6a7c5..1a6c9cf 100644 --- a/src/components/settings/CacheSettings.tsx +++ b/src/components/settings/CacheSettings.tsx @@ -41,6 +41,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { const [isServiceWorkerClearing, setIsServiceWorkerClearing] = useState(false); const [serverCacheSize, setServerCacheSize] = useState(null); const [swCacheSize, setSwCacheSize] = useState(null); + const [apiCacheSize, setApiCacheSize] = useState(null); const [isLoadingCacheSize, setIsLoadingCacheSize] = useState(true); const [cacheEntries, setCacheEntries] = useState([]); const [isLoadingEntries, setIsLoadingEntries] = useState(false); @@ -116,6 +117,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { if ("caches" in window) { const cacheNames = await caches.keys(); let totalSize = 0; + let apiSize = 0; for (const cacheName of cacheNames) { const cache = await caches.open(cacheName); @@ -126,11 +128,17 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { if (response) { const blob = await response.clone().blob(); totalSize += blob.size; + + // Calculer la taille du cache API séparément + if (cacheName.includes("api")) { + apiSize += blob.size; + } } } } setSwCacheSize(totalSize); + setApiCacheSize(apiSize); } } catch (error) { console.error("Erreur lors de la récupération de la taille du cache:", error); @@ -468,7 +476,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) { {isLoadingCacheSize ? (
{t("settings.cache.size.loading")}
) : ( -
+
{t("settings.cache.size.server")}
{serverCacheSize ? ( @@ -491,6 +499,15 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
{t("settings.cache.size.error")}
)}
+ +
+
{t("settings.cache.size.api")}
+ {apiCacheSize !== null ? ( +
{formatBytes(apiCacheSize)}
+ ) : ( +
{t("settings.cache.size.error")}
+ )} +
)}
diff --git a/src/components/ui/book-cover.tsx b/src/components/ui/book-cover.tsx index f962bba..2a7525e 100644 --- a/src/components/ui/book-cover.tsx +++ b/src/components/ui/book-cover.tsx @@ -12,6 +12,8 @@ import { BookOfflineButton } from "./book-offline-button"; import { useTranslate } from "@/hooks/useTranslate"; import type { KomgaBook } from "@/types/komga"; import { formatDate } from "@/lib/utils"; +import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus"; +import { WifiOff } from "lucide-react"; // Fonction utilitaire pour obtenir les informations de statut de lecture const getReadingStatusInfo = (book: KomgaBook, t: (key: string, options?: any) => string) => { @@ -59,6 +61,7 @@ export function BookCover({ overlayVariant = "default", }: BookCoverProps) { const { t } = useTranslate(); + const { isAccessible } = useBookOfflineStatus(book.id); const baseUrl = getImageUrl("book", book.id); const imageUrl = useImageUrl(baseUrl); @@ -73,6 +76,9 @@ export function BookCover({ const isRead = book.readProgress?.completed || false; const hasReadProgress = book.readProgress !== null || currentPage > 0; + // Détermine si le livre doit être grisé (non accessible hors ligne) + const isUnavailable = !isAccessible; + const handleMarkAsRead = () => { onSuccess?.(book, "read"); }; @@ -83,7 +89,7 @@ export function BookCover({ return ( <> -
+
{showProgress && } + {/* Badge hors ligne si non accessible */} + {isUnavailable && ( +
+
+ + {t("books.status.offline")} +
+
+ )}
{/* Overlay avec les contrôles */} {(showControls || showOverlay) && ( diff --git a/src/hooks/useBookOfflineStatus.ts b/src/hooks/useBookOfflineStatus.ts new file mode 100644 index 0000000..1b6fc4f --- /dev/null +++ b/src/hooks/useBookOfflineStatus.ts @@ -0,0 +1,86 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useNetworkStatus } from "./useNetworkStatus"; + +type BookStatus = "idle" | "downloading" | "available" | "error"; + +interface BookDownloadStatus { + status: BookStatus; + progress: number; + timestamp: number; + lastDownloadedPage?: number; +} + +/** + * Hook pour vérifier si un livre est disponible hors ligne + */ +export function useBookOfflineStatus(bookId: string) { + const [isAvailableOffline, setIsAvailableOffline] = useState(false); + const [isChecking, setIsChecking] = useState(true); + const isOnline = useNetworkStatus(); + + const getStorageKey = useCallback((id: string) => `book-status-${id}`, []); + + const checkOfflineAvailability = useCallback(async () => { + if (typeof window === "undefined" || !("caches" in window)) { + setIsAvailableOffline(false); + setIsChecking(false); + return; + } + + setIsChecking(true); + try { + // Vérifier le localStorage d'abord (plus rapide) + const statusStr = localStorage.getItem(getStorageKey(bookId)); + if (statusStr) { + const status: BookDownloadStatus = JSON.parse(statusStr); + if (status.status === "available") { + setIsAvailableOffline(true); + setIsChecking(false); + return; + } + } + + // Sinon vérifier le cache + const cache = await caches.open("stripstream-books"); + const bookPages = await cache.match(`/api/komga/images/books/${bookId}/pages`); + setIsAvailableOffline(!!bookPages); + } catch (error) { + console.error("Erreur lors de la vérification du cache:", error); + setIsAvailableOffline(false); + } finally { + setIsChecking(false); + } + }, [bookId, getStorageKey]); + + useEffect(() => { + checkOfflineAvailability(); + + // Écouter les changements de localStorage + const handleStorageChange = (e: StorageEvent) => { + if (e.key === getStorageKey(bookId)) { + checkOfflineAvailability(); + } + }; + + window.addEventListener("storage", handleStorageChange); + + // Rafraîchir périodiquement (pour les changements dans le même onglet) + const interval = setInterval(checkOfflineAvailability, 5000); + + return () => { + window.removeEventListener("storage", handleStorageChange); + clearInterval(interval); + }; + }, [bookId, checkOfflineAvailability, getStorageKey]); + + return { + isAvailableOffline, + isChecking, + isOnline, + // Le livre est "accessible" s'il est disponible hors ligne OU si on est en ligne + isAccessible: isAvailableOffline || isOnline, + }; +} + diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json index 5c58755..938c180 100644 --- a/src/i18n/messages/en/common.json +++ b/src/i18n/messages/en/common.json @@ -139,7 +139,8 @@ "size": { "title": "Cache size", "server": "Server cache", - "serviceWorker": "Service worker cache", + "serviceWorker": "SW cache (total)", + "api": "API cache (data)", "items": "{count} item(s)", "loading": "Loading...", "error": "Error loading" @@ -290,7 +291,8 @@ "unread": "Unread", "read": "Read", "readDate": "Read on {{date}}", - "progress": "Page {{current}}/{{total}}" + "progress": "Page {{current}}/{{total}}", + "offline": "Unavailable offline" }, "display": { "showing": "Showing books {start} to {end} of {total}", diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json index 4e8d383..a72986b 100644 --- a/src/i18n/messages/fr/common.json +++ b/src/i18n/messages/fr/common.json @@ -139,7 +139,8 @@ "size": { "title": "Taille du cache", "server": "Cache serveur", - "serviceWorker": "Cache service worker", + "serviceWorker": "Cache SW (total)", + "api": "Cache API (données)", "items": "{count} élément(s)", "loading": "Chargement...", "error": "Erreur lors du chargement" @@ -288,7 +289,8 @@ "unread": "Non lu", "read": "Lu", "readDate": "Lu le {{date}}", - "progress": "Page {{current}}/{{total}}" + "progress": "Page {{current}}/{{total}}", + "offline": "Indisponible hors ligne" }, "display": { "showing": "Affichage des tomes {start} à {end} sur {total}",