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

@@ -0,0 +1,293 @@
import { APP_CONFIG } from '@/config/app';
import {
MAP_FORMAT_VERSION,
type BranchStyle,
type LayoutKind,
type MapSettings,
type MindMap,
type MindMapNode,
type NodeKind,
type NodeShape,
type NodeStyle,
type TextAlign,
} from '@/types/map';
import { DEFAULT_SETTINGS } from '@/services/mapFactory';
import { createId } from '@/utils/id';
import { rebuildEdges, rebuildHierarchyFromEdges } from '@/utils/tree';
import { sanitizeUrl } from '@/utils/url';
/** Erreur d'import lisible par l'utilisateur : aucun detail technique. */
export class MapFormatError extends Error {
constructor(message: string) {
super(message);
this.name = 'MapFormatError';
}
}
const MAX_TEXT_LENGTH = 5000;
const MAX_NOTE_LENGTH = 20000;
const MAX_NAME_LENGTH = 200;
const NODE_KINDS: NodeKind[] = ['root', 'standard', 'note', 'image', 'group', 'link', 'task'];
const NODE_SHAPES: NodeShape[] = ['rectangle', 'rounded', 'capsule', 'circle'];
const TEXT_ALIGNS: TextAlign[] = ['left', 'center', 'right'];
const BRANCH_STYLES: BranchStyle[] = ['straight', 'curved', 'bezier'];
const LAYOUTS: LayoutKind[] = [
'mindmap-right',
'mindmap-left',
'tree-horizontal',
'tree-vertical',
'mindmap-both',
'organization',
'freeform',
];
/** Serialisation du format natif .mmap (JSON UTF-8, lisible et diffable). */
export function serializeMap(map: MindMap): string {
return JSON.stringify(map, null, 2);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function asString(value: unknown, maxLength: number): string | undefined {
if (typeof value !== 'string') return undefined;
return value.slice(0, maxLength);
}
function asFiniteNumber(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
/** Une couleur CSS acceptee : hex, rgb(a) ou nom simple. Rien d'autre. */
function asColor(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim().slice(0, 40);
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
if (/^rgba?\(\s*[\d.\s,%/]+\)$/.test(trimmed)) return trimmed;
if (/^[a-zA-Z]{3,20}$/.test(trimmed)) return trimmed;
return undefined;
}
function parseStyle(value: unknown): NodeStyle | undefined {
if (!isRecord(value)) return undefined;
const style: NodeStyle = {};
const background = asColor(value.backgroundColor);
if (background) style.backgroundColor = background;
const text = asColor(value.textColor);
if (text) style.textColor = text;
const border = asColor(value.borderColor);
if (border) style.borderColor = border;
if (typeof value.shape === 'string' && NODE_SHAPES.includes(value.shape as NodeShape)) {
style.shape = value.shape as NodeShape;
}
if (typeof value.fontSize === 'number' && Number.isFinite(value.fontSize)) {
style.fontSize = Math.min(48, Math.max(10, Math.round(value.fontSize)));
}
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
style.width = Math.min(600, Math.max(80, Math.round(value.width)));
}
if (typeof value.bold === 'boolean') style.bold = value.bold;
if (typeof value.italic === 'boolean') style.italic = value.italic;
if (typeof value.textAlign === 'string' && TEXT_ALIGNS.includes(value.textAlign as TextAlign)) {
style.textAlign = value.textAlign as TextAlign;
}
return Object.keys(style).length > 0 ? style : undefined;
}
function parseNode(value: unknown): MindMapNode | null {
if (!isRecord(value)) return null;
const id = asString(value.id, 128);
if (!id) return null;
const type =
typeof value.type === 'string' && NODE_KINDS.includes(value.type as NodeKind)
? (value.type as NodeKind)
: 'standard';
const rawPosition = isRecord(value.position) ? value.position : {};
const node: MindMapNode = {
id,
type,
text: asString(value.text, MAX_TEXT_LENGTH) ?? '',
position: {
x: asFiniteNumber(rawPosition.x, 0),
y: asFiniteNumber(rawPosition.y, 0),
},
parentId: asString(value.parentId, 128) ?? null,
};
if (value.collapsed === true) node.collapsed = true;
const icon = asString(value.icon, 8);
if (icon) node.icon = icon;
// Les URL sont assainies des l'import : aucun javascript: ne peut survivre.
const url = sanitizeUrl(asString(value.url, 2048));
if (url) node.url = url;
const note = asString(value.note, MAX_NOTE_LENGTH);
if (note) node.note = note;
const style = parseStyle(value.style);
if (style) node.style = style;
if (isRecord(value.meta)) node.meta = value.meta;
return node;
}
function parseSettings(value: unknown): MapSettings {
const settings: MapSettings = { ...DEFAULT_SETTINGS };
if (!isRecord(value)) return settings;
if (typeof value.theme === 'string') settings.theme = value.theme.slice(0, 40);
if (typeof value.layout === 'string' && LAYOUTS.includes(value.layout as LayoutKind)) {
settings.layout = value.layout as LayoutKind;
}
if (
typeof value.branchStyle === 'string' &&
BRANCH_STYLES.includes(value.branchStyle as BranchStyle)
) {
settings.branchStyle = value.branchStyle as BranchStyle;
}
if (typeof value.showGrid === 'boolean') settings.showGrid = value.showGrid;
return settings;
}
/**
* Point d'extension des migrations de format.
* Chaque version future ajoutera son etape ici, de la plus ancienne a la plus
* recente, avant la normalisation finale.
*/
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
const document = raw;
const current = version;
// Exemple d'evolution future :
// if (current === 1) { document = migrateV1toV2(document); current = 2; }
if (current !== MAP_FORMAT_VERSION) {
return { ...document, version: MAP_FORMAT_VERSION };
}
return document;
}
/**
* Valide et normalise un document carte provenant d'un fichier ou du serveur.
* Cette fonction ne fait qu'analyser des donnees : rien n'est jamais evalue,
* ni injecte en HTML.
*/
export function parseMapDocument(raw: unknown, options: { maxNodes?: number } = {}): MindMap {
if (!isRecord(raw)) {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide.");
}
const version = raw.version;
if (typeof version !== 'number' || !Number.isFinite(version) || version < 1) {
throw new MapFormatError(
"Ce fichier ne precise aucune version de format : il n'est pas reconnu comme une carte.",
);
}
if (version > MAP_FORMAT_VERSION) {
throw new MapFormatError(
'Cette carte a ete creee avec une version plus recente du format (v' +
version +
"). Mettez a jour l'application pour l'ouvrir.",
);
}
const document = migrate(raw, version);
if (!Array.isArray(document.nodes)) {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (noeuds absents).");
}
const maxNodes = options.maxNodes ?? APP_CONFIG.maxNodes;
if (document.nodes.length > maxNodes) {
throw new MapFormatError(
'Cette carte contient ' +
document.nodes.length +
' noeuds : la limite est de ' +
maxNodes +
'.',
);
}
const seen = new Set<string>();
const nodes: MindMapNode[] = [];
for (const rawNode of document.nodes) {
const node = parseNode(rawNode);
if (!node || seen.has(node.id)) continue;
seen.add(node.id);
nodes.push(node);
}
if (nodes.length === 0) {
throw new MapFormatError('Cette carte ne contient aucun noeud exploitable.');
}
const rawEdges = Array.isArray(document.edges) ? document.edges : [];
const parsedEdges = rawEdges
.filter(isRecord)
.map((edge) => ({
id: asString(edge.id, 256) ?? createId(),
source: asString(edge.source, 128) ?? '',
target: asString(edge.target, 128) ?? '',
}))
.filter((edge) => seen.has(edge.source) && seen.has(edge.target));
// La hierarchie fait autorite ; les liens en sont deduits.
const linked = rebuildHierarchyFromEdges(nodes, parsedEdges).map((node) => ({
...node,
parentId: node.parentId && seen.has(node.parentId) ? node.parentId : null,
}));
// Au moins une racine explicite pour que l'editeur reste coherent.
if (!linked.some((node) => node.type === 'root') && linked[0]) {
linked[0] = { ...linked[0], type: 'root', parentId: null };
}
const rawViewport = isRecord(document.viewport) ? document.viewport : {};
const timestamp = new Date().toISOString();
return {
version: MAP_FORMAT_VERSION,
id: asString(document.id, 128) ?? createId(),
name: asString(document.name, MAX_NAME_LENGTH)?.trim() || 'Carte importee',
createdAt: asString(document.createdAt, 40) ?? timestamp,
updatedAt: asString(document.updatedAt, 40) ?? timestamp,
nodes: linked,
edges: rebuildEdges(linked),
viewport: {
x: asFiniteNumber(rawViewport.x, 0),
y: asFiniteNumber(rawViewport.y, 0),
zoom: Math.min(4, Math.max(0.1, asFiniteNumber(rawViewport.zoom, 1))),
},
settings: parseSettings(document.settings),
};
}
/** Lit un fichier .mmap choisi par l'utilisateur et retourne la carte validee. */
export async function readMapFile(
file: File,
maxSizeMb: number = APP_CONFIG.maxMapSizeMb,
): Promise<MindMap> {
if (file.size > maxSizeMb * 1024 * 1024) {
throw new MapFormatError('Ce fichier depasse la taille maximale de ' + maxSizeMb + ' Mo.');
}
const text = await file.text();
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
throw new MapFormatError("Le fichier selectionne n'est pas une carte valide (JSON illisible).");
}
return parseMapDocument(raw);
}