Phase 2 : mind map bilaterale, relations, templates et Markdown
Format .mmap v4 : `node.side` (cote d'une branche de niveau 1) et `MindMap.relations` (relations secondaires). Migration v3 -> v4 sans perte, les cartes v1/v2/v3 restent ouvrables telles quelles. Une relation est une connexion purement visuelle : elle ne touche jamais parentId, order, les enfants, le repli ni l'auto-layout. `withEdges()` continue de deriver les liens de parentId et se contente de retirer les relations dont une extremite a disparu. Mind map bilaterale : repartition gloutonne et deterministe des branches selon la taille des sous-arbres, heritage du cote par sous-arbre, deux passes dagre recalees sur la racine. Choisir une disposition ne deplace plus la carte : seul Reorganiser applique. Ajoute aussi une galerie de huit modeles locaux, l'import Markdown (fichier ou texte, nouvelle carte ou sous-arbre) reutilisant l'analyseur d'outline existant, et l'export Markdown de la hierarchie. Corrige un bug de rendu : React Flow ne remesure pas les poignees quand elles changent de cote, ce qui faisait disparaitre les liens concernes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { MAP_FORMAT_VERSION, type MindMap, type MindMapNode } from '@/types/map';
|
||||
import { remapRelations } from '@/services/relations';
|
||||
import { createId } from '@/utils/id';
|
||||
import { rebuildEdges } from '@/utils/tree';
|
||||
|
||||
@@ -21,6 +22,7 @@ export function createNode(partial: Partial<MindMapNode> & { text: string }): Mi
|
||||
position: partial.position ?? { x: 0, y: 0 },
|
||||
parentId: partial.parentId ?? null,
|
||||
...(partial.order !== undefined ? { order: partial.order } : {}),
|
||||
...(partial.side !== undefined ? { side: partial.side } : {}),
|
||||
...(partial.collapsed !== undefined ? { collapsed: partial.collapsed } : {}),
|
||||
...(partial.icon ? { icon: partial.icon } : {}),
|
||||
...(partial.url ? { url: partial.url } : {}),
|
||||
@@ -45,6 +47,7 @@ export function createEmptyMap(name: string): MindMap {
|
||||
updatedAt: timestamp,
|
||||
nodes: [root],
|
||||
edges: [],
|
||||
relations: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
};
|
||||
@@ -72,6 +75,9 @@ export function cloneMap(map: MindMap, name?: string): MindMap {
|
||||
updatedAt: timestamp,
|
||||
nodes,
|
||||
edges: rebuildEdges(nodes),
|
||||
// Les relations suivent la copie : leurs extremites pointent vers les
|
||||
// nouveaux identifiants, jamais vers ceux de la carte d'origine.
|
||||
relations: remapRelations(map.relations ?? [], idMapping),
|
||||
viewport: { ...map.viewport },
|
||||
settings: { ...map.settings },
|
||||
};
|
||||
@@ -126,6 +132,7 @@ export function createSampleMap(): MindMap {
|
||||
updatedAt: timestamp,
|
||||
nodes,
|
||||
edges: rebuildEdges(nodes),
|
||||
relations: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
};
|
||||
|
||||
255
frontend/src/services/mapRelationSerialization.test.ts
Normal file
255
frontend/src/services/mapRelationSerialization.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
|
||||
import { MapFormatError, normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
|
||||
|
||||
/** Document v4 minimal : deux branches opposees et une relation entre feuilles. */
|
||||
function documentV4(): Record<string, unknown> {
|
||||
return {
|
||||
version: 4,
|
||||
id: 'map-1',
|
||||
name: 'Infrastructure',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
nodes: [
|
||||
{ id: 'root', type: 'root', text: 'Infrastructure', position: { x: 0, y: 0 }, parentId: null },
|
||||
{
|
||||
id: 'reseau',
|
||||
type: 'standard',
|
||||
text: 'Reseau',
|
||||
position: { x: 240, y: 0 },
|
||||
parentId: 'root',
|
||||
order: 0,
|
||||
side: 'right',
|
||||
},
|
||||
{
|
||||
id: 'firewall',
|
||||
type: 'standard',
|
||||
text: 'Firewall',
|
||||
position: { x: 480, y: 0 },
|
||||
parentId: 'reseau',
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
type: 'standard',
|
||||
text: 'Services',
|
||||
position: { x: -240, y: 0 },
|
||||
parentId: 'root',
|
||||
order: 1,
|
||||
side: 'left',
|
||||
},
|
||||
{
|
||||
id: 'web',
|
||||
type: 'standard',
|
||||
text: 'Web',
|
||||
position: { x: -480, y: 0 },
|
||||
parentId: 'services',
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
relations: [
|
||||
{
|
||||
id: 'r-1',
|
||||
sourceId: 'firewall',
|
||||
targetId: 'web',
|
||||
label: 'protege',
|
||||
style: 'bezier',
|
||||
color: '#a855f7',
|
||||
width: 2,
|
||||
dashed: true,
|
||||
markerEnd: 'arrow',
|
||||
},
|
||||
],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
settings: { theme: 'default', layout: 'mindmap-both', branchStyle: 'bezier', showGrid: true },
|
||||
};
|
||||
}
|
||||
|
||||
describe('lecture d un document v4', () => {
|
||||
it('conserve les cotes et les relations', () => {
|
||||
const map = normalizeMapDocument(documentV4(), { source: 'file' });
|
||||
expect(map.version).toBe(MAP_FORMAT_VERSION);
|
||||
|
||||
const sides = new Map(map.nodes.map((node) => [node.id, node.side]));
|
||||
expect(sides.get('reseau')).toBe('right');
|
||||
expect(sides.get('services')).toBe('left');
|
||||
|
||||
expect(map.relations).toHaveLength(1);
|
||||
expect(map.relations[0]?.label).toBe('protege');
|
||||
expect(map.relations[0]?.dashed).toBe(true);
|
||||
expect(map.relations[0]?.markerEnd).toBe('arrow');
|
||||
expect(map.settings.layout).toBe('mindmap-both');
|
||||
});
|
||||
|
||||
it('ne modifie pas le document fourni', () => {
|
||||
const raw = documentV4();
|
||||
const before = JSON.stringify(raw);
|
||||
normalizeMapDocument(raw, { source: 'file' });
|
||||
expect(JSON.stringify(raw)).toBe(before);
|
||||
});
|
||||
|
||||
it('est idempotent : renormaliser ne change plus rien', () => {
|
||||
const once = normalizeMapDocument(documentV4(), { source: 'file' });
|
||||
const twice = normalizeMapDocument(JSON.parse(JSON.stringify(once)), { source: 'file' });
|
||||
expect(twice.nodes).toEqual(once.nodes);
|
||||
expect(twice.relations).toEqual(once.relations);
|
||||
});
|
||||
|
||||
it('survit a un aller-retour complet', () => {
|
||||
const map = normalizeMapDocument(documentV4(), { source: 'file' });
|
||||
const again = normalizeMapDocument(JSON.parse(serializeMap(map)), { source: 'file' });
|
||||
expect(again.relations).toEqual(map.relations);
|
||||
expect(again.nodes.map((node) => [node.id, node.side])).toEqual(
|
||||
map.nodes.map((node) => [node.id, node.side]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relations incoherentes', () => {
|
||||
it('retire une relation dont la source n existe pas', () => {
|
||||
const raw = documentV4();
|
||||
(raw.relations as Array<Record<string, unknown>>)[0]!.sourceId = 'fantome';
|
||||
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('retire une relation dont la cible n existe pas', () => {
|
||||
const raw = documentV4();
|
||||
(raw.relations as Array<Record<string, unknown>>)[0]!.targetId = 'fantome';
|
||||
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('retire une relation bouclant sur elle-meme', () => {
|
||||
const raw = documentV4();
|
||||
(raw.relations as Array<Record<string, unknown>>)[0]!.targetId = 'firewall';
|
||||
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ne garde qu une relation par couple source / cible', () => {
|
||||
const raw = documentV4();
|
||||
const relations = raw.relations as Array<Record<string, unknown>>;
|
||||
relations.push({ ...relations[0], id: 'r-2' });
|
||||
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignore les champs de style invalides sans perdre la relation', () => {
|
||||
const raw = documentV4();
|
||||
const relation = (raw.relations as Array<Record<string, unknown>>)[0] as Record<string, unknown>;
|
||||
relation.style = 'zigzag';
|
||||
relation.color = 'javascript:alert(1)';
|
||||
relation.markerEnd = 'etoile';
|
||||
|
||||
const parsed = normalizeMapDocument(raw, { source: 'file' }).relations[0];
|
||||
expect(parsed?.style).toBeUndefined();
|
||||
expect(parsed?.color).toBeUndefined();
|
||||
expect(parsed?.markerEnd).toBeUndefined();
|
||||
expect(parsed?.label).toBe('protege');
|
||||
});
|
||||
|
||||
it('ignore un cote invalide', () => {
|
||||
const raw = documentV4();
|
||||
(raw.nodes as Array<Record<string, unknown>>)[1]!.side = 'haut';
|
||||
const map = normalizeMapDocument(raw, { source: 'file' });
|
||||
expect(map.nodes.find((node) => node.id === 'reseau')?.side).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuse un document depassant la limite de relations', () => {
|
||||
const raw = documentV4();
|
||||
raw.relations = Array.from({ length: 30 }, (_item, index) => ({
|
||||
id: `r-${index}`,
|
||||
sourceId: 'firewall',
|
||||
targetId: 'web',
|
||||
}));
|
||||
expect(() => normalizeMapDocument(raw, { source: 'file', maxNodes: 10 })).toThrow(
|
||||
MapFormatError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrations vers la v4', () => {
|
||||
function legacyDocument(version: number): Record<string, unknown> {
|
||||
return {
|
||||
version,
|
||||
id: `map-v${version}`,
|
||||
name: `Carte v${version}`,
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
nodes: [
|
||||
{
|
||||
id: 'root',
|
||||
type: 'root',
|
||||
text: 'Racine',
|
||||
position: { x: 0, y: 0 },
|
||||
parentId: null,
|
||||
style: version === 1 ? { width: 220, backgroundColor: '#ffffff' } : undefined,
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
type: 'standard',
|
||||
text: 'A',
|
||||
position: { x: 200, y: 0 },
|
||||
parentId: 'root',
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
type: 'standard',
|
||||
text: 'B',
|
||||
position: { x: 200, y: 80 },
|
||||
parentId: 'root',
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-root-a', source: 'root', target: 'a' },
|
||||
{ id: 'e-root-b', source: 'root', target: 'b' },
|
||||
],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
settings: { theme: 'default', layout: 'mindmap-right', branchStyle: 'bezier' },
|
||||
};
|
||||
}
|
||||
|
||||
it('ouvre une carte v1 et la ramene au format courant', () => {
|
||||
const map = normalizeMapDocument(legacyDocument(1), { source: 'file' });
|
||||
expect(map.version).toBe(4);
|
||||
expect(map.relations).toEqual([]);
|
||||
// La largeur fixe de la v1 a rejoint layout.width.
|
||||
const root = map.nodes.find((node) => node.id === 'root');
|
||||
expect(root?.layout?.width).toBe(220);
|
||||
expect(root?.layout?.widthMode).toBe('fixed');
|
||||
expect(root?.style?.backgroundColor).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('ouvre une carte v2 en materialisant l ordre puis la v4', () => {
|
||||
const map = normalizeMapDocument(legacyDocument(2), { source: 'file' });
|
||||
expect(map.version).toBe(4);
|
||||
expect(map.nodes.find((node) => node.id === 'a')?.order).toBe(0);
|
||||
expect(map.nodes.find((node) => node.id === 'b')?.order).toBe(1);
|
||||
expect(map.relations).toEqual([]);
|
||||
});
|
||||
|
||||
it('ouvre une carte v3 sans rien inventer', () => {
|
||||
const map = normalizeMapDocument(legacyDocument(3), { source: 'file' });
|
||||
expect(map.version).toBe(4);
|
||||
expect(map.relations).toEqual([]);
|
||||
// Aucun cote : la carte reste exactement telle qu'elle etait.
|
||||
for (const node of map.nodes) expect(node.side).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepte une carte historique lue depuis IndexedDB', () => {
|
||||
const stored = legacyDocument(2) as unknown as MindMap;
|
||||
const map = normalizeMapDocument(stored, { source: 'storage', allowEmpty: true });
|
||||
expect(map.version).toBe(4);
|
||||
expect(map.nodes).toHaveLength(3);
|
||||
expect(map.relations).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuse un document plus recent que le format connu', () => {
|
||||
const raw = { ...documentV4(), version: 99 };
|
||||
expect(() => normalizeMapDocument(raw, { source: 'file' })).toThrow(MapFormatError);
|
||||
});
|
||||
|
||||
it('accepte une carte v4 sans champ relations', () => {
|
||||
const raw = documentV4();
|
||||
delete raw.relations;
|
||||
expect(normalizeMapDocument(raw, { source: 'file' }).relations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,15 @@ import {
|
||||
type MapSettings,
|
||||
type MindMap,
|
||||
type MindMapNode,
|
||||
type MindMapRelation,
|
||||
type NodeBranchStyle,
|
||||
type NodeIconStyle,
|
||||
type NodeKind,
|
||||
type NodeLayout,
|
||||
type NodeShape,
|
||||
type NodeSide,
|
||||
type NodeStyle,
|
||||
type RelationMarker,
|
||||
type ShadowStyle,
|
||||
type SizeMode,
|
||||
type TextAlign,
|
||||
@@ -22,6 +25,7 @@ import {
|
||||
import { LAYOUT_LIMITS, clampTo } from '@/features/editor/nodeStyleResolver';
|
||||
import { SPACING_LIMITS } from '@/features/editor/layout';
|
||||
import { DEFAULT_SETTINGS } from '@/services/mapFactory';
|
||||
import { createRelationId, pruneRelations } from '@/services/relations';
|
||||
import { createId } from '@/utils/id';
|
||||
import {
|
||||
flattenInOrder,
|
||||
@@ -53,6 +57,9 @@ const FONT_FAMILIES: FontFamilyId[] = ['theme', 'sans', 'serif', 'mono', 'rounde
|
||||
const ICON_POSITIONS: IconPosition[] = ['left', 'right', 'top'];
|
||||
const ICON_KINDS = ['emoji', 'builtin'] as const;
|
||||
const BRANCH_STYLES: BranchStyle[] = ['straight', 'curved', 'bezier'];
|
||||
const NODE_SIDES: NodeSide[] = ['left', 'right'];
|
||||
const RELATION_MARKERS: RelationMarker[] = ['none', 'arrow'];
|
||||
const MAX_LABEL_LENGTH = 120;
|
||||
const LAYOUTS: LayoutKind[] = [
|
||||
'mindmap-right',
|
||||
'mindmap-left',
|
||||
@@ -251,6 +258,10 @@ function parseNode(value: unknown): MindMapNode | null {
|
||||
node.order = Math.max(0, Math.round(value.order));
|
||||
}
|
||||
|
||||
if (typeof value.side === 'string' && NODE_SIDES.includes(value.side as NodeSide)) {
|
||||
node.side = value.side as NodeSide;
|
||||
}
|
||||
|
||||
if (value.collapsed === true) node.collapsed = true;
|
||||
|
||||
// 32 caracteres : suffisant pour un emoji compose ou un identifiant d'icone integree.
|
||||
@@ -281,6 +292,54 @@ function parseNode(value: unknown): MindMapNode | null {
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relation secondaire (v4).
|
||||
*
|
||||
* Validee comme le reste du document : identifiants bornes, couleur filtree,
|
||||
* enumerations verifiees. Une relation incoherente est ignoree plutot que
|
||||
* corrigee au jugement.
|
||||
*/
|
||||
function parseRelation(value: unknown): MindMapRelation | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const sourceId = asString(value.sourceId, 128);
|
||||
const targetId = asString(value.targetId, 128);
|
||||
if (!sourceId || !targetId || sourceId === targetId) return null;
|
||||
|
||||
const relation: MindMapRelation = {
|
||||
id: asString(value.id, 128) ?? createRelationId(),
|
||||
sourceId,
|
||||
targetId,
|
||||
};
|
||||
|
||||
const label = asString(value.label, MAX_LABEL_LENGTH)?.trim();
|
||||
if (label) relation.label = label;
|
||||
|
||||
if (typeof value.style === 'string' && BRANCH_STYLES.includes(value.style as BranchStyle)) {
|
||||
relation.style = value.style as BranchStyle;
|
||||
}
|
||||
const color = asColor(value.color);
|
||||
if (color) relation.color = color;
|
||||
if (typeof value.width === 'number' && Number.isFinite(value.width)) {
|
||||
relation.width = clampTo(value.width, LAYOUT_LIMITS.branchWidth);
|
||||
}
|
||||
if (typeof value.dashed === 'boolean') relation.dashed = value.dashed;
|
||||
if (
|
||||
typeof value.markerStart === 'string' &&
|
||||
RELATION_MARKERS.includes(value.markerStart as RelationMarker)
|
||||
) {
|
||||
relation.markerStart = value.markerStart as RelationMarker;
|
||||
}
|
||||
if (
|
||||
typeof value.markerEnd === 'string' &&
|
||||
RELATION_MARKERS.includes(value.markerEnd as RelationMarker)
|
||||
) {
|
||||
relation.markerEnd = value.markerEnd as RelationMarker;
|
||||
}
|
||||
|
||||
return relation;
|
||||
}
|
||||
|
||||
function parseSettings(value: unknown): MapSettings {
|
||||
const settings: MapSettings = { ...DEFAULT_SETTINGS };
|
||||
if (!isRecord(value)) return settings;
|
||||
@@ -367,6 +426,18 @@ function migrateV2ToV3(document: Record<string, unknown>): Record<string, unknow
|
||||
return { ...document, nodes, version: 3 };
|
||||
}
|
||||
|
||||
/**
|
||||
* v3 -> v4 : rien a reecrire.
|
||||
*
|
||||
* Une carte v3 n'a ni cote ni relation. Absence de cote signifie "a repartir
|
||||
* automatiquement", et absence de relation signifie "aucune" : la carte
|
||||
* s'affiche donc exactement comme avant tant que l'utilisateur ne demande pas
|
||||
* la disposition bilaterale. La migration se contente d'estampiller la version.
|
||||
*/
|
||||
function migrateV3ToV4(document: Record<string, unknown>): Record<string, unknown> {
|
||||
return { ...document, version: 4 };
|
||||
}
|
||||
|
||||
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
|
||||
let document = raw;
|
||||
let current = version;
|
||||
@@ -379,6 +450,10 @@ function migrate(raw: Record<string, unknown>, version: number): Record<string,
|
||||
document = migrateV2ToV3(document);
|
||||
current = 3;
|
||||
}
|
||||
if (current === 3) {
|
||||
document = migrateV3ToV4(document);
|
||||
current = 4;
|
||||
}
|
||||
|
||||
if (current !== MAP_FORMAT_VERSION) {
|
||||
// Version intermediaire inconnue : lecture au mieux, sans perdre de donnee.
|
||||
@@ -503,6 +578,35 @@ export function normalizeMapDocument(raw: unknown, options: NormalizeOptions = {
|
||||
// est toujours dans la meme forme canonique, quelle que soit sa provenance.
|
||||
const normalised = flattenInOrder(normalizeChildOrders(linked));
|
||||
|
||||
// Les relations sont une donnee propre : rien ne les deduit de la hierarchie,
|
||||
// et rien dans la hierarchie ne doit dependre d'elles. On se contente de
|
||||
// retirer celles dont une extremite n'existe plus.
|
||||
const rawRelations = Array.isArray(document.relations) ? document.relations : [];
|
||||
if (rawRelations.length > maxNodes * 2) {
|
||||
throw new MapFormatError(
|
||||
'Cette carte contient ' +
|
||||
rawRelations.length +
|
||||
' relations : la limite est de ' +
|
||||
maxNodes * 2 +
|
||||
'.',
|
||||
);
|
||||
}
|
||||
|
||||
const seenRelations = new Set<string>();
|
||||
const relations = pruneRelations(
|
||||
rawRelations
|
||||
.map(parseRelation)
|
||||
.filter((relation): relation is MindMapRelation => relation !== null)
|
||||
.filter((relation) => {
|
||||
// Un doublon exact serait invisible : le second trait se superposerait.
|
||||
const key = `${relation.sourceId}>${relation.targetId}`;
|
||||
if (seenRelations.has(key)) return false;
|
||||
seenRelations.add(key);
|
||||
return true;
|
||||
}),
|
||||
normalised,
|
||||
);
|
||||
|
||||
return {
|
||||
version: MAP_FORMAT_VERSION,
|
||||
id: asString(document.id, 128) ?? createId(),
|
||||
@@ -511,6 +615,7 @@ export function normalizeMapDocument(raw: unknown, options: NormalizeOptions = {
|
||||
updatedAt: asString(document.updatedAt, 40) ?? timestamp,
|
||||
nodes: normalised,
|
||||
edges: rebuildEdges(normalised),
|
||||
relations,
|
||||
viewport: {
|
||||
x: asFiniteNumber(rawViewport.x, 0),
|
||||
y: asFiniteNumber(rawViewport.y, 0),
|
||||
|
||||
262
frontend/src/services/markdown.test.ts
Normal file
262
frontend/src/services/markdown.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MarkdownError,
|
||||
buildMapFromMarkdown,
|
||||
exportMarkdown,
|
||||
parseMarkdown,
|
||||
} from '@/services/markdown';
|
||||
import { createEmptyMap } from '@/services/mapFactory';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { getOrderedChildren } from '@/utils/tree';
|
||||
|
||||
const SAMPLE = `# Infrastructure
|
||||
|
||||
## Reseau
|
||||
- VLAN
|
||||
- VPN
|
||||
- Firewall
|
||||
|
||||
## Virtualisation
|
||||
- Proxmox
|
||||
- VM
|
||||
- LXC
|
||||
- Docker
|
||||
`;
|
||||
|
||||
function texts(nodes: Array<{ text: string }>): string[] {
|
||||
return nodes.map((node) => node.text);
|
||||
}
|
||||
|
||||
describe('analyse Markdown', () => {
|
||||
it('construit la hierarchie a partir des titres et des listes', () => {
|
||||
const result = parseMarkdown(SAMPLE);
|
||||
expect(result.title).toBe('Infrastructure');
|
||||
expect(result.roots).toHaveLength(1);
|
||||
|
||||
const root = result.roots[0];
|
||||
expect(texts(root?.children ?? [])).toEqual(['Reseau', 'Virtualisation']);
|
||||
|
||||
const reseau = root?.children[0];
|
||||
expect(texts(reseau?.children ?? [])).toEqual(['VLAN', 'VPN', 'Firewall']);
|
||||
|
||||
const virtualisation = root?.children[1];
|
||||
expect(texts(virtualisation?.children ?? [])).toEqual(['Proxmox', 'Docker']);
|
||||
expect(texts(virtualisation?.children[0]?.children ?? [])).toEqual(['VM', 'LXC']);
|
||||
expect(result.count).toBe(10);
|
||||
});
|
||||
|
||||
it('gere trois niveaux de titres', () => {
|
||||
const result = parseMarkdown('# A\n## B\n### C\n');
|
||||
expect(result.roots[0]?.children[0]?.children[0]?.text).toBe('C');
|
||||
});
|
||||
|
||||
it('accepte un niveau de titre saute', () => {
|
||||
// Un H3 directement sous un H1 se rattache au titre ouvert le plus proche.
|
||||
const result = parseMarkdown('# A\n### C\n');
|
||||
expect(result.roots[0]?.children[0]?.text).toBe('C');
|
||||
});
|
||||
|
||||
it('reprend un titre de niveau superieur apres un plus profond', () => {
|
||||
const result = parseMarkdown('# A\n### C\n## B\n');
|
||||
expect(texts(result.roots[0]?.children ?? [])).toEqual(['C', 'B']);
|
||||
});
|
||||
|
||||
it('lit les listes numerotees', () => {
|
||||
const result = parseMarkdown('# A\n1. un\n2. deux\n');
|
||||
expect(texts(result.roots[0]?.children ?? [])).toEqual(['un', 'deux']);
|
||||
});
|
||||
|
||||
it('accepte les puces * et +', () => {
|
||||
const result = parseMarkdown('# A\n* un\n+ deux\n');
|
||||
expect(texts(result.roots[0]?.children ?? [])).toEqual(['un', 'deux']);
|
||||
});
|
||||
|
||||
it('accepte un document fait uniquement de listes', () => {
|
||||
const result = parseMarkdown('- un\n - un.a\n- deux\n');
|
||||
expect(texts(result.roots)).toEqual(['un', 'deux']);
|
||||
expect(texts(result.roots[0]?.children ?? [])).toEqual(['un.a']);
|
||||
expect(result.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it('conserve les caracteres Unicode', () => {
|
||||
const result = parseMarkdown('# Sécurité réseau\n- Pare-feu «actif»\n- 日本語\n');
|
||||
expect(result.roots[0]?.text).toBe('Sécurité réseau');
|
||||
expect(texts(result.roots[0]?.children ?? [])).toEqual(['Pare-feu «actif»', '日本語']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paragraphes et blocs de code', () => {
|
||||
it('met un paragraphe dans la note du titre au-dessus', () => {
|
||||
const result = parseMarkdown(
|
||||
'# A\n\n## VPN\n\nLe VPN permet aux utilisateurs distants\nd acceder aux ressources internes.\n',
|
||||
);
|
||||
const vpn = result.roots[0]?.children[0];
|
||||
expect(vpn?.text).toBe('VPN');
|
||||
expect(vpn?.note).toContain('Le VPN permet aux utilisateurs distants');
|
||||
expect(vpn?.note).toContain('ressources internes');
|
||||
});
|
||||
|
||||
it('met un bloc de code dans la note, sans creer de noeuds', () => {
|
||||
const result = parseMarkdown('# A\n\n## Docker\n\n```bash\ndocker compose up -d\n```\n');
|
||||
const docker = result.roots[0]?.children[0];
|
||||
expect(docker?.note).toContain('docker compose up -d');
|
||||
expect(docker?.children).toHaveLength(0);
|
||||
expect(result.count).toBe(2);
|
||||
});
|
||||
|
||||
it('n interprete jamais le contenu : la note reste du texte', () => {
|
||||
const result = parseMarkdown('# A\n\n<script>alert(1)</script>\n');
|
||||
expect(result.roots[0]?.note).toBe('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
it('attache un paragraphe qui suit une liste au dernier element cree', () => {
|
||||
const result = parseMarkdown('# A\n- un\n\nDetail de un.\n');
|
||||
expect(result.roots[0]?.children[0]?.note).toBe('Detail de un.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('documents refuses', () => {
|
||||
it('refuse un document vide', () => {
|
||||
expect(() => parseMarkdown('')).toThrow(MarkdownError);
|
||||
});
|
||||
|
||||
it('refuse un document sans titre ni liste', () => {
|
||||
expect(() => parseMarkdown('juste du texte\net encore du texte')).toThrow(MarkdownError);
|
||||
});
|
||||
|
||||
it('signale la ligne exacte d une indentation incoherente', () => {
|
||||
const text = '# A\n- un\n - trop profond\n - incoherent\n';
|
||||
try {
|
||||
parseMarkdown(text);
|
||||
throw new Error('aurait du echouer');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(MarkdownError);
|
||||
expect((error as MarkdownError).line).toBe(4);
|
||||
expect((error as MarkdownError).message).toContain('Ligne 4');
|
||||
}
|
||||
});
|
||||
|
||||
it('refuse un document au-dela de la limite de noeuds', () => {
|
||||
const many = Array.from({ length: 12 }, (_item, index) => `- item ${index}`).join('\n');
|
||||
expect(() => parseMarkdown(`# A\n${many}`, 5)).toThrow(MarkdownError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('import comme nouvelle carte', () => {
|
||||
it('fait du titre H1 la racine et le nom de la carte', () => {
|
||||
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
|
||||
expect(map.name).toBe('Infrastructure');
|
||||
expect(map.nodes[0]?.text).toBe('Infrastructure');
|
||||
expect(map.nodes).toHaveLength(10);
|
||||
expect(map.relations).toEqual([]);
|
||||
});
|
||||
|
||||
it('utilise le nom du fichier quand il n y a pas de H1 unique', () => {
|
||||
const map = buildMapFromMarkdown('- un\n- deux\n', 'mes notes');
|
||||
expect(map.name).toBe('mes notes');
|
||||
expect(texts(getOrderedChildren(map.nodes, map.nodes[0]?.id as string))).toEqual([
|
||||
'un',
|
||||
'deux',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reconstruit la hierarchie et positionne les noeuds', () => {
|
||||
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
|
||||
const root = map.nodes[0];
|
||||
const branches = getOrderedChildren(map.nodes, root?.id as string);
|
||||
expect(texts(branches)).toEqual(['Reseau', 'Virtualisation']);
|
||||
|
||||
const proxmox = map.nodes.find((node) => node.text === 'Proxmox');
|
||||
expect(texts(getOrderedChildren(map.nodes, proxmox?.id as string))).toEqual(['VM', 'LXC']);
|
||||
|
||||
// Les positions viennent de l'auto-layout : rien n'est empile a l'origine.
|
||||
const distinct = new Set(map.nodes.map((node) => `${node.position.x}:${node.position.y}`));
|
||||
expect(distinct.size).toBe(map.nodes.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('import comme sous-arbre', () => {
|
||||
it('insere le contenu sous le noeud vise en une seule entree d historique', () => {
|
||||
const store = useEditorStore.getState();
|
||||
store.loadMap(createEmptyMap('Carte'));
|
||||
const rootId = useEditorStore.getState().map?.nodes[0]?.id as string;
|
||||
|
||||
const parsed = parseMarkdown(SAMPLE);
|
||||
const created = useEditorStore.getState().insertOutline(rootId, parsed.roots);
|
||||
expect(created).toBe(10);
|
||||
expect(useEditorStore.getState().map?.nodes).toHaveLength(11);
|
||||
|
||||
useEditorStore.getState().undo();
|
||||
expect(useEditorStore.getState().map?.nodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reporte les notes des paragraphes sur les noeuds crees', () => {
|
||||
const store = useEditorStore.getState();
|
||||
store.loadMap(createEmptyMap('Carte'));
|
||||
const rootId = useEditorStore.getState().map?.nodes[0]?.id as string;
|
||||
|
||||
const parsed = parseMarkdown('## VPN\n\nAcces distant securise.\n');
|
||||
useEditorStore.getState().insertOutline(rootId, parsed.roots);
|
||||
|
||||
const vpn = useEditorStore.getState().map?.nodes.find((node) => node.text === 'VPN');
|
||||
expect(vpn?.note).toBe('Acces distant securise.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('export Markdown', () => {
|
||||
it('ecrit les trois premiers niveaux en titres et la suite en listes', () => {
|
||||
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
|
||||
const markdown = exportMarkdown(map);
|
||||
|
||||
expect(markdown).toContain('# Infrastructure');
|
||||
expect(markdown).toContain('## Reseau');
|
||||
expect(markdown).toContain('### VLAN');
|
||||
expect(markdown).toContain('### Proxmox');
|
||||
// VM et LXC sont freres sous Proxmox : meme niveau de liste.
|
||||
expect(markdown).toContain('- VM');
|
||||
expect(markdown).toContain('- LXC');
|
||||
// Un titre est toujours precede d'une ligne vide.
|
||||
expect(markdown).toContain('\n\n### Docker');
|
||||
});
|
||||
|
||||
it('ecrit les notes sous leur titre', () => {
|
||||
const map = buildMapFromMarkdown('# A\n\n## VPN\n\nAcces distant.\n', 'fichier');
|
||||
expect(exportMarkdown(map)).toContain('Acces distant.');
|
||||
});
|
||||
|
||||
it('ne represente pas les relations secondaires', () => {
|
||||
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
|
||||
const firewall = map.nodes.find((node) => node.text === 'Firewall') as { id: string };
|
||||
const docker = map.nodes.find((node) => node.text === 'Docker') as { id: string };
|
||||
|
||||
const withRelation = {
|
||||
...map,
|
||||
relations: [
|
||||
{ id: 'r-1', sourceId: firewall.id, targetId: docker.id, label: 'protege' },
|
||||
],
|
||||
};
|
||||
const markdown = exportMarkdown(withRelation);
|
||||
expect(markdown).not.toContain('protege');
|
||||
expect(markdown).toContain('# Infrastructure');
|
||||
});
|
||||
|
||||
it('conserve la hierarchie lors d un aller-retour', () => {
|
||||
const map = buildMapFromMarkdown(SAMPLE, 'fichier');
|
||||
const again = buildMapFromMarkdown(exportMarkdown(map), 'fichier');
|
||||
|
||||
const flatten = (source: typeof map, parentId: string | null, depth: number): string[] =>
|
||||
getOrderedChildren(source.nodes, parentId).flatMap((node) => [
|
||||
`${depth}:${node.text}`,
|
||||
...flatten(source, node.id, depth + 1),
|
||||
]);
|
||||
|
||||
expect(flatten(again, null, 0)).toEqual(flatten(map, null, 0));
|
||||
});
|
||||
|
||||
it('respecte l ordre des freres', () => {
|
||||
const map = buildMapFromMarkdown('# A\n- premier\n- deuxieme\n- troisieme\n', 'fichier');
|
||||
const markdown = exportMarkdown(map);
|
||||
expect(markdown.indexOf('premier')).toBeLessThan(markdown.indexOf('deuxieme'));
|
||||
expect(markdown.indexOf('deuxieme')).toBeLessThan(markdown.indexOf('troisieme'));
|
||||
});
|
||||
});
|
||||
319
frontend/src/services/markdown.ts
Normal file
319
frontend/src/services/markdown.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { APP_CONFIG } from '@/config/app';
|
||||
import { type MindMap, type MindMapNode } from '@/types/map';
|
||||
import {
|
||||
OutlineError,
|
||||
isListItem,
|
||||
parseOutline,
|
||||
type OutlineNode,
|
||||
} from '@/services/outlineParser';
|
||||
import { computeLayout } from '@/features/editor/layout';
|
||||
import { getTheme } from '@/features/editor/themes';
|
||||
import { createEmptyMap, createNode } from '@/services/mapFactory';
|
||||
import { getOrderedChildren, rebuildEdges } from '@/utils/tree';
|
||||
|
||||
/**
|
||||
* Markdown <-> mind map.
|
||||
*
|
||||
* L'import ne cherche pas a etre un lecteur Markdown complet : il retient ce
|
||||
* qui porte une hierarchie (titres et listes) et met le reste (paragraphes,
|
||||
* blocs de code) dans la note du noeud courant. Rien n'est interprete comme du
|
||||
* HTML, rien n'est execute : le texte reste du texte.
|
||||
*
|
||||
* Les listes sont deleguees a `parseOutline`, deja utilise par l'import
|
||||
* d'outline : il n'y a qu'un seul analyseur d'indentation dans le projet.
|
||||
*/
|
||||
|
||||
const MAX_NOTE_LENGTH = 20000;
|
||||
const MAX_HEADING_DEPTH = 6;
|
||||
|
||||
export class MarkdownError extends Error {
|
||||
readonly line: number;
|
||||
|
||||
constructor(message: string, line: number) {
|
||||
super(message);
|
||||
this.name = 'MarkdownError';
|
||||
this.line = line;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MarkdownResult {
|
||||
roots: OutlineNode[];
|
||||
/** Nombre total de noeuds produits. */
|
||||
count: number;
|
||||
/** Texte du premier titre de niveau 1, s'il existe. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const HEADING = /^(#{1,6})\s+(.*)$/;
|
||||
const FENCE = /^\s*(```|~~~)/;
|
||||
|
||||
function countNodes(nodes: OutlineNode[]): number {
|
||||
let total = 0;
|
||||
for (const node of nodes) total += 1 + countNodes(node.children);
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Dernier noeud cree en profondeur : celui auquel rattacher la suite. */
|
||||
function lastLeaf(nodes: OutlineNode[]): OutlineNode | null {
|
||||
const last = nodes[nodes.length - 1];
|
||||
if (!last) return null;
|
||||
return lastLeaf(last.children) ?? last;
|
||||
}
|
||||
|
||||
function appendNote(node: OutlineNode | null, block: string): void {
|
||||
if (!node) return;
|
||||
const text = block.trim();
|
||||
if (!text) return;
|
||||
const merged = node.note ? `${node.note}\n\n${text}` : text;
|
||||
node.note = merged.slice(0, MAX_NOTE_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse un document Markdown.
|
||||
*
|
||||
* Titres et listes construisent la hierarchie ; un titre de niveau saute est
|
||||
* accepte et rattache au titre ouvert le plus proche, ce qui evite de rejeter
|
||||
* un document par ailleurs parfaitement lisible.
|
||||
*/
|
||||
export function parseMarkdown(
|
||||
text: string,
|
||||
maxNodes: number = APP_CONFIG.maxNodes,
|
||||
): MarkdownResult {
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
||||
|
||||
const roots: OutlineNode[] = [];
|
||||
const headings: Array<{ level: number; node: OutlineNode }> = [];
|
||||
let title: string | undefined;
|
||||
let current: OutlineNode | null = null;
|
||||
|
||||
let paragraph: string[] = [];
|
||||
let listBlock: Array<{ raw: string; line: number }> = [];
|
||||
let fence: string | null = null;
|
||||
let fenced: string[] = [];
|
||||
|
||||
const flushParagraph = (): void => {
|
||||
if (paragraph.length === 0) return;
|
||||
appendNote(current, paragraph.join('\n'));
|
||||
paragraph = [];
|
||||
};
|
||||
|
||||
const flushList = (): void => {
|
||||
if (listBlock.length === 0) return;
|
||||
const offset = (listBlock[0] as { line: number }).line - 1;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseOutline(listBlock.map((entry) => entry.raw).join('\n'), maxNodes);
|
||||
} catch (error) {
|
||||
if (error instanceof OutlineError) {
|
||||
// On repositionne le numero de ligne sur le document complet.
|
||||
throw new MarkdownError(
|
||||
error.message.replace(/^Ligne \d+/, `Ligne ${error.line + offset}`),
|
||||
error.line + offset,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
listBlock = [];
|
||||
|
||||
const parent = headings[headings.length - 1]?.node;
|
||||
if (parent) parent.children.push(...parsed.roots);
|
||||
else roots.push(...parsed.roots);
|
||||
current = lastLeaf(parsed.roots) ?? current;
|
||||
};
|
||||
|
||||
lines.forEach((raw, index) => {
|
||||
const lineNumber = index + 1;
|
||||
|
||||
// Bloc de code : recopie telle quelle dans la note, jamais en noeuds.
|
||||
if (fence !== null) {
|
||||
fenced.push(raw);
|
||||
if (raw.trimStart().startsWith(fence)) {
|
||||
flushList();
|
||||
appendNote(current, fenced.join('\n'));
|
||||
fenced = [];
|
||||
fence = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const fenceMatch = FENCE.exec(raw);
|
||||
if (fenceMatch) {
|
||||
flushParagraph();
|
||||
fence = fenceMatch[1] as string;
|
||||
fenced = [raw];
|
||||
return;
|
||||
}
|
||||
|
||||
if (raw.trim().length === 0) {
|
||||
flushParagraph();
|
||||
return;
|
||||
}
|
||||
|
||||
const heading = HEADING.exec(raw.trim());
|
||||
if (heading) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
|
||||
const level = Math.min(MAX_HEADING_DEPTH, (heading[1] as string).length);
|
||||
const label = (heading[2] as string).trim();
|
||||
const node: OutlineNode = { text: label, children: [] };
|
||||
|
||||
while (headings.length > 0 && (headings[headings.length - 1] as { level: number }).level >= level) {
|
||||
headings.pop();
|
||||
}
|
||||
const parent = headings[headings.length - 1]?.node;
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
|
||||
headings.push({ level, node });
|
||||
current = node;
|
||||
if (level === 1 && !title) title = label;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isListItem(raw)) {
|
||||
flushParagraph();
|
||||
listBlock.push({ raw, line: lineNumber });
|
||||
return;
|
||||
}
|
||||
|
||||
// Toute autre ligne est du texte : elle rejoint la note du noeud courant.
|
||||
flushList();
|
||||
paragraph.push(raw.trim());
|
||||
});
|
||||
|
||||
if (fence !== null) appendNote(current, fenced.join('\n'));
|
||||
flushList();
|
||||
flushParagraph();
|
||||
|
||||
const count = countNodes(roots);
|
||||
if (count === 0) {
|
||||
throw new MarkdownError('Ce document Markdown ne contient aucun titre ni aucune liste.', 0);
|
||||
}
|
||||
if (count > maxNodes) {
|
||||
throw new MarkdownError(
|
||||
`Ce document produirait ${count} noeuds : la limite est de ${maxNodes}.`,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
return { roots, count, ...(title ? { title } : {}) };
|
||||
}
|
||||
|
||||
const HEADING_DEPTH = 3;
|
||||
|
||||
/** Titre d'un noeud sur une seule ligne : un titre Markdown ne se coupe pas. */
|
||||
function singleLine(text: string): string {
|
||||
return text.replace(/\s*\n+\s*/g, ' ').trim();
|
||||
}
|
||||
|
||||
function writeNode(
|
||||
node: MindMapNode,
|
||||
depth: number,
|
||||
nodes: MindMapNode[],
|
||||
out: string[],
|
||||
): void {
|
||||
const label = singleLine(node.text) || '(sans titre)';
|
||||
|
||||
if (depth < HEADING_DEPTH) {
|
||||
out.push(`${'#'.repeat(depth + 1)} ${label}`, '');
|
||||
} else {
|
||||
out.push(`${' '.repeat(depth - HEADING_DEPTH)}- ${label}`);
|
||||
}
|
||||
|
||||
if (node.note) {
|
||||
// La note est ecrite sous son titre, separee par une ligne vide : elle
|
||||
// revient au meme endroit lors d'un reimport.
|
||||
if (depth >= HEADING_DEPTH) out.push('');
|
||||
out.push(node.note.trim(), '');
|
||||
}
|
||||
|
||||
for (const child of getOrderedChildren(nodes, node.id)) {
|
||||
writeNode(child, depth + 1, nodes, out);
|
||||
}
|
||||
|
||||
if (depth >= HEADING_DEPTH && node.note) out.push('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export Markdown : contenu et hierarchie uniquement.
|
||||
*
|
||||
* Ni positions, ni couleurs, ni dimensions, ni relations secondaires : ce
|
||||
* format decrit ce que la carte dit, pas a quoi elle ressemble. Les trois
|
||||
* premiers niveaux deviennent des titres, les suivants des listes imbriquees,
|
||||
* ce qui redonne exactement la meme arborescence a la relecture.
|
||||
*/
|
||||
export function exportMarkdown(map: MindMap): string {
|
||||
const out: string[] = [];
|
||||
// `getOrderedChildren(nodes, null)` rend les racines dans l'ordre du document.
|
||||
for (const root of getOrderedChildren(map.nodes, null)) {
|
||||
writeNode(root, 0, map.nodes, out);
|
||||
}
|
||||
// Un titre est toujours precede d'une ligne vide : c'est ce qui separe une
|
||||
// liste du titre suivant, et ce qui garde le fichier relisable tel quel.
|
||||
const spaced: string[] = [];
|
||||
for (const line of out) {
|
||||
if (line.startsWith('#') && spaced.length > 0 && spaced[spaced.length - 1] !== '') {
|
||||
spaced.push('');
|
||||
}
|
||||
spaced.push(line);
|
||||
}
|
||||
|
||||
return `${spaced.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`;
|
||||
}
|
||||
|
||||
/** Vrai si la carte contient des relations que l'export Markdown ne peut pas rendre. */
|
||||
export function hasUnexportableRelations(map: MindMap): boolean {
|
||||
return map.relations.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree une carte complete a partir d'un document Markdown.
|
||||
*
|
||||
* Si le document commence par un unique titre de niveau 1, il devient la racine
|
||||
* et le nom de la carte. Sinon, une racine est creee avec le nom fourni (celui
|
||||
* du fichier) : rien n'est invente au-dela.
|
||||
*/
|
||||
export function buildMapFromMarkdown(
|
||||
text: string,
|
||||
fallbackName: string,
|
||||
maxNodes: number = APP_CONFIG.maxNodes,
|
||||
): MindMap {
|
||||
const parsed = parseMarkdown(text, maxNodes);
|
||||
const single = parsed.roots.length === 1 ? (parsed.roots[0] as OutlineNode) : null;
|
||||
|
||||
const rootSpec: OutlineNode = single ?? {
|
||||
text: fallbackName,
|
||||
children: parsed.roots,
|
||||
};
|
||||
|
||||
const map = createEmptyMap(rootSpec.text || fallbackName);
|
||||
const root = map.nodes[0] as MindMapNode;
|
||||
const nodes: MindMapNode[] = [
|
||||
{ ...root, text: rootSpec.text || fallbackName, ...(rootSpec.note ? { note: rootSpec.note } : {}) },
|
||||
];
|
||||
|
||||
const walk = (children: OutlineNode[], parentId: string): void => {
|
||||
children.forEach((child, index) => {
|
||||
const node = createNode({
|
||||
text: child.text,
|
||||
parentId,
|
||||
order: index,
|
||||
...(child.note ? { note: child.note } : {}),
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
nodes.push(node);
|
||||
walk(child.children, node.id);
|
||||
});
|
||||
};
|
||||
walk(rootSpec.children, root.id);
|
||||
|
||||
const positions = computeLayout(nodes, map.settings.layout, {
|
||||
theme: getTheme(map.settings.theme),
|
||||
});
|
||||
const placed = nodes.map((node) => ({
|
||||
...node,
|
||||
position: positions.get(node.id) ?? node.position,
|
||||
}));
|
||||
|
||||
return { ...map, nodes: placed, edges: rebuildEdges(placed) };
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import { APP_CONFIG } from '@/config/app';
|
||||
export interface OutlineNode {
|
||||
text: string;
|
||||
children: OutlineNode[];
|
||||
/**
|
||||
* Note attachee au noeud. Toujours du texte brut : elle n'est jamais
|
||||
* interpretee comme du HTML. Alimentee par l'import Markdown.
|
||||
*/
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface OutlineResult {
|
||||
@@ -39,7 +44,14 @@ const MAX_TEXT_LENGTH = 500;
|
||||
const BULLET = /^[-*+\u2022\u25E6\u2023]\s+/;
|
||||
const NUMBERED = /^\d{1,3}[.)]\s+/;
|
||||
|
||||
function indentWidth(line: string): number {
|
||||
/** Vrai si la ligne est un element de liste : puce ou numerotation. */
|
||||
export function isListItem(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
return BULLET.test(trimmed) || NUMBERED.test(trimmed);
|
||||
}
|
||||
|
||||
/** Largeur d'indentation d'une ligne, tabulation comptee pour TAB_WIDTH. */
|
||||
export function indentWidth(line: string): number {
|
||||
let width = 0;
|
||||
for (const character of line) {
|
||||
if (character === ' ') width += 1;
|
||||
@@ -49,7 +61,8 @@ function indentWidth(line: string): number {
|
||||
return width;
|
||||
}
|
||||
|
||||
function cleanText(line: string): string {
|
||||
/** Texte utile d'une ligne : sans indentation, sans puce ni numerotation. */
|
||||
export function cleanText(line: string): string {
|
||||
return line.trim().replace(BULLET, '').replace(NUMBERED, '').trim().slice(0, MAX_TEXT_LENGTH);
|
||||
}
|
||||
|
||||
|
||||
105
frontend/src/services/relations.ts
Normal file
105
frontend/src/services/relations.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { type MindMapNode, type MindMapRelation } from '@/types/map';
|
||||
import { createId } from '@/utils/id';
|
||||
|
||||
/**
|
||||
* Regles des relations secondaires.
|
||||
*
|
||||
* Une relation est une connexion visuelle entre deux noeuds. Elle ne participe
|
||||
* jamais a la structure : ni `parentId`, ni `order`, ni le repli, ni
|
||||
* l'auto-layout ne la consultent. Ce module rassemble les seules regles qui la
|
||||
* gouvernent, pour qu'elles ne soient pas reecrites a chaque appelant.
|
||||
*/
|
||||
|
||||
/** Prefixe distinctif : un identifiant de relation ne ressemble a rien d'autre. */
|
||||
export function createRelationId(): string {
|
||||
return `r-${createId()}`;
|
||||
}
|
||||
|
||||
export type RelationRejection = 'self' | 'duplicate' | 'unknown';
|
||||
|
||||
export const RELATION_REJECTION_MESSAGE: Record<RelationRejection, string> = {
|
||||
self: "Une relation relie deux noeuds differents : elle ne peut pas boucler sur elle-meme.",
|
||||
duplicate: 'Ces deux noeuds sont deja relies dans ce sens.',
|
||||
unknown: "L'un des deux noeuds n'existe plus dans cette carte.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifie qu'une relation peut etre creee.
|
||||
*
|
||||
* Deux refus volontaires :
|
||||
* - source == target : un trait qui revient sur le meme noeud n'apporte rien
|
||||
* et se dessine mal ;
|
||||
* - doublon exact (meme source vers meme cible) : deux traits identiques se
|
||||
* superposent, donc le second serait invisible et impossible a selectionner.
|
||||
* Le sens inverse, lui, reste autorise : il porte une autre information.
|
||||
*/
|
||||
export function checkRelation(
|
||||
nodes: MindMapNode[],
|
||||
relations: MindMapRelation[],
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
): RelationRejection | null {
|
||||
if (sourceId === targetId) return 'self';
|
||||
const known = new Set(nodes.map((node) => node.id));
|
||||
if (!known.has(sourceId) || !known.has(targetId)) return 'unknown';
|
||||
const exists = relations.some(
|
||||
(relation) => relation.sourceId === sourceId && relation.targetId === targetId,
|
||||
);
|
||||
return exists ? 'duplicate' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire les relations devenues invalides.
|
||||
*
|
||||
* Appelee a chaque modification structurelle : supprimer un noeud ne doit pas
|
||||
* laisser derriere lui un trait vers le vide. C'est la contrepartie du fait que
|
||||
* les relations ne sont pas derivees de la hierarchie : rien ne les nettoie
|
||||
* automatiquement.
|
||||
*/
|
||||
export function pruneRelations(
|
||||
relations: MindMapRelation[],
|
||||
nodes: MindMapNode[],
|
||||
): MindMapRelation[] {
|
||||
if (relations.length === 0) return relations;
|
||||
const known = new Set(nodes.map((node) => node.id));
|
||||
const kept = relations.filter(
|
||||
(relation) =>
|
||||
relation.sourceId !== relation.targetId &&
|
||||
known.has(relation.sourceId) &&
|
||||
known.has(relation.targetId),
|
||||
);
|
||||
return kept.length === relations.length ? relations : kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reporte des relations sur des identifiants renumerotes.
|
||||
*
|
||||
* Seules les relations dont les DEUX extremites font partie de la copie sont
|
||||
* reportees. Une relation qui sort du sous-arbre copie n'est pas dupliquee :
|
||||
* rien ne dit que la copie doit pointer vers la meme cible externe que
|
||||
* l'original, et le deviner creerait des liens que l'utilisateur n'a pas
|
||||
* demandes. Il reste libre de la recreer explicitement.
|
||||
*/
|
||||
export function remapRelations(
|
||||
relations: MindMapRelation[],
|
||||
idMapping: Map<string, string>,
|
||||
): MindMapRelation[] {
|
||||
const result: MindMapRelation[] = [];
|
||||
for (const relation of relations) {
|
||||
const source = idMapping.get(relation.sourceId);
|
||||
const target = idMapping.get(relation.targetId);
|
||||
if (!source || !target) continue;
|
||||
result.push({ ...relation, id: createRelationId(), sourceId: source, targetId: target });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Relations entierement contenues dans un ensemble de noeuds. */
|
||||
export function relationsWithin(
|
||||
relations: MindMapRelation[],
|
||||
nodeIds: Set<string>,
|
||||
): MindMapRelation[] {
|
||||
return relations.filter(
|
||||
(relation) => nodeIds.has(relation.sourceId) && nodeIds.has(relation.targetId),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user