import { APP_CONFIG } from '@/config/app'; import { MAP_FORMAT_VERSION, type BranchStyle, type FontFamilyId, type IconPosition, type LayoutKind, type MapSettings, type MindMap, type MindMapNode, type NodeBranchStyle, type NodeIconStyle, type NodeKind, type NodeLayout, type NodeShape, type NodeStyle, type ShadowStyle, type SizeMode, type TextAlign, type VerticalAlign, } from '@/types/map'; import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver'; import { SPACING_LIMITS } from '@/features/editor/layout'; 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 VERTICAL_ALIGNS: VerticalAlign[] = ['top', 'middle', 'bottom']; const SIZE_MODES: SizeMode[] = ['auto', 'fixed']; const SHADOW_STYLES: ShadowStyle[] = ['theme', 'none', 'soft', 'strong']; const FONT_FAMILIES: FontFamilyId[] = ['theme', 'sans', 'serif', 'mono', 'rounded']; const ICON_POSITIONS: IconPosition[] = ['left', 'right', 'top']; const ICON_KINDS = ['emoji', 'builtin'] as const; 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 { 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; } if ( typeof value.verticalAlign === 'string' && VERTICAL_ALIGNS.includes(value.verticalAlign as VerticalAlign) ) { style.verticalAlign = value.verticalAlign as VerticalAlign; } if ( typeof value.fontFamily === 'string' && FONT_FAMILIES.includes(value.fontFamily as FontFamilyId) ) { style.fontFamily = value.fontFamily as FontFamilyId; } if (typeof value.shadow === 'string' && SHADOW_STYLES.includes(value.shadow as ShadowStyle)) { style.shadow = value.shadow as ShadowStyle; } if (typeof value.lineHeight === 'number' && Number.isFinite(value.lineHeight)) { style.lineHeight = Math.min( LAYOUT_LIMITS.lineHeight.max, Math.max(LAYOUT_LIMITS.lineHeight.min, Math.round(value.lineHeight * 100) / 100), ); } return Object.keys(style).length > 0 ? style : undefined; } /** Mise en page d'un noeud (v2). Chaque valeur est bornee. */ function parseLayout(value: unknown): NodeLayout | undefined { if (!isRecord(value)) return undefined; const layout: NodeLayout = {}; if (typeof value.widthMode === 'string' && SIZE_MODES.includes(value.widthMode as SizeMode)) { layout.widthMode = value.widthMode as SizeMode; } if (typeof value.heightMode === 'string' && SIZE_MODES.includes(value.heightMode as SizeMode)) { layout.heightMode = value.heightMode as SizeMode; } if (typeof value.width === 'number' && Number.isFinite(value.width)) { layout.width = clampTo(value.width, LAYOUT_LIMITS.width); } if (typeof value.height === 'number' && Number.isFinite(value.height)) { layout.height = clampTo(value.height, LAYOUT_LIMITS.height); } if (typeof value.paddingX === 'number' && Number.isFinite(value.paddingX)) { layout.paddingX = clampTo(value.paddingX, LAYOUT_LIMITS.paddingX); } if (typeof value.paddingY === 'number' && Number.isFinite(value.paddingY)) { layout.paddingY = clampTo(value.paddingY, LAYOUT_LIMITS.paddingY); } if (typeof value.maxTextWidth === 'number' && Number.isFinite(value.maxTextWidth)) { layout.maxTextWidth = clampTo(value.maxTextWidth, LAYOUT_LIMITS.maxTextWidth); } if (typeof value.wrap === 'boolean') layout.wrap = value.wrap; return Object.keys(layout).length > 0 ? layout : undefined; } function parseIconStyle(value: unknown): NodeIconStyle | undefined { if (!isRecord(value)) return undefined; const iconStyle: NodeIconStyle = {}; if ( typeof value.kind === 'string' && (ICON_KINDS as readonly string[]).includes(value.kind) ) { iconStyle.kind = value.kind as NodeIconStyle['kind']; } if ( typeof value.position === 'string' && ICON_POSITIONS.includes(value.position as IconPosition) ) { iconStyle.position = value.position as IconPosition; } if (typeof value.size === 'number' && Number.isFinite(value.size)) { iconStyle.size = clampTo(value.size, LAYOUT_LIMITS.iconSize); } if (typeof value.gap === 'number' && Number.isFinite(value.gap)) { iconStyle.gap = clampTo(value.gap, LAYOUT_LIMITS.iconGap); } return Object.keys(iconStyle).length > 0 ? iconStyle : undefined; } function parseBranch(value: unknown): NodeBranchStyle | undefined { if (!isRecord(value)) return undefined; const branch: NodeBranchStyle = {}; if ( typeof value.style === 'string' && BRANCH_STYLES.includes(value.style as BranchStyle) ) { branch.style = value.style as BranchStyle; } if (typeof value.width === 'number' && Number.isFinite(value.width)) { branch.width = clampTo(value.width, LAYOUT_LIMITS.branchWidth); } const color = asColor(value.color); if (color) branch.color = color; return Object.keys(branch).length > 0 ? branch : 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; // 32 caracteres : suffisant pour un emoji compose ou un identifiant d'icone integree. const icon = asString(value.icon, 32); if (icon) node.icon = icon; const iconStyle = parseIconStyle(value.iconStyle); if (iconStyle) node.iconStyle = iconStyle; // 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; const layout = parseLayout(value.layout); if (layout) node.layout = layout; const branch = parseBranch(value.branch); if (branch) node.branch = branch; 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; if (typeof value.snapToGrid === 'boolean') settings.snapToGrid = value.snapToGrid; if (typeof value.alignmentGuides === 'boolean') settings.alignmentGuides = value.alignmentGuides; if (typeof value.snapGrid === 'number' && Number.isFinite(value.snapGrid)) { settings.snapGrid = Math.min(100, Math.max(2, Math.round(value.snapGrid))); } if (isRecord(value.layoutSpacing)) { const raw = value.layoutSpacing; const parentGap = typeof raw.parentGap === 'number' ? raw.parentGap : undefined; const siblingGap = typeof raw.siblingGap === 'number' ? raw.siblingGap : undefined; if (parentGap !== undefined && siblingGap !== undefined) { settings.layoutSpacing = { parentGap: clampTo(parentGap, SPACING_LIMITS.parentGap), siblingGap: clampTo(siblingGap, SPACING_LIMITS.siblingGap), }; } } return settings; } /** * Migrations de format, appliquees de la plus ancienne a la plus recente. * * v1 -> v2 : la largeur fixe vivait dans `style.width`. Elle rejoint * `layout.width` avec un `widthMode` explicite. Les autres nouveautes de la v2 * sont facultatives : leur absence signifie "herite du theme", il n'y a donc * rien a ecrire dans le document. */ function migrateV1ToV2(document: Record): Record { if (!Array.isArray(document.nodes)) return { ...document, version: 2 }; const nodes = document.nodes.map((rawNode) => { if (!isRecord(rawNode) || !isRecord(rawNode.style)) return rawNode; const { width, ...styleRest } = rawNode.style; if (typeof width !== 'number' || !Number.isFinite(width)) return rawNode; const existingLayout = isRecord(rawNode.layout) ? rawNode.layout : {}; return { ...rawNode, style: styleRest, layout: { widthMode: 'fixed', width, ...existingLayout }, }; }); return { ...document, nodes, version: 2 }; } function migrate(raw: Record, version: number): Record { let document = raw; let current = version; if (current === 1) { document = migrateV1ToV2(document); current = 2; } if (current !== MAP_FORMAT_VERSION) { // Version intermediaire inconnue : lecture au mieux, sans perdre de donnee. return { ...document, version: MAP_FORMAT_VERSION }; } return document; } /** Provenance d'un document : ne sert qu'a formuler les messages d'erreur. */ export type MapDocumentSource = 'file' | 'storage' | 'share'; export interface NormalizeOptions { maxNodes?: number; source?: MapDocumentSource; /** * Autorise un document sans noeud. Vrai pour les cartes deja enregistrees : * l'utilisateur a le droit d'avoir vide sa carte, et la rejeter reviendrait a * la lui rendre inaccessible. */ allowEmpty?: boolean; } const INVALID_DOCUMENT_MESSAGE: Record = { file: "Le fichier selectionne n'est pas une carte valide.", storage: "Cette carte enregistree est illisible et n'a pas pu etre ouverte.", share: "Cette carte partagee est illisible.", }; /** * Point d'entree unique des documents carte. * * Toute carte qui entre dans l'editeur passe par ici, quelle que soit sa * provenance : fichier .mmap, IndexedDB ou partage serveur. Le pipeline est * toujours le meme : * * source -> validation -> migration de version -> normalisation -> modele courant * * La normalisation est non destructive : le document d'origine n'est jamais * modifie, et rien n'est reecrit en base. Une carte v1 restee en IndexedDB * continue donc de s'ouvrir exactement comme avant, et n'est reecrite que si * l'utilisateur la modifie. * * Cette fonction ne fait qu'analyser des donnees : rien n'est jamais evalue ni * injecte en HTML. */ export function normalizeMapDocument(raw: unknown, options: NormalizeOptions = {}): MindMap { const source = options.source ?? 'file'; if (!isRecord(raw)) { throw new MapFormatError(INVALID_DOCUMENT_MESSAGE[source]); } 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(`${INVALID_DOCUMENT_MESSAGE[source]} (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(); 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 && !options.allowEmpty) { 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.length > 0 && !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.02, 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 { 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 normalizeMapDocument(raw, { source: 'file' }); }