OpenMind v1 : cartes mentales libres, local-first et dockerisees

Premiere version fonctionnelle, demarrable avec `docker compose up -d --build`
sur http://localhost:8080.

Architecture
- Frontend React 18 / TypeScript strict / Vite / Tailwind, canvas @xyflow/react,
  etat Zustand, persistance IndexedDB via Dexie, auto-layout dagre.
- Backend FastAPI / SQLAlchemy 2 / Pydantic v2 / SQLite, qui sert aussi les
  fichiers statiques du frontend compile.
- Image Docker multi-stage, un seul service, bind mount ./data pour SQLite.

Local-first
Les cartes restent dans le navigateur. Le serveur ne recoit une carte que sur
demande explicite de partage.

Partage
Deux secrets distincts : share_token public en lecture seule dans l'URL, et
management_token conserve uniquement cote client, transmis en en-tete pour les
modifications et stocke hashe en base.

Fonctionnalites
Tableau de bord, editeur complet (undo/redo, copier/coller, raccourcis,
branches repliables, recherche), 9 themes, 4 dispositions automatiques,
panneau de proprietes, import/export .mmap, export PNG/SVG/PDF, impression,
page publique en lecture seule, PWA.

Qualite
25 tests pytest et 43 tests vitest au vert, ruff et ESLint sans erreur,
TypeScript strict sans erreur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 22:19:18 +02:00
commit 0b2e5e326d
113 changed files with 14576 additions and 0 deletions

View File

9
backend/app/db/base.py Normal file
View File

@@ -0,0 +1,9 @@
"""Base declarative SQLAlchemy commune a tous les modeles."""
from __future__ import annotations
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
"""Classe de base des modeles ORM."""

26
backend/app/db/init_db.py Normal file
View File

@@ -0,0 +1,26 @@
"""Initialisation du schema SQLite.
La V1 n'utilise pas Alembic : le schema tient dans une seule table et
`create_all` suffit. Toute evolution future du schema devra soit passer par
Alembic, soit par une migration explicite ajoutee ici (voir README).
"""
from __future__ import annotations
import logging
from sqlalchemy import Engine
from app.db.base import Base
from app.db.session import engine as default_engine
from app.models import share as _share_model # noqa: F401 (enregistre la table)
logger = logging.getLogger(__name__)
SCHEMA_VERSION = 1
def init_db(engine: Engine | None = None) -> None:
target = engine or default_engine
Base.metadata.create_all(bind=target)
logger.info("Schema SQLite initialise (version %s)", SCHEMA_VERSION)

62
backend/app/db/session.py Normal file
View File

@@ -0,0 +1,62 @@
"""Creation du moteur SQLAlchemy et de la session de requete."""
from __future__ import annotations
import logging
from collections.abc import Generator
from pathlib import Path
from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from app.config import get_settings
logger = logging.getLogger(__name__)
_SQLITE_FILE_PREFIX = "sqlite:///"
def _ensure_sqlite_directory(database_url: str) -> None:
"""Cree le dossier parent du fichier SQLite s'il n'existe pas encore."""
if not database_url.startswith(_SQLITE_FILE_PREFIX):
return
raw_path = database_url[len(_SQLITE_FILE_PREFIX) :]
if not raw_path or raw_path == ":memory:":
return
path = Path(raw_path)
if path.parent and str(path.parent) not in ("", "."):
path.parent.mkdir(parents=True, exist_ok=True)
def create_db_engine(database_url: str | None = None) -> Engine:
settings = get_settings()
url = database_url or settings.database_url
_ensure_sqlite_directory(url)
connect_args = {"check_same_thread": False} if url.startswith("sqlite") else {}
engine = create_engine(url, connect_args=connect_args, future=True, echo=False)
if url.startswith("sqlite"):
@event.listens_for(engine, "connect")
def _set_sqlite_pragma(dbapi_connection, _connection_record): # type: ignore[no-untyped-def]
cursor = dbapi_connection.cursor()
# WAL : meilleure resistance aux arrets brutaux du conteneur.
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
return engine
engine: Engine = create_db_engine()
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
def get_db() -> Generator[Session, None, None]:
"""Dependance FastAPI : ouvre une session et la referme systematiquement."""
session = SessionLocal()
try:
yield session
finally:
session.close()