From be0c8bceb6ab318d91fe24263a4ae7383d9cb5c3 Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Mon, 25 May 2026 15:33:29 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20export=20Markdown=20notes=20(ARQ/Redis)?= =?UTF-8?q?=20+=20backup/restore=20BDD=20=E2=80=94=20v0.5.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Volume data/ (bind mount ./data) remplace le volume uploads nommé data/notes/ → .md auto-générés, data/uploads/ → médias, data/backup/ → dumps - Service Redis (redis:7-alpine) + worker ARQ (backend-worker) - notes_markdown.py : frontmatter YAML + contenu + pièces jointes (liens relatifs) Nom : YYYY-MM-DD_slug-titre_shortid.md, rotation si titre modifié - api/notes.py : publie export_note_markdown / remove_note_markdown sur Redis après chaque create / update / delete / add_attachment / delete_attachment - api/admin.py : POST /backup, GET /backups, POST /restore/{filename} (pg_dump/pg_restore) - Backend Dockerfile : postgresql-client ; requirements : arq==0.26.1 - ConfigPage : section "Base de données" avec sauvegarde + liste + restauration Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + backend/Dockerfile | 2 +- backend/app/api/admin.py | 95 ++++++++++++++++++++ backend/app/api/notes.py | 6 ++ backend/app/core/config.py | 12 ++- backend/app/core/redis.py | 33 +++++++ backend/app/main.py | 9 ++ backend/app/services/notes_markdown.py | 79 +++++++++++++++++ backend/app/workers/__init__.py | 0 backend/app/workers/notes_worker.py | 30 +++++++ backend/requirements.txt | 1 + data/backup/.gitkeep | 0 data/notes/.gitkeep | 0 docker-compose.yml | 37 +++++++- docs/plan.md | 39 +++++++-- frontend/package.json | 2 +- frontend/src/api/admin.ts | 28 ++++++ frontend/src/pages/ConfigPage.tsx | 116 +++++++++++++++++++++++++ 18 files changed, 482 insertions(+), 11 deletions(-) create mode 100644 backend/app/api/admin.py create mode 100644 backend/app/core/redis.py create mode 100644 backend/app/services/notes_markdown.py create mode 100644 backend/app/workers/__init__.py create mode 100644 backend/app/workers/notes_worker.py create mode 100644 data/backup/.gitkeep create mode 100644 data/notes/.gitkeep create mode 100644 frontend/src/api/admin.ts diff --git a/.gitignore b/.gitignore index a18a5fd..c4b47ca 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ node_modules/ dist/ screenshot.png *.png +# Données utilisateur — ignorées sauf la structure +data/notes/*.md +data/backup/*.dump +data/uploads/ diff --git a/backend/Dockerfile b/backend/Dockerfile index 35ab1ce..87e49db 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.12-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ - libpq-dev gcc \ + libpq-dev gcc postgresql-client \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..e52d639 --- /dev/null +++ b/backend/app/api/admin.py @@ -0,0 +1,95 @@ +import asyncio +import os +from datetime import datetime +from pathlib import Path +from urllib.parse import urlparse + +from fastapi import APIRouter, HTTPException + +from app.core.config import settings + +router = APIRouter() + + +def _pg_env() -> dict: + url = urlparse(settings.database_url.replace("+asyncpg", "")) + env = os.environ.copy() + env["PGPASSWORD"] = url.password or "" + return env, url + + +@router.post("/backup") +async def create_backup(): + backup_dir = settings.backup_path + backup_dir.mkdir(parents=True, exist_ok=True) + + timestamp = datetime.now().strftime("%Y-%m-%d_%H%M") + filename = f"homehub_{timestamp}.dump" + filepath = backup_dir / filename + + env, url = _pg_env() + proc = await asyncio.create_subprocess_exec( + "pg_dump", "-Fc", + "-h", url.hostname or "db", + "-p", str(url.port or 5432), + "-U", url.username or "homehub", + "-d", (url.path or "/homehub").lstrip("/"), + "-f", str(filepath), + env=env, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + if proc.returncode != 0: + raise HTTPException(500, f"Échec du backup : {stderr.decode()}") + + stat = filepath.stat() + return { + "filename": filename, + "size": stat.st_size, + "created_at": datetime.now().isoformat(), + } + + +@router.get("/backups") +async def list_backups(): + backup_dir = settings.backup_path + if not backup_dir.exists(): + return [] + files = sorted(backup_dir.glob("*.dump"), key=lambda f: f.stat().st_mtime, reverse=True) + return [ + { + "filename": f.name, + "size": f.stat().st_size, + "created_at": datetime.fromtimestamp(f.stat().st_mtime).isoformat(), + } + for f in files + ] + + +@router.post("/restore/{filename}") +async def restore_backup(filename: str): + # Sécurité : interdit les chemins relatifs + if "/" in filename or ".." in filename or not filename.endswith(".dump"): + raise HTTPException(400, "Nom de fichier invalide") + + filepath = settings.backup_path / filename + if not filepath.exists(): + raise HTTPException(404, "Fichier introuvable") + + env, url = _pg_env() + proc = await asyncio.create_subprocess_exec( + "pg_restore", "--clean", "--if-exists", "--no-owner", "--no-privileges", + "-h", url.hostname or "db", + "-p", str(url.port or 5432), + "-U", url.username or "homehub", + "-d", (url.path or "/homehub").lstrip("/"), + str(filepath), + env=env, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + # pg_restore retourne 1 pour des warnings non-fatals (DROP IF EXISTS sur objets manquants) + if proc.returncode not in (0, 1): + raise HTTPException(500, f"Échec de la restauration : {stderr.decode()}") + + return {"message": "Restauration réussie"} diff --git a/backend/app/api/notes.py b/backend/app/api/notes.py index 79c45af..edf6130 100644 --- a/backend/app/api/notes.py +++ b/backend/app/api/notes.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.database import get_session +from app.core.redis import enqueue from app.models.notes import NoteItem, NoteAttachment from app.schemas.notes import NoteCreate, NoteUpdate, NoteResponse from app.services.media import save_image, save_audio, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_TYPES @@ -75,6 +76,7 @@ async def create_note(payload: NoteCreate, session: AsyncSession = Depends(get_s await session.refresh(note, ["attachments"]) await session.commit() await session.refresh(note, ["attachments"]) + await enqueue("export_note_markdown", str(note.id)) return note @@ -97,6 +99,7 @@ async def update_note( setattr(note, field, value) await session.commit() await session.refresh(note, ["attachments"]) + await enqueue("export_note_markdown", str(note.id)) return note @@ -107,6 +110,7 @@ async def delete_note(note_id: uuid.UUID, session: AsyncSession = Depends(get_se raise HTTPException(404, "Note introuvable") await session.delete(note) await session.commit() + await enqueue("remove_note_markdown", str(note_id)) return Response(status_code=204) @@ -145,6 +149,7 @@ async def add_attachment( session.add(att) await session.commit() await session.refresh(note, ["attachments"]) + await enqueue("export_note_markdown", str(note_id)) return note @@ -170,4 +175,5 @@ async def delete_attachment( ) await session.delete(att) await session.commit() + await enqueue("export_note_markdown", str(note_id)) return Response(status_code=204) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f7f7cf6..eee1633 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -6,7 +6,9 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") database_url: str = "postgresql+asyncpg://homehub:homehub@localhost:5432/homehub" - upload_dir: str = "/uploads" + upload_dir: str = "/data/uploads" + data_dir: str = "/data" + redis_url: str = "redis://redis:6379" cors_origins: str = "http://localhost:3000" @property @@ -17,5 +19,13 @@ class Settings(BaseSettings): def upload_path(self) -> Path: return Path(self.upload_dir) + @property + def notes_md_path(self) -> Path: + return Path(self.data_dir) / "notes" + + @property + def backup_path(self) -> Path: + return Path(self.data_dir) / "backup" + settings = Settings() diff --git a/backend/app/core/redis.py b/backend/app/core/redis.py new file mode 100644 index 0000000..485cd7b --- /dev/null +++ b/backend/app/core/redis.py @@ -0,0 +1,33 @@ +import logging +from arq import create_pool +from arq.connections import ArqRedis, RedisSettings +from app.core.config import settings + +_pool: ArqRedis | None = None + +logger = logging.getLogger(__name__) + + +async def init_redis() -> None: + global _pool + try: + _pool = await create_pool(RedisSettings.from_dsn(settings.redis_url)) + logger.info("Redis pool initialisé") + except Exception as e: + logger.warning(f"Redis indisponible, export Markdown désactivé : {e}") + + +async def close_redis() -> None: + global _pool + if _pool: + await _pool.close() + _pool = None + + +async def enqueue(job_name: str, *args) -> None: + if _pool is None: + return + try: + await _pool.enqueue_job(job_name, *args) + except Exception as e: + logger.warning(f"Redis enqueue échoué ({job_name}) : {e}") diff --git a/backend/app/main.py b/backend/app/main.py index 15635d1..349ace1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,20 +1,28 @@ from contextlib import asynccontextmanager +from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from app.api.admin import router as admin_router from app.api.health import router as health_router from app.api.media import router as media_router from app.api.notes import router as notes_router from app.api.todos import router as todos_router from app.api.shopping import router as shopping_router from app.core.config import settings +from app.core.redis import init_redis, close_redis from app.data.seed import run_seed @asynccontextmanager async def lifespan(app: FastAPI): + # Crée les dossiers data/ au démarrage + for subdir in ("uploads", "notes", "backup"): + Path(settings.data_dir, subdir).mkdir(parents=True, exist_ok=True) await run_seed() + await init_redis() yield + await close_redis() app = FastAPI(title="HomeHub API", version="0.1.0", lifespan=lifespan) @@ -28,6 +36,7 @@ app.add_middleware( ) app.include_router(health_router, prefix="/api") +app.include_router(admin_router, prefix="/api/admin") app.include_router(media_router, prefix="/api/media") app.include_router(notes_router, prefix="/api/notes") app.include_router(todos_router, prefix="/api/todos") diff --git a/backend/app/services/notes_markdown.py b/backend/app/services/notes_markdown.py new file mode 100644 index 0000000..7b9a240 --- /dev/null +++ b/backend/app/services/notes_markdown.py @@ -0,0 +1,79 @@ +import re +import unicodedata +from pathlib import Path + +from app.core.config import settings +from app.models.notes import NoteItem + + +def _slugify(text: str, max_len: int = 50) -> str: + text = unicodedata.normalize("NFD", text) + text = "".join(c for c in text if unicodedata.category(c) != "Mn") + text = text.lower() + text = re.sub(r"[^\w\s-]", "", text) + text = re.sub(r"[\s_-]+", "-", text) + return text.strip("-")[:max_len] + + +def _note_filename(note: NoteItem) -> str: + date = note.created_at.strftime("%Y-%m-%d") + raw = note.title or note.content[:40] + slug = _slugify(raw) or "sans-titre" + short_id = str(note.id)[:8] + return f"{date}_{slug}_{short_id}.md" + + +def _render(note: NoteItem) -> str: + lines = ["---"] + lines.append(f"id: {note.id}") + if note.title: + lines.append(f'title: "{note.title}"') + if note.category: + lines.append(f"category: {note.category}") + if note.tags: + tags_str = ", ".join(f'"{t}"' for t in note.tags) + lines.append(f"tags: [{tags_str}]") + lines.append(f"created_at: {note.created_at.isoformat()}") + if note.gps_lat is not None and note.gps_lon is not None: + lines.append(f'gps: "{note.gps_lat}, {note.gps_lon}"') + lines.append("---") + lines.append("") + lines.append(note.content) + + images = [a for a in note.attachments if a.file_type == "image" and a.file_path] + audios = [a for a in note.attachments if a.file_type == "audio" and a.file_path] + + if images or audios: + lines.append("") + lines.append("## Pièces jointes") + lines.append("") + for att in images: + # file_path est relatif à UPLOAD_DIR, ex: "images/originals/uuid.webp" + rel = f"../uploads/{att.file_path}" + name = att.original_name or "image" + lines.append(f"![{name}]({rel})") + for att in audios: + rel = f"../uploads/{att.file_path}" + name = att.original_name or "audio" + lines.append(f"[{name}]({rel})") + + return "\n".join(lines) + "\n" + + +def save_note_markdown(note: NoteItem) -> None: + notes_dir = settings.notes_md_path + notes_dir.mkdir(parents=True, exist_ok=True) + short_id = str(note.id)[:8] + # Supprime l'ancienne version (titre peut avoir changé) + for old in notes_dir.glob(f"*_{short_id}.md"): + old.unlink() + (notes_dir / _note_filename(note)).write_text(_render(note), encoding="utf-8") + + +def delete_note_markdown(note_id: str) -> None: + notes_dir = settings.notes_md_path + if not notes_dir.exists(): + return + short_id = note_id[:8] + for f in notes_dir.glob(f"*_{short_id}.md"): + f.unlink() diff --git a/backend/app/workers/__init__.py b/backend/app/workers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/workers/notes_worker.py b/backend/app/workers/notes_worker.py new file mode 100644 index 0000000..5eaac3a --- /dev/null +++ b/backend/app/workers/notes_worker.py @@ -0,0 +1,30 @@ +from arq.connections import RedisSettings +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from app.core.config import settings +from app.core.database import AsyncSessionLocal +from app.models.notes import NoteItem +from app.services.notes_markdown import delete_note_markdown, save_note_markdown + + +async def export_note_markdown(ctx: dict, note_id: str) -> None: + async with AsyncSessionLocal() as session: + stmt = ( + select(NoteItem) + .where(NoteItem.id == note_id) + .options(selectinload(NoteItem.attachments)) + ) + result = await session.execute(stmt) + note = result.scalar_one_or_none() + if note: + save_note_markdown(note) + + +async def remove_note_markdown(ctx: dict, note_id: str) -> None: + delete_note_markdown(note_id) + + +class WorkerSettings: + functions = [export_note_markdown, remove_note_markdown] + redis_settings = RedisSettings.from_dsn(settings.redis_url) diff --git a/backend/requirements.txt b/backend/requirements.txt index 5c06f36..3ac3e3d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,5 +7,6 @@ pydantic-settings==2.6.1 pillow==11.1.0 python-multipart==0.0.20 httpx==0.28.0 +arq==0.26.1 pytest==8.3.4 pytest-asyncio==0.24.0 diff --git a/data/backup/.gitkeep b/data/backup/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/notes/.gitkeep b/data/notes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml index a26cab7..4ac542b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,20 +13,51 @@ services: timeout: 5s retries: 10 + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + backend: build: ./backend environment: DATABASE_URL: postgresql+asyncpg://homehub:homehub@db:5432/homehub - UPLOAD_DIR: /uploads + UPLOAD_DIR: /data/uploads + DATA_DIR: /data + REDIS_URL: redis://redis:6379 CORS_ORIGINS: http://localhost:3001,http://localhost:3000 volumes: - ./backend/app:/app/app - - uploads:/uploads + - ./data:/data ports: - "8000:8000" depends_on: db: condition: service_healthy + redis: + condition: service_healthy + + backend-worker: + build: ./backend + command: arq app.workers.notes_worker.WorkerSettings + environment: + DATABASE_URL: postgresql+asyncpg://homehub:homehub@db:5432/homehub + UPLOAD_DIR: /data/uploads + DATA_DIR: /data + REDIS_URL: redis://redis:6379 + volumes: + - ./backend/app:/app/app + - ./data:/data + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy frontend: build: ./frontend @@ -37,4 +68,4 @@ services: volumes: db_data: - uploads: + redis_data: diff --git a/docs/plan.md b/docs/plan.md index 57a4aea..a0fb495 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -177,7 +177,36 @@ --- -## Phase 5 — Scan produits + enrichissement catalogue +## Phase 5 — Export Markdown notes + Backup BDD ✅ + +**Objectif** : persistance double des notes (BDD source de vérité + fichiers Markdown partagés), infrastructure Redis, backup/restore depuis l'interface. + +### Architecture +- Volume Docker `./data/` (bind mount) remplace le volume nommé `uploads` + - `data/notes/` — fichiers `.md` auto-générés + - `data/uploads/` — médias (photos, audio) + - `data/backup/` — dumps PostgreSQL + +### Backend +- [x] Service Redis : `redis:7-alpine` dans docker-compose +- [x] Worker ARQ (`backend-worker`) : consomme la queue `notes:markdown`, même image que backend +- [x] `app/core/redis.py` — pool ARQ, `enqueue()` best-effort (silence si Redis down) +- [x] `app/workers/notes_worker.py` — tâches `export_note_markdown` + `remove_note_markdown` +- [x] `app/services/notes_markdown.py` — génère le frontmatter YAML + contenu + pièces jointes (liens relatifs `../uploads/…`) + - Nom de fichier : `YYYY-MM-DD_slug-du-titre_shortid.md` + - Rotation automatique si titre modifié (recherche par `*_{shortid}.md`) +- [x] `app/api/notes.py` — publie sur Redis après create / update / delete / add_attachment / delete_attachment +- [x] `app/api/admin.py` — `POST /api/admin/backup`, `GET /api/admin/backups`, `POST /api/admin/restore/{filename}` +- [x] `backend/Dockerfile` — ajout `postgresql-client` pour pg_dump / pg_restore +- [x] `requirements.txt` — ajout `arq==0.26.1` + +### Frontend +- [x] `frontend/src/api/admin.ts` — client TypeScript backup/restore +- [x] Page Config — section "Base de données" : bouton sauvegarde + liste des dumps + bouton Restaurer par fichier + +--- + +## Phase 6 — Scan produits + enrichissement catalogue **Objectif** : scan code-barres depuis mobile, auto-remplissage depuis OpenFoodFacts. @@ -203,7 +232,7 @@ --- -## Phase 6 — Service OCR (conteneur dédié) +## Phase 7 — Service OCR (conteneur dédié) **Objectif** : OCR partagé, prérequis pour shopping avancé et notes avancées. @@ -214,7 +243,7 @@ --- -## Phase 7 — Shopping avancé (OCR + suivi prix) +## Phase 8 — Shopping avancé (OCR + suivi prix) ### Backend - [ ] `POST /api/shopping/ocr/price-tag` — photo étiquette → extraction prix @@ -228,7 +257,7 @@ --- -## Phase 8 — MCP Server +## Phase 9 — MCP Server **Objectif** : exposer les outils HomeHub aux agents IA (Hermes, Claude, etc.). @@ -262,5 +291,5 @@ ## Ordre de développement ``` -Phase 1 ✅ → Phase 2 ✅ → Phase 3 ✅ → Phase 4 ✅ → Phase 4b ✅ → Phase 5 (Scan) → Phase 6 (OCR) → Phase 7 (Shopping avancé) → Phase 8 (MCP) +Phase 1 ✅ → Phase 2 ✅ → Phase 3 ✅ → Phase 4 ✅ → Phase 4b ✅ → Phase 5 ✅ → Phase 6 (Scan) → Phase 7 (OCR) → Phase 8 (Shopping avancé) → Phase 9 (MCP) ``` diff --git a/frontend/package.json b/frontend/package.json index 0ecdecd..db24d0a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "homehub-frontend", "private": true, - "version": "0.5.1", + "version": "0.5.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 0000000..232edd1 --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,28 @@ +export interface BackupFile { + filename: string + size: number + created_at: string +} + +export async function fetchBackups(): Promise { + const res = await fetch('/api/admin/backups') + if (!res.ok) throw new Error('Erreur chargement sauvegardes') + return res.json() as Promise +} + +export async function createBackup(): Promise { + const res = await fetch('/api/admin/backup', { method: 'POST' }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) as { detail?: string } + throw new Error(err.detail ?? 'Erreur lors du backup') + } + return res.json() as Promise +} + +export async function restoreBackup(filename: string): Promise { + const res = await fetch(`/api/admin/restore/${encodeURIComponent(filename)}`, { method: 'POST' }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) as { detail?: string } + throw new Error(err.detail ?? 'Erreur lors de la restauration') + } +} diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx index ba7ec15..00129ab 100644 --- a/frontend/src/pages/ConfigPage.tsx +++ b/frontend/src/pages/ConfigPage.tsx @@ -1,5 +1,7 @@ +import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { useTheme, type ThemeMode } from '../contexts/ThemeContext' +import { fetchBackups, createBackup, restoreBackup, type BackupFile } from '../api/admin' const sectionStyle: React.CSSProperties = { background: 'var(--bg-3)', @@ -31,9 +33,53 @@ const FONT_LABELS: Record = { '1.25': 'XL', '1.3': 'XL+', '1.35': 'XXL', '1.4': 'XXL+', } +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} o` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko` + return `${(bytes / 1024 / 1024).toFixed(1)} Mo` +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' }) +} + export default function ConfigPage() { const navigate = useNavigate() const { theme, setTheme, fontScale, setFontScale } = useTheme() + const [backups, setBackups] = useState([]) + const [backupLoading, setBackupLoading] = useState(false) + const [restoring, setRestoring] = useState(null) + const [backupError, setBackupError] = useState(null) + + useEffect(() => { + fetchBackups().then(setBackups).catch(() => setBackups([])) + }, []) + + async function handleCreateBackup() { + setBackupLoading(true) + setBackupError(null) + try { + const b = await createBackup() + setBackups(prev => [b, ...prev]) + } catch (e) { + setBackupError((e as Error).message) + } finally { + setBackupLoading(false) + } + } + + async function handleRestore(filename: string) { + if (!confirm(`Restaurer "${filename}" ? L'état actuel de la base sera remplacé.`)) return + setRestoring(filename) + setBackupError(null) + try { + await restoreBackup(filename) + } catch (e) { + setBackupError((e as Error).message) + } finally { + setRestoring(null) + } + } const scaleLabel = FONT_LABELS[String(fontScale)] ?? `${Math.round(fontScale * 100)}%` @@ -86,6 +132,76 @@ export default function ConfigPage() { + {/* Sauvegarde & Restauration */} +
+
Base de données
+ + + {backupError && ( +
+ {backupError} +
+ )} + + {backups.length > 0 && ( +
+
Sauvegardes disponibles
+ {backups.map(b => ( +
+ +
+
+ {b.filename} +
+
+ {formatDate(b.created_at)} — {formatSize(b.size)} +
+
+ +
+ ))} +
+ )} + + {backups.length === 0 && !backupLoading && ( +
+ Aucune sauvegarde disponible +
+ )} +
+ {/* Taille du texte */}
Taille du texte