Phase 1.2 : productivite de creation
Construire une grande carte demande beaucoup moins de gestes. Ordre des freres (format v3) `MindMapNode.order` devient la position parmi les freres. `parentId` reste la seule source de verite de la relation hierarchique : une source par preoccupation, jamais deux versions contradictoires. L'ordre est renumerote a chaque modification structurelle dans `withEdges`, unique point de passage. Le format passe en v3 : un lecteur v2 ignorerait `order` et reordonnerait silencieusement la carte. Migration v2 -> v3 depuis la position dans le tableau, v1 -> v2 -> v3 s'enchainent, et les noeuds sont ecrits en parcours de profondeur pour rester corrects meme pour un lecteur anterieur. Creation rapide - Ajouter plusieurs enfants : une ligne = un noeud, avec apercu avant creation. - Collage d'une liste multi-ligne sur un noeud : le presse-papiers interne reste prioritaire, l'edition de texte n'est jamais interceptee. - Import d'une arborescence indentee : espaces ou tabulations, puces retirees, indentation incoherente signalee avec le numero de ligne plutot que devinee. - Les nouveaux noeuds sont repartis autour du parent au lieu de se superposer. Hierarchie Monter, descendre, placer en premier ou en dernier, remonter d'un niveau, "Deplacer vers..." avec recherche, suppression d'un noeud en conservant ses enfants a la place qu'il occupait. Le re-parentage passe toujours par `reparentNode` : la detection de cycle n'est ecrite qu'une fois. Branches Selectionner, dupliquer, copier, coller comme enfant, deplacer avec ses descendants (reglage, ou Alt pour l'inverser le temps d'un glisser), replier et developper une branche, tout replier, tout developper, replier au-dela d'un niveau. Clavier Maj+Tab remonte d'un niveau, Maj+Entree ouvre la creation multiple, Alt+Haut et Alt+Bas reordonnent. Tab, Entree et F2 gardent leur comportement. Historique Chaque operation logique reste une seule entree : creation multiple, import d'outline, reordonnancement, deplacement de branche, duplication, collage, suppression, tout replier. Tests : 172 vitest (103 -> 172) et 36 pytest (31 -> 36). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ export function createNode(partial: Partial<MindMapNode> & { text: string }): Mi
|
||||
text: partial.text,
|
||||
position: partial.position ?? { x: 0, y: 0 },
|
||||
parentId: partial.parentId ?? null,
|
||||
...(partial.order !== undefined ? { order: partial.order } : {}),
|
||||
...(partial.collapsed !== undefined ? { collapsed: partial.collapsed } : {}),
|
||||
...(partial.icon ? { icon: partial.icon } : {}),
|
||||
...(partial.url ? { url: partial.url } : {}),
|
||||
|
||||
151
frontend/src/services/mapOrderSerialization.test.ts
Normal file
151
frontend/src/services/mapOrderSerialization.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MAP_FORMAT_VERSION, type MindMap } from '@/types/map';
|
||||
import { normalizeMapDocument, serializeMap } from '@/services/mapSerialization';
|
||||
import { getOrderedChildren } from '@/utils/tree';
|
||||
|
||||
/** Carte v2 : pas de champ `order`, l'ordre est celui du tableau. */
|
||||
const V2_MAP = {
|
||||
version: 2,
|
||||
id: 'v2-map',
|
||||
name: 'Reseau v2',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
nodes: [
|
||||
{ id: 'root', type: 'root', text: 'Reseau', position: { x: 0, y: 0 }, parentId: null },
|
||||
{ id: 'dns', type: 'standard', text: 'DNS', position: { x: 200, y: 0 }, parentId: 'root' },
|
||||
{ id: 'dhcp', type: 'standard', text: 'DHCP', position: { x: 200, y: 60 }, parentId: 'root' },
|
||||
{ id: 'vlan', type: 'standard', text: 'VLAN', position: { x: 200, y: 120 }, parentId: 'root' },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e-root-dns', source: 'root', target: 'dns' },
|
||||
{ id: 'e-root-dhcp', source: 'root', target: 'dhcp' },
|
||||
{ id: 'e-root-vlan', source: 'root', target: 'vlan' },
|
||||
],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
settings: { theme: 'default', layout: 'mindmap-right', branchStyle: 'bezier', showGrid: true },
|
||||
};
|
||||
|
||||
const V1_MAP = {
|
||||
...V2_MAP,
|
||||
version: 1,
|
||||
id: 'v1-map',
|
||||
nodes: V2_MAP.nodes.map((node) =>
|
||||
node.id === 'root' ? { ...node, style: { width: 240, textAlign: 'center' } } : node,
|
||||
),
|
||||
};
|
||||
|
||||
function texts(map: MindMap, parentId: string | null): string[] {
|
||||
return getOrderedChildren(map.nodes, parentId).map((node) => node.text);
|
||||
}
|
||||
|
||||
describe('migration vers la v3', () => {
|
||||
it('materialise l ordre d une carte v2 depuis la position dans le tableau', () => {
|
||||
const map = normalizeMapDocument(V2_MAP);
|
||||
|
||||
expect(map.version).toBe(MAP_FORMAT_VERSION);
|
||||
expect(texts(map, 'root')).toEqual(['DNS', 'DHCP', 'VLAN']);
|
||||
expect(map.nodes.map((node) => node.order)).toEqual([0, 0, 1, 2]);
|
||||
});
|
||||
|
||||
it('migre une carte v1 jusqu a la v3 sans perdre la mise en page', () => {
|
||||
const map = normalizeMapDocument(V1_MAP);
|
||||
|
||||
expect(map.version).toBe(MAP_FORMAT_VERSION);
|
||||
expect(texts(map, 'root')).toEqual(['DNS', 'DHCP', 'VLAN']);
|
||||
expect(map.nodes[0]?.layout?.width).toBe(240);
|
||||
expect(map.nodes[0]?.style?.textAlign).toBe('center');
|
||||
});
|
||||
|
||||
it('respecte un ordre explicite plutot que la position dans le tableau', () => {
|
||||
const desordonne = {
|
||||
...V2_MAP,
|
||||
version: 3,
|
||||
nodes: [
|
||||
V2_MAP.nodes[0],
|
||||
{ ...V2_MAP.nodes[1], order: 2 },
|
||||
{ ...V2_MAP.nodes[2], order: 1 },
|
||||
{ ...V2_MAP.nodes[3], order: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
expect(texts(normalizeMapDocument(desordonne), 'root')).toEqual(['VLAN', 'DHCP', 'DNS']);
|
||||
});
|
||||
|
||||
it('refuse une version plus recente', () => {
|
||||
expect(() => normalizeMapDocument({ ...V2_MAP, version: MAP_FORMAT_VERSION + 1 })).toThrow(
|
||||
/plus recente/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aller-retour de l ordre', () => {
|
||||
it('conserve un ordre reordonne apres export puis import', () => {
|
||||
const map = normalizeMapDocument(V2_MAP);
|
||||
const reordonne: MindMap = {
|
||||
...map,
|
||||
nodes: map.nodes.map((node) => {
|
||||
if (node.id === 'vlan') return { ...node, order: 0 };
|
||||
if (node.id === 'dns') return { ...node, order: 1 };
|
||||
if (node.id === 'dhcp') return { ...node, order: 2 };
|
||||
return node;
|
||||
}),
|
||||
};
|
||||
|
||||
const restaure = normalizeMapDocument(JSON.parse(serializeMap(reordonne)));
|
||||
expect(texts(restaure, 'root')).toEqual(['VLAN', 'DNS', 'DHCP']);
|
||||
});
|
||||
|
||||
/** VLAN en tete, comme le ferait "Placer en premier". */
|
||||
function withVlanFirst(): MindMap {
|
||||
const map = normalizeMapDocument(V2_MAP);
|
||||
const ordres: Record<string, number> = { vlan: 0, dns: 1, dhcp: 2 };
|
||||
return {
|
||||
...map,
|
||||
nodes: map.nodes.map((node) =>
|
||||
ordres[node.id] === undefined ? node : { ...node, order: ordres[node.id] as number },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
it('ecrit les noeuds dans l ordre hierarchique', () => {
|
||||
const serialise = JSON.parse(serializeMap(withVlanFirst())) as MindMap;
|
||||
// Un lecteur anterieur a la v3, qui ignore `order`, retrouve le bon ordre.
|
||||
expect(serialise.nodes.map((node) => node.id)).toEqual(['root', 'vlan', 'dns', 'dhcp']);
|
||||
});
|
||||
|
||||
it('emet les liens dans l ordre de fratrie', () => {
|
||||
const serialise = JSON.parse(serializeMap(withVlanFirst())) as MindMap;
|
||||
expect(serialise.edges.map((edge) => edge.target)).toEqual(['vlan', 'dns', 'dhcp']);
|
||||
});
|
||||
|
||||
it('departage deux freres de meme rang par leur position dans le tableau', () => {
|
||||
const map = normalizeMapDocument(V2_MAP);
|
||||
const ambigu: MindMap = {
|
||||
...map,
|
||||
nodes: map.nodes.map((node) => (node.id === 'vlan' ? { ...node, order: 0 } : node)),
|
||||
};
|
||||
// `dns` et `vlan` ont tous deux le rang 0 : le tableau tranche, sans erreur.
|
||||
expect(texts(normalizeMapDocument(JSON.parse(serializeMap(ambigu))), 'root')).toEqual([
|
||||
'DNS',
|
||||
'VLAN',
|
||||
'DHCP',
|
||||
]);
|
||||
});
|
||||
|
||||
it('conserve l ordre d une arborescence sur plusieurs niveaux', () => {
|
||||
const map = normalizeMapDocument({
|
||||
version: 3,
|
||||
id: 'profond',
|
||||
nodes: [
|
||||
{ id: 'r', type: 'root', text: 'R', parentId: null, position: { x: 0, y: 0 } },
|
||||
{ id: 'a', text: 'A', parentId: 'r', order: 1, position: { x: 0, y: 0 } },
|
||||
{ id: 'b', text: 'B', parentId: 'r', order: 0, position: { x: 0, y: 0 } },
|
||||
{ id: 'a1', text: 'A1', parentId: 'a', order: 1, position: { x: 0, y: 0 } },
|
||||
{ id: 'a2', text: 'A2', parentId: 'a', order: 0, position: { x: 0, y: 0 } },
|
||||
],
|
||||
});
|
||||
|
||||
expect(texts(map, 'r')).toEqual(['B', 'A']);
|
||||
expect(texts(map, 'a')).toEqual(['A2', 'A1']);
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,12 @@ 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 {
|
||||
flattenInOrder,
|
||||
normalizeChildOrders,
|
||||
rebuildEdges,
|
||||
rebuildHierarchyFromEdges,
|
||||
} from '@/utils/tree';
|
||||
import { sanitizeUrl } from '@/utils/url';
|
||||
|
||||
/** Erreur d'import lisible par l'utilisateur : aucun detail technique. */
|
||||
@@ -58,9 +63,16 @@ const LAYOUTS: LayoutKind[] = [
|
||||
'freeform',
|
||||
];
|
||||
|
||||
/** Serialisation du format natif .mmap (JSON UTF-8, lisible et diffable). */
|
||||
/**
|
||||
* Serialisation du format natif .mmap (JSON UTF-8, lisible et diffable).
|
||||
*
|
||||
* Les noeuds sont ecrits en parcours de profondeur, freres dans l'ordre : le
|
||||
* fichier reste lisible a l'oeil, et un lecteur anterieur a la v3, qui ignore
|
||||
* `order`, retrouve malgre tout le bon ordre via la position dans le tableau.
|
||||
*/
|
||||
export function serializeMap(map: MindMap): string {
|
||||
return JSON.stringify(map, null, 2);
|
||||
const nodes = flattenInOrder(normalizeChildOrders(map.nodes));
|
||||
return JSON.stringify({ ...map, nodes, edges: rebuildEdges(nodes) }, null, 2);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -235,6 +247,10 @@ function parseNode(value: unknown): MindMapNode | null {
|
||||
parentId: asString(value.parentId, 128) ?? null,
|
||||
};
|
||||
|
||||
if (typeof value.order === 'number' && Number.isFinite(value.order)) {
|
||||
node.order = Math.max(0, Math.round(value.order));
|
||||
}
|
||||
|
||||
if (value.collapsed === true) node.collapsed = true;
|
||||
|
||||
// 32 caracteres : suffisant pour un emoji compose ou un identifiant d'icone integree.
|
||||
@@ -282,6 +298,7 @@ function parseSettings(value: unknown): MapSettings {
|
||||
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.dragMovesBranch === 'boolean') settings.dragMovesBranch = value.dragMovesBranch;
|
||||
if (typeof value.snapGrid === 'number' && Number.isFinite(value.snapGrid)) {
|
||||
settings.snapGrid = Math.min(100, Math.max(2, Math.round(value.snapGrid)));
|
||||
}
|
||||
@@ -329,6 +346,27 @@ function migrateV1ToV2(document: Record<string, unknown>): Record<string, unknow
|
||||
return { ...document, nodes, version: 2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 -> v3 : materialise l'ordre des freres depuis leur position dans le
|
||||
* tableau `nodes`, ce qui reproduit exactement l'affichage de la v2.
|
||||
*/
|
||||
function migrateV2ToV3(document: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!Array.isArray(document.nodes)) return { ...document, version: 3 };
|
||||
|
||||
const counters = new Map<string, number>();
|
||||
const nodes = document.nodes.map((rawNode) => {
|
||||
if (!isRecord(rawNode)) return rawNode;
|
||||
if (typeof rawNode.order === 'number') return rawNode;
|
||||
|
||||
const parentId = typeof rawNode.parentId === 'string' ? rawNode.parentId : '';
|
||||
const next = counters.get(parentId) ?? 0;
|
||||
counters.set(parentId, next + 1);
|
||||
return { ...rawNode, order: next };
|
||||
});
|
||||
|
||||
return { ...document, nodes, version: 3 };
|
||||
}
|
||||
|
||||
function migrate(raw: Record<string, unknown>, version: number): Record<string, unknown> {
|
||||
let document = raw;
|
||||
let current = version;
|
||||
@@ -337,6 +375,10 @@ function migrate(raw: Record<string, unknown>, version: number): Record<string,
|
||||
document = migrateV1ToV2(document);
|
||||
current = 2;
|
||||
}
|
||||
if (current === 2) {
|
||||
document = migrateV2ToV3(document);
|
||||
current = 3;
|
||||
}
|
||||
|
||||
if (current !== MAP_FORMAT_VERSION) {
|
||||
// Version intermediaire inconnue : lecture au mieux, sans perdre de donnee.
|
||||
@@ -457,14 +499,18 @@ export function normalizeMapDocument(raw: unknown, options: NormalizeOptions = {
|
||||
const rawViewport = isRecord(document.viewport) ? document.viewport : {};
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Ordre de fratrie dense et tableau range en profondeur : le document sortant
|
||||
// est toujours dans la meme forme canonique, quelle que soit sa provenance.
|
||||
const normalised = flattenInOrder(normalizeChildOrders(linked));
|
||||
|
||||
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),
|
||||
nodes: normalised,
|
||||
edges: rebuildEdges(normalised),
|
||||
viewport: {
|
||||
x: asFiniteNumber(rawViewport.x, 0),
|
||||
y: asFiniteNumber(rawViewport.y, 0),
|
||||
|
||||
131
frontend/src/services/outlineParser.test.ts
Normal file
131
frontend/src/services/outlineParser.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
OutlineError,
|
||||
looksIndented,
|
||||
parseOutline,
|
||||
toFlatLines,
|
||||
} from '@/services/outlineParser';
|
||||
|
||||
describe('lecture d une liste simple', () => {
|
||||
it('produit un noeud par ligne', () => {
|
||||
const result = parseOutline('VLAN\nVPN\nFirewall');
|
||||
|
||||
expect(result.count).toBe(3);
|
||||
expect(result.depth).toBe(1);
|
||||
expect(result.roots.map((node) => node.text)).toEqual(['VLAN', 'VPN', 'Firewall']);
|
||||
expect(result.roots.every((node) => node.children.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignore les lignes vides et les espaces superflus', () => {
|
||||
const result = parseOutline('VLAN\n\n \nVPN\n\n');
|
||||
expect(result.roots.map((node) => node.text)).toEqual(['VLAN', 'VPN']);
|
||||
});
|
||||
|
||||
it('retire les puces et les numerotations', () => {
|
||||
const result = parseOutline('- VLAN\n* VPN\n1. Firewall\n2) Switch');
|
||||
expect(result.roots.map((node) => node.text)).toEqual(['VLAN', 'VPN', 'Firewall', 'Switch']);
|
||||
});
|
||||
|
||||
it('refuse un texte sans ligne exploitable', () => {
|
||||
expect(() => parseOutline(' \n\n')).toThrow(OutlineError);
|
||||
});
|
||||
|
||||
it('applique la limite de noeuds', () => {
|
||||
const texte = Array.from({ length: 12 }, (_unused, index) => `N${index}`).join('\n');
|
||||
expect(() => parseOutline(texte, 5)).toThrow(/limite/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lecture d une arborescence indentee', () => {
|
||||
const OUTLINE = [
|
||||
'Infrastructure',
|
||||
' Reseau',
|
||||
' VLAN',
|
||||
' VPN',
|
||||
' Firewall',
|
||||
' Virtualisation',
|
||||
' Proxmox',
|
||||
' Docker',
|
||||
' Services',
|
||||
' GLPI',
|
||||
' Grafana',
|
||||
].join('\n');
|
||||
|
||||
it('reconstruit la hierarchie complete', () => {
|
||||
const result = parseOutline(OUTLINE);
|
||||
|
||||
expect(result.count).toBe(11);
|
||||
expect(result.depth).toBe(3);
|
||||
expect(result.roots).toHaveLength(1);
|
||||
|
||||
const racine = result.roots[0];
|
||||
expect(racine?.text).toBe('Infrastructure');
|
||||
expect(racine?.children.map((node) => node.text)).toEqual([
|
||||
'Reseau',
|
||||
'Virtualisation',
|
||||
'Services',
|
||||
]);
|
||||
expect(racine?.children[0]?.children.map((node) => node.text)).toEqual([
|
||||
'VLAN',
|
||||
'VPN',
|
||||
'Firewall',
|
||||
]);
|
||||
expect(racine?.children[2]?.children).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('accepte les tabulations', () => {
|
||||
const result = parseOutline('Reseau\n\tVLAN\n\tVPN\n\t\tSite A');
|
||||
|
||||
expect(result.roots[0]?.children.map((node) => node.text)).toEqual(['VLAN', 'VPN']);
|
||||
expect(result.roots[0]?.children[1]?.children[0]?.text).toBe('Site A');
|
||||
});
|
||||
|
||||
it('accepte plusieurs racines', () => {
|
||||
const result = parseOutline('Reseau\n VLAN\nServices\n GLPI');
|
||||
expect(result.roots.map((node) => node.text)).toEqual(['Reseau', 'Services']);
|
||||
});
|
||||
|
||||
it('tolere un texte entierement indente', () => {
|
||||
const result = parseOutline(' Reseau\n VLAN');
|
||||
expect(result.roots[0]?.text).toBe('Reseau');
|
||||
expect(result.roots[0]?.children[0]?.text).toBe('VLAN');
|
||||
});
|
||||
|
||||
it('refuse une indentation incoherente', () => {
|
||||
// "C" ne correspond a aucun niveau ouvert : ni 0, ni 4.
|
||||
expect(() => parseOutline('A\n B\n C')).toThrow(/incoherente/i);
|
||||
});
|
||||
|
||||
it('refuse une ligne moins indentee que la premiere', () => {
|
||||
expect(() => parseOutline(' A\nB')).toThrow(/moins indentee/i);
|
||||
});
|
||||
|
||||
it('refuse une arborescence trop profonde', () => {
|
||||
const profond = Array.from({ length: 25 }, (_unused, index) =>
|
||||
`${' '.repeat(index * 2)}N${index}`,
|
||||
).join('\n');
|
||||
expect(() => parseOutline(profond)).toThrow(/profonde/i);
|
||||
});
|
||||
|
||||
it('signale la ligne fautive', () => {
|
||||
try {
|
||||
parseOutline('A\n B\n C');
|
||||
expect.unreachable('une erreur etait attendue');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(OutlineError);
|
||||
expect((error as OutlineError).line).toBe(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detection du mode', () => {
|
||||
it('reconnait un texte indente', () => {
|
||||
expect(looksIndented('A\n B')).toBe(true);
|
||||
expect(looksIndented('A\n\tB')).toBe(true);
|
||||
expect(looksIndented('A\nB')).toBe(false);
|
||||
});
|
||||
|
||||
it('extrait les lignes utiles', () => {
|
||||
expect(toFlatLines('- A\n\n \n2. B\r\nC')).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
});
|
||||
173
frontend/src/services/outlineParser.ts
Normal file
173
frontend/src/services/outlineParser.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { APP_CONFIG } from '@/config/app';
|
||||
|
||||
/**
|
||||
* Lecture d'une arborescence indentee collee par l'utilisateur.
|
||||
*
|
||||
* Volontairement limite a un "outline" : une ligne = un noeud, l'indentation
|
||||
* donne la profondeur. Ce n'est pas un lecteur Markdown ; seules les puces de
|
||||
* liste les plus courantes sont retirees pour que coller une liste a puces
|
||||
* fonctionne sans retouche.
|
||||
*/
|
||||
|
||||
export interface OutlineNode {
|
||||
text: string;
|
||||
children: OutlineNode[];
|
||||
}
|
||||
|
||||
export interface OutlineResult {
|
||||
roots: OutlineNode[];
|
||||
/** Nombre total de noeuds, tous niveaux confondus. */
|
||||
count: number;
|
||||
/** Profondeur maximale, la racine comptant pour 1. */
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/** Erreur d'analyse, deja formulee pour l'utilisateur. */
|
||||
export class OutlineError extends Error {
|
||||
readonly line: number;
|
||||
|
||||
constructor(message: string, line: number) {
|
||||
super(message);
|
||||
this.name = 'OutlineError';
|
||||
this.line = line;
|
||||
}
|
||||
}
|
||||
|
||||
const TAB_WIDTH = 4;
|
||||
const MAX_DEPTH = 20;
|
||||
const MAX_TEXT_LENGTH = 500;
|
||||
const BULLET = /^[-*+\u2022\u25E6\u2023]\s+/;
|
||||
const NUMBERED = /^\d{1,3}[.)]\s+/;
|
||||
|
||||
function indentWidth(line: string): number {
|
||||
let width = 0;
|
||||
for (const character of line) {
|
||||
if (character === ' ') width += 1;
|
||||
else if (character === '\t') width += TAB_WIDTH;
|
||||
else break;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
function cleanText(line: string): string {
|
||||
return line.trim().replace(BULLET, '').replace(NUMBERED, '').trim().slice(0, MAX_TEXT_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse un texte indente.
|
||||
*
|
||||
* Une indentation incoherente (un niveau qui ne correspond a aucun niveau
|
||||
* ouvert) est signalee plutot que devinee : mieux vaut un message clair qu'une
|
||||
* hierarchie absurde.
|
||||
*/
|
||||
export function parseOutline(text: string, maxNodes: number = APP_CONFIG.maxNodes): OutlineResult {
|
||||
const rawLines = text.replace(/\r\n?/g, '\n').split('\n');
|
||||
|
||||
const entries: Array<{ indent: number; text: string; line: number }> = [];
|
||||
rawLines.forEach((raw, index) => {
|
||||
if (raw.trim().length === 0) return; // les lignes vides sont ignorees
|
||||
const content = cleanText(raw);
|
||||
if (content.length === 0) return;
|
||||
entries.push({ indent: indentWidth(raw), text: content, line: index + 1 });
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
throw new OutlineError('Aucune ligne exploitable dans le texte fourni.', 0);
|
||||
}
|
||||
if (entries.length > maxNodes) {
|
||||
throw new OutlineError(
|
||||
`Ce texte produirait ${entries.length} noeuds : la limite est de ${maxNodes}.`,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
const base = entries[0]?.indent ?? 0;
|
||||
const roots: OutlineNode[] = [];
|
||||
// Pile des niveaux ouverts : largeur d'indentation -> noeud parent.
|
||||
const stack: Array<{ indent: number; node: OutlineNode | null }> = [{ indent: base, node: null }];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.indent < base) {
|
||||
throw new OutlineError(
|
||||
`Ligne ${entry.line} : elle est moins indentee que la premiere ligne.`,
|
||||
entry.line,
|
||||
);
|
||||
}
|
||||
|
||||
// On referme les niveaux plus profonds que la ligne courante.
|
||||
let closedLevels = false;
|
||||
while (
|
||||
stack.length > 1 &&
|
||||
entry.indent < (stack[stack.length - 1] as { indent: number }).indent
|
||||
) {
|
||||
stack.pop();
|
||||
closedLevels = true;
|
||||
}
|
||||
|
||||
const top = stack[stack.length - 1] as { indent: number; node: OutlineNode | null };
|
||||
const node: OutlineNode = { text: entry.text, children: [] };
|
||||
|
||||
if (entry.indent === top.indent) {
|
||||
const parent =
|
||||
stack.length > 1 ? (stack[stack.length - 2] as { node: OutlineNode | null }).node : null;
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
stack[stack.length - 1] = { indent: entry.indent, node };
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
* Ici l'indentation est superieure au niveau courant. Si l'on vient de
|
||||
* refermer des niveaux, c'est qu'elle ne correspond a aucun niveau ouvert :
|
||||
* l'outline est incoherent, on le signale plutot que de deviner.
|
||||
*/
|
||||
if (closedLevels) {
|
||||
throw new OutlineError(
|
||||
`Ligne ${entry.line} : indentation incoherente avec les lignes precedentes.`,
|
||||
entry.line,
|
||||
);
|
||||
}
|
||||
if (!top.node) {
|
||||
throw new OutlineError(
|
||||
`Ligne ${entry.line} : indentation inattendue, aucun element parent au-dessus.`,
|
||||
entry.line,
|
||||
);
|
||||
}
|
||||
if (stack.length >= MAX_DEPTH) {
|
||||
throw new OutlineError(
|
||||
`Ligne ${entry.line} : arborescence trop profonde (maximum ${MAX_DEPTH} niveaux).`,
|
||||
entry.line,
|
||||
);
|
||||
}
|
||||
|
||||
top.node.children.push(node);
|
||||
stack.push({ indent: entry.indent, node });
|
||||
}
|
||||
|
||||
return { roots, count: entries.length, depth: measureDepth(roots) };
|
||||
}
|
||||
|
||||
function measureDepth(nodes: OutlineNode[]): number {
|
||||
let deepest = 0;
|
||||
for (const node of nodes) {
|
||||
deepest = Math.max(deepest, 1 + measureDepth(node.children));
|
||||
}
|
||||
return deepest;
|
||||
}
|
||||
|
||||
/** Vrai si le texte contient au moins une indentation exploitable. */
|
||||
export function looksIndented(text: string): boolean {
|
||||
return text
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split('\n')
|
||||
.some((line) => line.trim().length > 0 && /^[ \t]+/.test(line));
|
||||
}
|
||||
|
||||
/** Lignes non vides d'un texte, sans puce ni numerotation. */
|
||||
export function toFlatLines(text: string): string[] {
|
||||
return text
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split('\n')
|
||||
.map(cleanText)
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
Reference in New Issue
Block a user