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:
293
frontend/src/services/mapSerialization.ts
Normal file
293
frontend/src/services/mapSerialization.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user