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); }