diff --git a/app/api/process-document/route.ts b/app/api/process-document/route.ts index 50b57ae..9f58da9 100644 --- a/app/api/process-document/route.ts +++ b/app/api/process-document/route.ts @@ -15,7 +15,7 @@ export async function POST(req: NextRequest) { { status: 400 } ); } - + // Vérifications supplémentaires if (file.size === 0) { return NextResponse.json( @@ -23,19 +23,20 @@ export async function POST(req: NextRequest) { { status: 400 } ); } - - if (file.size > 50 * 1024 * 1024) { // 50MB + + if (file.size > 50 * 1024 * 1024) { + // 50MB return NextResponse.json( { error: "Le fichier est trop volumineux (max 50MB)." }, { status: 400 } ); } - + console.log("📁 Fichier reçu:", { name: file.name, type: file.type, size: `${(file.size / 1024 / 1024).toFixed(2)} MB`, - lastModified: new Date(file.lastModified).toISOString() + lastModified: new Date(file.lastModified).toISOString(), }); let fileContent = ""; @@ -45,55 +46,56 @@ export async function POST(req: NextRequest) { if (fileType === "application/pdf") { console.log("📄 Traitement PDF en cours..."); console.log("📊 Taille du fichier:", file.size, "bytes"); - + try { const buffer = Buffer.from(await file.arrayBuffer()); console.log("📦 Buffer créé, taille:", buffer.length); - + const data = await pdf(buffer); fileContent = data.text || ""; - + console.log("✅ Extraction PDF réussie, longueur:", fileContent.length); console.log("📄 Nombre de pages:", data.numpages); console.log("ℹ️ Info PDF:", data.info?.Title || "Titre non disponible"); - + // ✅ Vérification améliorée if (!fileContent.trim()) { console.log("⚠️ PDF vide - Détails:", { pages: data.numpages, metadata: data.metadata, info: data.info, - extractedLength: fileContent.length + extractedLength: fileContent.length, }); - + // Détecter si c'est un PDF scanné - const isScanned = data.info?.Creator?.includes('RICOH') || - data.info?.Creator?.includes('Canon') || - data.info?.Creator?.includes('HP') || - data.info?.Producer?.includes('Scanner') || - (data.numpages > 0 && fileContent.length < 50); - - const errorMessage = isScanned + const isScanned = + data.info?.Creator?.includes("RICOH") || + data.info?.Creator?.includes("Canon") || + data.info?.Creator?.includes("HP") || + data.info?.Producer?.includes("Scanner") || + (data.numpages > 0 && fileContent.length < 50); + + const errorMessage = isScanned ? `Ce PDF semble être un document scanné (créé par: ${data.info?.Creator}). Les documents scannés contiennent des images de texte, pas du texte extractible.\n\n💡 Solutions :\n- Utilisez un PDF créé depuis Word/Google Docs\n- Appliquez l'OCR avec Adobe Acrobat\n- Recréez le document au lieu de le scanner` : `Le PDF ne contient pas de texte extractible.\n\nCela peut être dû à :\n- PDF scanné (image uniquement)\n- PDF protégé\n- PDF avec texte en images\n- Nombre de pages: ${data.numpages}`; - - return NextResponse.json( - { error: errorMessage }, - { status: 400 } - ); + + return NextResponse.json({ error: errorMessage }, { status: 400 }); } } catch (pdfError) { console.error("❌ Erreur PDF détaillée:", { - message: pdfError instanceof Error ? pdfError.message : "Erreur inconnue", + message: + pdfError instanceof Error ? pdfError.message : "Erreur inconnue", stack: pdfError instanceof Error ? pdfError.stack : undefined, fileName: file.name, fileSize: file.size, - fileType: file.type + fileType: file.type, }); - + return NextResponse.json( { - error: `Impossible de traiter ce PDF (${file.name}). Erreur: ${pdfError instanceof Error ? pdfError.message : "Erreur inconnue"}. Vérifiez que le PDF n'est pas protégé, corrompu ou scanné.`, + error: `Impossible de traiter ce PDF (${file.name}). Erreur: ${ + pdfError instanceof Error ? pdfError.message : "Erreur inconnue" + }. Vérifiez que le PDF n'est pas protégé, corrompu ou scanné.`, }, { status: 500 } ); @@ -170,10 +172,10 @@ export async function POST(req: NextRequest) { }; console.log("🔍 Appel à Presidio Analyzer..."); - + // ✅ Définir l'URL AVANT de l'utiliser - const presidioAnalyzerUrl = "http://analyzer.151.80.20.211.sslip.io/analyze"; - + const presidioAnalyzerUrl = "http://localhost:5001/analyze"; + try { const analyzeResponse = await fetch(presidioAnalyzerUrl, { method: "POST", @@ -183,10 +185,10 @@ export async function POST(req: NextRequest) { }, body: JSON.stringify(analyzerConfig), }); - + console.log("📊 Statut Analyzer:", analyzeResponse.status); console.log("📊 Headers Analyzer:", analyzeResponse.headers); - + if (!analyzeResponse.ok) { const errorBody = await analyzeResponse.text(); console.error("❌ Erreur Analyzer:", errorBody); @@ -209,8 +211,7 @@ export async function POST(req: NextRequest) { }; console.log("🔍 Appel à Presidio Anonymizer..."); - const presidioAnonymizerUrl = - "http://anonymizer.151.80.20.211.sslip.io/anonymize"; + const presidioAnonymizerUrl = "http://localhost:5001/anonymize"; const anonymizeResponse = await fetch(presidioAnonymizerUrl, { method: "POST", @@ -232,13 +233,66 @@ export async function POST(req: NextRequest) { const anonymizerResult = await anonymizeResponse.json(); console.log("✅ Anonymisation réussie."); + // 🔧 NOUVELLE FONCTION SIMPLIFIÉE pour extraire les valeurs de remplacement + // Ajouter cette interface au début du fichier + interface AnalyzerResult { + entity_type: string; + start: number; + end: number; + score: number; + } + + // Puis modifier la fonction + const extractReplacementValues = (originalText: string, anonymizedText: string, analyzerResults: AnalyzerResult[]) => { + const replacementMap: Record = {}; + + // Créer une copie du texte anonymisé pour le traitement + const workingText = anonymizedText; // ✅ Changé de 'let' à 'const' + // Supprimer workingOriginal car elle n'est jamais utilisée + + // Trier les résultats par position (du plus grand au plus petit pour éviter les décalages) + const sortedResults = [...analyzerResults].sort((a, b) => b.start - a.start); + + for (const result of sortedResults) { + const originalValue = originalText.substring(result.start, result.end); + + // Extraire les parties avant et après l'entité dans le texte original + const beforeOriginal = originalText.substring(0, result.start); + const afterOriginal = originalText.substring(result.end); + + // Trouver les mêmes parties dans le texte anonymisé + const beforeIndex = workingText.indexOf(beforeOriginal); + const afterIndex = workingText.lastIndexOf(afterOriginal); + + if (beforeIndex !== -1 && afterIndex !== -1) { + // Extraire la valeur de remplacement entre ces deux parties + const startPos = beforeIndex + beforeOriginal.length; + const endPos = afterIndex; + const replacementValue = workingText.substring(startPos, endPos); + + // Vérifier que c'est bien un remplacement (commence par [ et finit par ]) + if (replacementValue.startsWith('[') && replacementValue.endsWith(']')) { + replacementMap[originalValue] = replacementValue; + } + } + } + + return replacementMap; + }; + + const replacementValues = extractReplacementValues(fileContent, anonymizerResult.anonymized_text, analyzerResults); + + // 🔍 AJOUT D'UN LOG POUR DÉBOGUER + console.log("🔧 Valeurs de remplacement extraites:", replacementValues); + const result = { text: fileContent, - anonymizedText: anonymizerResult.text, + anonymizedText: anonymizerResult.anonymized_text, piiCount: analyzerResults.length, analyzerResults: analyzerResults, + replacementValues: replacementValues // Utiliser les nouvelles valeurs }; - + return NextResponse.json(result, { status: 200 }); } catch (presidioError) { console.error("❌ Erreur Presidio:", presidioError); diff --git a/app/components/AnonymizationInterface.tsx b/app/components/AnonymizationInterface.tsx index d79ae69..d288140 100644 --- a/app/components/AnonymizationInterface.tsx +++ b/app/components/AnonymizationInterface.tsx @@ -17,7 +17,7 @@ export const AnonymizationInterface = ({ const anonymizedTypes = new Set(); - if (outputText.includes("")) { + if (outputText.includes("")) { anonymizedTypes.add("Prénoms"); anonymizedTypes.add("Noms de famille"); anonymizedTypes.add("Noms complets"); diff --git a/app/components/AnonymizationLogic.tsx b/app/components/AnonymizationLogic.tsx index e51692e..d6ceda8 100644 --- a/app/components/AnonymizationLogic.tsx +++ b/app/components/AnonymizationLogic.tsx @@ -1,211 +1,140 @@ import { useState } from "react"; -import { patterns } from "@/app/utils/highlightEntities"; +import { + PresidioAnalyzerResult, + EntityMapping, +} from "@/app/config/entityLabels"; -interface EntityMapping { - originalValue: string; - anonymizedValue: string; - entityType: string; - startIndex: number; - endIndex: number; -} - -// L'API retourne des objets avec snake_case -interface PresidioAnalyzerResult { - entity_type: string; - start: number; - end: number; - score: number; -} - -// La réponse de l'API utilise camelCase pour les clés principales +// Interface pour la réponse de l'API process-document interface ProcessDocumentResponse { - text?: string; // Texte original en cas de fallback + text?: string; anonymizedText?: string; analyzerResults?: PresidioAnalyzerResult[]; + replacementValues?: Record; // Nouvelle propriété error?: string; } +// Props du hook interface AnonymizationLogicProps { - sourceText: string; - fileContent: string; - uploadedFile: File | null; setOutputText: (text: string) => void; setError: (error: string | null) => void; setEntityMappings: (mappings: EntityMapping[]) => void; } +// NOUVEAU: Définir les types pour le paramètre de anonymizeData +interface AnonymizeDataParams { + file?: File | null; + text?: string; +} + +/** + * Hook pour la logique d'anonymisation. + * Gère l'appel API et la création du tableau de mapping de manière simple et directe. + */ export const useAnonymization = ({ - sourceText, - fileContent, - uploadedFile, setOutputText, setError, setEntityMappings, }: AnonymizationLogicProps) => { const [isProcessing, setIsProcessing] = useState(false); - const anonymizeData = async () => { - const textToProcess = sourceText || fileContent || ""; - - if (!textToProcess.trim()) { - setError( - "Veuillez saisir du texte à anonymiser ou télécharger un fichier" - ); - return; - } - + const anonymizeData = async ({ file, text }: AnonymizeDataParams) => { setIsProcessing(true); setError(null); - setOutputText(""); setEntityMappings([]); + setOutputText(""); try { + // ÉTAPE 1: Construire le FormData ici pour garantir le bon format const formData = new FormData(); - if (uploadedFile) { - formData.append("file", uploadedFile); + if (file) { + formData.append("file", file); + } else if (text) { + // Si c'est du texte, on le transforme en Blob pour l'envoyer comme un fichier + const textBlob = new Blob([text], { type: "text/plain" }); + formData.append("file", textBlob, "input.txt"); } else { - const textBlob = new Blob([textToProcess], { type: "text/plain" }); - const textFile = new File([textBlob], "input.txt", { - type: "text/plain", - }); - formData.append("file", textFile); + throw new Error("Aucune donnée à anonymiser (ni fichier, ni texte)."); } const response = await fetch("/api/process-document", { method: "POST", - body: formData, + body: formData, // Le Content-Type sera automatiquement défini par le navigateur }); - if (!response.ok) { - let errorMessage = `Erreur HTTP: ${response.status}`; - try { - const errorData = await response.json(); - if (errorData.error) errorMessage = errorData.error; - } catch { - /* Ignore */ - } - throw new Error(errorMessage); - } - const data: ProcessDocumentResponse = await response.json(); - if (data.error) { - throw new Error(data.error); + if (!response.ok || data.error) { + throw new Error( + data.error || "Erreur lors de la communication avec l'API." + ); } - // Utiliser camelCase pour les propriétés de la réponse principale - if (data.anonymizedText && data.analyzerResults) { - setOutputText(data.anonymizedText); + const originalText = data.text || ""; + const presidioResults = data.analyzerResults || []; + const replacementValues = data.replacementValues || {}; // Récupérer les valeurs de remplacement - const entityTypeMap = new Map(); - patterns.forEach((p) => { - const match = p.regex.toString().match(/<([A-Z_]+)>/); - if (match && match[1]) { - entityTypeMap.set(match[1], p.label); - } + // 🔍 AJOUT DES CONSOLE.LOG POUR DÉBOGUER + console.log("📊 Données reçues de Presidio:", { + originalTextLength: originalText.length, + presidioResultsCount: presidioResults.length, + presidioResults: presidioResults, + replacementValues: replacementValues, + replacementValuesKeys: Object.keys(replacementValues), + replacementValuesEntries: Object.entries(replacementValues) + }); + + // ÉTAPE 2 : Passer le texte ORIGINAL à l'état de sortie. + setOutputText(originalText); + + // ÉTAPE 3 : Créer le tableau de mapping avec la nouvelle structure + const sortedResults = [...presidioResults].sort( + (a, b) => a.start - b.start + ); + const mappings: EntityMapping[] = []; + + // Dans la fonction anonymizeData, section création des mappings : + for (const result of sortedResults) { + const { entity_type, start, end } = result; + const detectedText = originalText.substring(start, end); + + // 🔍 CONSOLE.LOG POUR CHAQUE ENTITÉ + console.log(`🔍 Entité détectée:`, { + entity_type, + detectedText, + replacementFromMap: replacementValues[detectedText], + fallback: `[${entity_type}]` }); - // 1. Compter les occurrences de chaque tag d'entité dans le texte anonymisé - const tagCounts = new Map(); - data.analyzerResults.forEach((result) => { - const tag = `<${result.entity_type}>`; - if (!tagCounts.has(result.entity_type)) { - const count = ( - data.anonymizedText?.match(new RegExp(tag, "g")) || [] - ).length; - tagCounts.set(result.entity_type, count); - } + mappings.push({ + entity_type: entity_type, + start: start, + end: end, + text: detectedText, + replacementValue: replacementValues[detectedText] || `[${entity_type}]`, + displayName: replacementValues[detectedText] || `[${entity_type}]`, // Ajouter cette ligne + customColor: undefined, }); - - // 2. Créer un mapping basé sur l'ordre d'apparition dans le texte anonymisé - const uniqueMappings: EntityMapping[] = []; - - // Vérifier que les données nécessaires sont disponibles - if (!data.analyzerResults || !data.anonymizedText) { - setEntityMappings([]); - return; - } - - const entityCounters = new Map(); - - // Parcourir le texte anonymisé pour trouver les tags dans l'ordre - const anonymizedText = data.anonymizedText; - const allMatches: Array<{ - match: RegExpMatchArray; - entityType: string; - position: number; - }> = []; - - // Trouver tous les tags dans le texte anonymisé - patterns.forEach(pattern => { - const entityTypeKey = pattern.regex.toString().match(/<([A-Z_]+)>/)?.[1]; - if (entityTypeKey) { - const regex = new RegExp(pattern.regex.source, 'g'); - let match; - while ((match = regex.exec(anonymizedText)) !== null) { - allMatches.push({ - match, - entityType: entityTypeKey, - position: match.index - }); - } - } - }); - - // Trier par position dans le texte anonymisé - allMatches.sort((a, b) => a.position - b.position); - - // Créer les mappings dans l'ordre d'apparition - const seen = new Set(); - allMatches.forEach(({ entityType }) => { - const frenchLabel = entityTypeMap.get(entityType) || entityType; - const currentCount = (entityCounters.get(entityType) || 0) + 1; - entityCounters.set(entityType, currentCount); - - // Trouver l'entité correspondante dans les résultats d'analyse - const correspondingResult = data.analyzerResults - ?.filter(result => result.entity_type === entityType) - .find(result => { - const originalValue = textToProcess.substring(result.start, result.end); - const uniqueKey = `${frenchLabel}|${originalValue}|${currentCount}`; - return !seen.has(uniqueKey); - }); - - if (correspondingResult) { - const originalValue = textToProcess.substring( - correspondingResult.start, - correspondingResult.end - ); - const uniqueKey = `${frenchLabel}|${originalValue}|${currentCount}`; - - if (!seen.has(uniqueKey)) { - uniqueMappings.push({ - entityType: frenchLabel, - originalValue: originalValue, - anonymizedValue: `${frenchLabel} [${currentCount}]`, - startIndex: correspondingResult.start, - endIndex: correspondingResult.end, - }); - seen.add(uniqueKey); - } - } - }); - - setEntityMappings(uniqueMappings); - } else if (data.text) { - setOutputText(data.text); - setError("Presidio temporairement indisponible. Texte non anonymisé."); } + + // 🔍 CONSOLE.LOG FINAL DES MAPPINGS + console.log("📋 Mappings créés:", mappings); + + // ÉTAPE 4 : Mettre à jour l'état global avec les mappings créés. + setEntityMappings(mappings); } catch (error) { + console.error("Erreur dans useAnonymization:", error); setError( error instanceof Error ? error.message - : "Erreur lors de l'anonymisation avec Presidio" + : "Une erreur inconnue est survenue." ); } finally { setIsProcessing(false); } }; - return { anonymizeData, isProcessing }; + return { + anonymizeData, + isProcessing, + }; }; diff --git a/app/components/ContextMenu.tsx b/app/components/ContextMenu.tsx new file mode 100644 index 0000000..0d445eb --- /dev/null +++ b/app/components/ContextMenu.tsx @@ -0,0 +1,352 @@ +import React, { useState, useRef, useEffect } from "react"; +import { Trash2, Check, RotateCcw } from "lucide-react"; +import { COLOR_PALETTE, type ColorOption } from "../config/colorPalette"; +// import { EntityMapping } from "../config/entityLabels"; // SUPPRIMER cette ligne + +interface ContextMenuProps { + contextMenu: { + visible: boolean; + x: number; + y: number; + selectedText: string; + wordIndices: number[]; + }; + existingLabels: string[]; + // entityMappings: EntityMapping[]; // SUPPRIMER cette ligne + onApplyLabel: ( + displayName: string, + applyToAll?: boolean + ) => void; + onApplyColor: ( + color: string, + colorName: string, + applyToAll?: boolean + ) => void; + onRemoveLabel: (applyToAll?: boolean) => void; + getCurrentColor: (selectedText: string) => string; +} + +const colorOptions: ColorOption[] = COLOR_PALETTE; + +export const ContextMenu: React.FC = ({ + contextMenu, + existingLabels, + // entityMappings, // SUPPRIMER cette ligne + onApplyLabel, + onApplyColor, + onRemoveLabel, + getCurrentColor, +}) => { + const [customLabel, setCustomLabel] = useState(""); + const [showNewLabelInput, setShowNewLabelInput] = useState(false); + const [showColorPalette, setShowColorPalette] = useState(false); + const [applyToAll, setApplyToAll] = useState(false); + const menuRef = useRef(null); + const inputRef = useRef(null); + + // Fonction corrigée pour le bouton + + const handleNewLabelClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + console.log("Bouton + cliqué - Ouverture du champ de saisie"); + setShowNewLabelInput(true); + setShowColorPalette(false); + }; + + const handleApplyCustomLabel = (e?: React.MouseEvent) => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + if (customLabel.trim()) { + console.log( + "Application du label personnalisé:", + customLabel.trim(), + "À toutes les occurrences:", + applyToAll + ); + onApplyLabel(customLabel.trim(), applyToAll); // CORRIGER: 2 paramètres seulement + setCustomLabel(""); + setShowNewLabelInput(false); + } + }; + + // Modifier la fonction handleCancelNewLabel pour accepter les deux types d'événements + const handleCancelNewLabel = (e: React.MouseEvent | React.KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + console.log("Annulation du nouveau label"); + setShowNewLabelInput(false); + setCustomLabel(""); + }; + + // Fonction pour empêcher la propagation des événements + const handleMenuClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + // Auto-focus sur l'input quand il apparaît + useEffect(() => { + if (showNewLabelInput && inputRef.current) { + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + } + }, [showNewLabelInput]); + + if (!contextMenu.visible) return null; + + // Calcul du positionnement pour s'assurer que le menu reste visible + const calculatePosition = () => { + const menuWidth = Math.max(600, contextMenu.selectedText.length * 8 + 400); // Largeur dynamique basée sur le texte + const menuHeight = 60; // Hauteur fixe pour une seule ligne + const padding = 10; + + let x = contextMenu.x; + let y = contextMenu.y; + + // Ajuster X pour rester dans la fenêtre + if (x + menuWidth / 2 > window.innerWidth - padding) { + x = window.innerWidth - menuWidth / 2 - padding; + } + if (x - menuWidth / 2 < padding) { + x = menuWidth / 2 + padding; + } + + // Ajuster Y pour rester dans la fenêtre + if (y + menuHeight > window.innerHeight - padding) { + y = contextMenu.y - menuHeight - 20; // Afficher au-dessus + } + + return { x, y }; + }; + + const position = calculatePosition(); + + return ( +
e.stopPropagation()} + > + {/* Une seule ligne avec tous les contrôles */} +
+ {/* Texte sélectionné complet */} +
+
+ {contextMenu.selectedText} +
+
+ +
+ + {/* Labels existants */} + {existingLabels.length > 0 && ( + <> +
+ +
+
+ + )} + + {/* Nouveau label */} +
+
+ {!showNewLabelInput ? ( + + ) : ( +
+ { + e.stopPropagation(); + setCustomLabel(e.target.value); + }} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + handleApplyCustomLabel(); + } else if (e.key === "Escape") { + e.preventDefault(); + handleCancelNewLabel(e); + } + }} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onFocus={(e) => e.stopPropagation()} + className="text-xs border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500 w-20" + placeholder="Label" + /> + + +
+ )} +
+
+ +
+ + {/* Sélecteur de couleur */} +
+
+ )} +
+ +
+ + {/* Bouton supprimer */} +
+ +
+ +
+ + {/* Case à cocher "Toutes les occurrences" */} +
+
+ { + e.stopPropagation(); + setApplyToAll(e.target.checked); + console.log( + "Appliquer à toutes les occurrences:", + e.target.checked + ); + }} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + className="h-3 w-3 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> + +
+
+
+ + ); +}; diff --git a/app/components/DownloadActions.tsx b/app/components/DownloadActions.tsx index 7524b09..162f184 100644 --- a/app/components/DownloadActions.tsx +++ b/app/components/DownloadActions.tsx @@ -1,14 +1,23 @@ +import { generateAnonymizedText } from "@/app/utils/generateAnonymizedText"; +import { EntityMapping } from "@/app/config/entityLabels"; + interface DownloadActionsProps { outputText: string; + entityMappings?: EntityMapping[]; } -export const useDownloadActions = ({ outputText }: DownloadActionsProps) => { +export const useDownloadActions = ({ + outputText, + entityMappings = [], +}: DownloadActionsProps) => { const copyToClipboard = () => { - navigator.clipboard.writeText(outputText); + const anonymizedText = generateAnonymizedText(outputText, entityMappings); + navigator.clipboard.writeText(anonymizedText); }; const downloadText = () => { - const blob = new Blob([outputText], { type: "text/plain" }); + const anonymizedText = generateAnonymizedText(outputText, entityMappings); + const blob = new Blob([anonymizedText], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -20,4 +29,4 @@ export const useDownloadActions = ({ outputText }: DownloadActionsProps) => { }; return { copyToClipboard, downloadText }; -}; \ No newline at end of file +}; diff --git a/app/components/EntityMappingTable.tsx b/app/components/EntityMappingTable.tsx index 6f520cd..b7afba2 100644 --- a/app/components/EntityMappingTable.tsx +++ b/app/components/EntityMappingTable.tsx @@ -8,14 +8,7 @@ import { } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; - -interface EntityMapping { - originalValue: string; - anonymizedValue: string; - entityType: string; - startIndex: number; - endIndex: number; -} +import { EntityMapping } from "../config/entityLabels"; interface EntityMappingTableProps { mappings: EntityMapping[]; @@ -24,65 +17,72 @@ interface EntityMappingTableProps { export const EntityMappingTable = ({ mappings }: EntityMappingTableProps) => { if (!mappings || mappings.length === 0) { return ( - - - - Tableau de mapping des entités + + + + Entités détectées -

- Aucune entité sensible détectée dans le texte. +

+ Aucune entité détectée dans le document.

); } + // Créer un compteur pour chaque type d'entité + const entityCounts: { [key: string]: number } = {}; + const mappingsWithNumbers = mappings.map((mapping) => { + const entityType = mapping.entity_type; + entityCounts[entityType] = (entityCounts[entityType] || 0) + 1; + return { + ...mapping, + entityNumber: entityCounts[entityType], + displayName: mapping.displayName || mapping.replacementValue || `[${entityType}]`, + }; + }); + return ( - - - - Tableau de mapping des entités ({mappings.length} entité - {mappings.length > 1 ? "s" : ""} anonymisée - {mappings.length > 1 ? "s" : ""}) + + + + Entités détectées ({mappings.length}) - + {/* Version mobile : Cards empilées */} -
- {mappings.map((mapping, index) => ( +
+ {mappingsWithNumbers.map((mapping, index) => (
-
- - Type d'entité - +
- {mapping.entityType} + {mapping.displayName}
- Valeur originale + Texte détecté
- {mapping.originalValue} + {mapping.text}
- Valeur anonymisée + Identifiant
- {mapping.anonymizedValue} + {mapping.displayName} #{mapping.entityNumber}
@@ -100,29 +100,29 @@ export const EntityMappingTable = ({ mappings }: EntityMappingTableProps) => { Type d'entité - Valeur originale + Texte détecté - - Valeur anonymisée + + Identifiant - {mappings.map((mapping, index) => ( + {mappingsWithNumbers.map((mapping, index) => ( - {mapping.entityType} + {mapping.displayName} - {mapping.originalValue} + {mapping.text} - - {mapping.anonymizedValue} + + {mapping.displayName} #{mapping.entityNumber} ))} diff --git a/app/components/FileHandler.tsx b/app/components/FileHandler.tsx index bd0addb..8281a23 100644 --- a/app/components/FileHandler.tsx +++ b/app/components/FileHandler.tsx @@ -1,15 +1,13 @@ interface FileHandlerProps { setUploadedFile: (file: File | null) => void; setSourceText: (text: string) => void; - setFileContent: (content: string) => void; setError: (error: string | null) => void; - setIsLoadingFile?: (loading: boolean) => void; // Ajouter cette propriété + setIsLoadingFile: (loading: boolean) => void; } export const useFileHandler = ({ setUploadedFile, setSourceText, - setFileContent, setError, setIsLoadingFile, }: FileHandlerProps) => { @@ -22,12 +20,10 @@ export const useFileHandler = ({ setUploadedFile(file); setError(null); setSourceText(""); - setFileContent(""); if (file.type === "text/plain") { try { const text = await file.text(); - setFileContent(text); setSourceText(text); } catch { setError("Erreur lors de la lecture du fichier texte"); @@ -49,11 +45,11 @@ export const useFileHandler = ({ if (!response.ok) { // ✅ Récupérer le message d'erreur détaillé du serveur let errorMessage = `Erreur HTTP: ${response.status}`; - + try { const responseText = await response.text(); console.log("🔍 Réponse brute du serveur:", responseText); - + try { const errorData = JSON.parse(responseText); if (errorData.error) { @@ -63,12 +59,14 @@ export const useFileHandler = ({ } catch (jsonError) { console.error("❌ Erreur parsing JSON:", jsonError); console.error("❌ Réponse non-JSON:", responseText); - errorMessage = `Erreur ${response.status}: ${responseText || 'Réponse invalide du serveur'}`; + errorMessage = `Erreur ${response.status}: ${ + responseText || "Réponse invalide du serveur" + }`; } } catch (readError) { console.error("❌ Impossible de lire la réponse:", readError); } - + throw new Error(errorMessage); } @@ -86,7 +84,6 @@ export const useFileHandler = ({ ); } - setFileContent(extractedText); setSourceText(extractedText); } catch (error) { console.error("Erreur PDF:", error); @@ -96,7 +93,6 @@ export const useFileHandler = ({ : "Erreur lors de la lecture du fichier PDF" ); setUploadedFile(null); - setFileContent(""); setSourceText(""); } finally { // Désactiver le loader une fois terminé diff --git a/app/components/FileUploadComponent.tsx b/app/components/FileUploadComponent.tsx index 9e37caa..4274b29 100644 --- a/app/components/FileUploadComponent.tsx +++ b/app/components/FileUploadComponent.tsx @@ -11,14 +11,9 @@ import { SupportedDataTypes } from "./SupportedDataTypes"; import { AnonymizationInterface } from "./AnonymizationInterface"; import { highlightEntities } from "../utils/highlightEntities"; import { useState } from "react"; +import { EntityMapping } from "../config/entityLabels"; // Importer l'interface unifiée -interface EntityMapping { - originalValue: string; - anonymizedValue: string; - entityType: string; - startIndex: number; - endIndex: number; -} +// Supprimer l'interface locale EntityMapping (lignes 15-21) interface FileUploadComponentProps { uploadedFile: File | null; @@ -26,7 +21,6 @@ interface FileUploadComponentProps { sourceText: string; setSourceText: (text: string) => void; setUploadedFile: (file: File | null) => void; - setFileContent: (content: string) => void; onAnonymize?: () => void; isProcessing?: boolean; canAnonymize?: boolean; @@ -37,7 +31,7 @@ interface FileUploadComponentProps { downloadText?: () => void; isExampleLoaded?: boolean; setIsExampleLoaded?: (loaded: boolean) => void; - entityMappings?: EntityMapping[]; // Ajouter cette prop + entityMappings?: EntityMapping[]; } export const FileUploadComponent = ({ @@ -46,7 +40,6 @@ export const FileUploadComponent = ({ sourceText, setSourceText, setUploadedFile, - setFileContent, onAnonymize, isProcessing = false, canAnonymize = false, @@ -56,7 +49,7 @@ export const FileUploadComponent = ({ copyToClipboard, downloadText, setIsExampleLoaded, - entityMappings, // Ajouter cette prop ici + entityMappings, }: FileUploadComponentProps) => { const [isDragOver, setIsDragOver] = useState(false); @@ -224,8 +217,8 @@ export const FileUploadComponent = ({
{highlightEntities( - outputText || "Aucun contenu à afficher", - entityMappings + sourceText || "Aucun contenu à afficher", // Utiliser sourceText au lieu de outputText + entityMappings || [] // Fournir un tableau vide par défaut )}
@@ -293,8 +286,8 @@ export const FileUploadComponent = ({ {/* Boutons d'action - Responsive mobile */} {canAnonymize && !isLoadingFile && (
- {/* Bouton Anonymiser en premier */} - {onAnonymize && ( + {/* Bouton Anonymiser - seulement si pas encore anonymisé */} + {onAnonymize && !outputText && (
diff --git a/app/components/SampleTextComponent.tsx b/app/components/SampleTextComponent.tsx index 136318a..13ec5be 100644 --- a/app/components/SampleTextComponent.tsx +++ b/app/components/SampleTextComponent.tsx @@ -1,14 +1,12 @@ interface SampleTextComponentProps { setSourceText: (text: string) => void; - setFileContent: (content: string) => void; setUploadedFile: (file: File | null) => void; setIsExampleLoaded?: (loaded: boolean) => void; - variant?: "button" | "link"; // Nouvelle prop + variant?: "button" | "link"; } export const SampleTextComponent = ({ setSourceText, - setFileContent, setUploadedFile, setIsExampleLoaded, variant = "button", @@ -26,16 +24,15 @@ Le contrat de prestation signé le 3 janvier 2024 prévoyait un montant de 75 00 - M. Pieter Van Der Berg (consultant IT, email: p.vanderberg@itconsult.be) **Données sensibles :** -Le serveur compromis contenait 12 000 dossiers clients avec numéros de registre national. L’incident du 28 février 2024 a exposé les données personnelles stockées sur l’adresse IP 10.0.0.45 dans les bureaux situés Rue de la Loi 200, 1040 Etterbeek. +Le serveur compromis contenait 12 000 dossiers clients avec numéros de registre national. L'incident du 28 février 2024 a exposé les données personnelles stockées sur l'adresse IP 10.0.0.45 dans les bureaux situés Rue de la Loi 200, 1040 Etterbeek. Coordonnées bancaires : BE43 0017 5555 5557 (CBC Banque) TVA intracommunautaire : BE0987.654.321`; setSourceText(sampleText); - setFileContent(sampleText); setUploadedFile(null); if (setIsExampleLoaded) { - setIsExampleLoaded(true); // NOUVEAU - Marquer qu'un exemple est chargé + setIsExampleLoaded(true); } }; diff --git a/app/components/TextDisplay.tsx b/app/components/TextDisplay.tsx new file mode 100644 index 0000000..a1dc2ff --- /dev/null +++ b/app/components/TextDisplay.tsx @@ -0,0 +1,108 @@ +import React from "react"; +import { generateColorFromName } from "@/app/config/colorPalette"; +import { Word } from "./hooks/useTextParsing"; + +interface TextDisplayProps { + words: Word[]; + text: string; + selectedWords: Set; + hoveredWord: number | null; + onWordClick: (index: number, event: React.MouseEvent) => void; + onContextMenu: (event: React.MouseEvent) => void; + onWordHover: (index: number | null) => void; +} + +export const TextDisplay: React.FC = ({ + words, + text, + selectedWords, + hoveredWord, + onWordClick, + onContextMenu, + onWordHover, +}) => { + const renderWord = (word: Word, index: number) => { + const isSelected = selectedWords.has(index); + const isHovered = hoveredWord === index; + + let className = + "inline-block cursor-pointer transition-all duration-200 px-1 py-0.5 rounded-sm "; + let backgroundColor = "transparent"; + + if (word.isEntity) { + // Couleur personnalisée ou générée - Niveau 200 + if (word.mapping?.customColor) { + backgroundColor = word.mapping.customColor; + } else if (word.mapping?.displayName) { + // Utiliser generateColorFromName pour la cohérence + backgroundColor = generateColorFromName(word.mapping.displayName).value; + } else if (word.entityType) { + backgroundColor = generateColorFromName(word.entityType).value; + } else { + // Couleur par défaut si aucune information disponible + backgroundColor = generateColorFromName("default").value; + } + + // Utiliser la classe CSS appropriée + if (word.mapping?.displayName) { + const colorClass = generateColorFromName(word.mapping.displayName); + className += `${colorClass.bgClass} ${colorClass.textClass} border `; + } else if (word.entityType) { + const colorClass = generateColorFromName(word.entityType); + className += `${colorClass.bgClass} ${colorClass.textClass} border `; + } + } + + // Gestion du survol et sélection - Couleurs claires + if (isSelected) { + className += "ring-2 ring-blue-400 "; + } else if (isHovered) { + if (!word.isEntity) { + className += "bg-gray-200 "; + backgroundColor = "#E5E7EB"; // gray-200 + } + } + + className += "brightness-95 "; + return ( + onWordHover(index)} + onMouseLeave={() => onWordHover(null)} + onClick={(e) => onWordClick(index, e)} + onContextMenu={onContextMenu} + title={ + word.isEntity + ? `Entité: ${word.entityType} (Original: ${word.text})` + : "Cliquez pour sélectionner" + } + > + {word.displayText}{" "} + + ); + }; + + return ( +
+
+ {words.map((word, index) => { + const nextWord = words[index + 1]; + const spaceBetween = nextWord + ? text.slice(word.end, nextWord.start) + : text.slice(word.end); + + return ( + + {renderWord(word, index)} + {spaceBetween} + + ); + })} +
+
+ ); +}; diff --git a/app/components/hooks/useColorMapping.ts b/app/components/hooks/useColorMapping.ts new file mode 100644 index 0000000..abe4d18 --- /dev/null +++ b/app/components/hooks/useColorMapping.ts @@ -0,0 +1,61 @@ +import { useCallback, useMemo } from "react"; +import { EntityMapping } from "@/app/config/entityLabels"; +import { + COLOR_PALETTE, + generateColorFromName, + type ColorOption, +} from "../../config/colorPalette"; + +export const useColorMapping = (entityMappings: EntityMapping[]) => { + const colorOptions: ColorOption[] = COLOR_PALETTE; + + const tailwindToHex = useMemo(() => { + const mapping: Record = {}; + COLOR_PALETTE.forEach((color) => { + mapping[color.bgClass] = color.value; + }); + return mapping; + }, []); + + // CORRECTION: Fonction qui prend un texte et retourne la couleur + const getCurrentColor = useCallback( + (selectedText: string): string => { + if (!selectedText || !entityMappings) { + return COLOR_PALETTE[0].value; + } + + // Chercher le mapping correspondant au texte sélectionné + const mapping = entityMappings.find((m) => m.text === selectedText); + + if (mapping?.customColor) { + return mapping.customColor; + } + + if (mapping?.displayName) { + return generateColorFromName(mapping.displayName).value; + } + + if (mapping?.entity_type) { + return generateColorFromName(mapping.entity_type).value; + } + + // Générer une couleur basée sur le texte + return generateColorFromName(selectedText).value; + }, + [entityMappings] + ); + + const getColorByText = useCallback( + (selectedText: string) => { + return getCurrentColor(selectedText); + }, + [getCurrentColor] + ); + + return { + colorOptions, + tailwindToHex, + getCurrentColor, + getColorByText, + }; +}; diff --git a/app/components/hooks/useContextMenu.ts b/app/components/hooks/useContextMenu.ts new file mode 100644 index 0000000..580b470 --- /dev/null +++ b/app/components/hooks/useContextMenu.ts @@ -0,0 +1,207 @@ +import { useState, useCallback, useEffect } from "react"; +import { EntityMapping } from "@/app/config/entityLabels"; +import { Word } from "./useTextParsing"; // AJOUTER cet import + +interface ContextMenuState { + visible: boolean; + x: number; + y: number; + selectedText: string; + wordIndices: number[]; +} + +interface UseContextMenuProps { + entityMappings: EntityMapping[]; + words: Word[]; // Maintenant le type Word est reconnu + onUpdateMapping: ( + originalValue: string, + newLabel: string, + entityType: string, + applyToAll?: boolean, + customColor?: string, + wordStart?: number, + wordEnd?: number + ) => void; + onRemoveMapping?: (originalValue: string, applyToAll?: boolean) => void; + getCurrentColor: (selectedText: string) => string; + setSelectedWords: (words: Set) => void; +} + +export const useContextMenu = ({ + entityMappings, + words, // Paramètre ajouté + onUpdateMapping, + onRemoveMapping, + getCurrentColor, + setSelectedWords, +}: UseContextMenuProps) => { + const [contextMenu, setContextMenu] = useState({ + visible: false, + x: 0, + y: 0, + selectedText: "", + wordIndices: [], + }); + + const closeContextMenu = useCallback(() => { + setContextMenu((prev) => ({ ...prev, visible: false })); + }, []); + + const showContextMenu = useCallback( + (menuData: Omit) => { + setContextMenu({ ...menuData, visible: true }); + }, + [] + ); + + const getExistingLabels = useCallback(() => { + const uniqueLabels = new Set(); + entityMappings.forEach((mapping) => { + uniqueLabels.add(mapping.displayName || mapping.entity_type); // Utiliser displayName + }); + return Array.from(uniqueLabels).sort(); + }, [entityMappings]); + + // CORRECTION: Accepter displayName comme premier paramètre + const applyLabel = useCallback( + (displayName: string, applyToAll?: boolean) => { + if (!contextMenu.selectedText) return; + + const originalText = contextMenu.selectedText; + const firstWordIndex = contextMenu.wordIndices[0]; + + // Calculer les vraies coordonnées start/end du mot cliqué + const clickedWord = words[firstWordIndex]; + const wordStart = clickedWord?.start; + const wordEnd = clickedWord?.end; + + const existingMapping = entityMappings.find( + (m) => m.text === originalText + ); + const entityType = + existingMapping?.entity_type || + displayName.replace(/[\[\]]/g, "").toUpperCase(); + + onUpdateMapping( + originalText, + displayName, + entityType, + applyToAll, + undefined, // customColor + wordStart, // vraies coordonnées start + wordEnd // vraies coordonnées end + ); + + setSelectedWords(new Set()); + closeContextMenu(); + }, + [ + contextMenu, + words, // NOUVEAU + entityMappings, + onUpdateMapping, + closeContextMenu, + setSelectedWords, + ] + ); + + // CORRECTION: Accepter applyToAll comme paramètre + const applyColorDirectly = useCallback( + (color: string, colorName: string, applyToAll?: boolean) => { + if (!contextMenu.selectedText) return; + + const existingMapping = entityMappings.find( + (mapping) => mapping.text === contextMenu.selectedText + ); + + console.log("useContextMenu - applyColorDirectly:", { + color, + colorName, + applyToAll, + existingMapping, + }); + + if (existingMapping) { + onUpdateMapping( + contextMenu.selectedText, + existingMapping.displayName || existingMapping.entity_type, // Utiliser displayName + existingMapping.entity_type, + applyToAll, + color + ); + } else { + onUpdateMapping( + contextMenu.selectedText, + "CUSTOM_LABEL", + "CUSTOM_LABEL", + applyToAll, + color + ); + } + + setSelectedWords(new Set()); + closeContextMenu(); + }, + [ + contextMenu.selectedText, + entityMappings, // Ajouter cette dépendance + onUpdateMapping, + closeContextMenu, + setSelectedWords, + ] + ); + + // CORRECTION: Accepter applyToAll comme paramètre + const removeLabel = useCallback( + (applyToAll?: boolean) => { + if (!contextMenu.selectedText || !onRemoveMapping) return; + + console.log("useContextMenu - removeLabel:", { + selectedText: contextMenu.selectedText, + applyToAll, + }); + + onRemoveMapping(contextMenu.selectedText, applyToAll); + setSelectedWords(new Set()); + closeContextMenu(); + }, + [ + contextMenu.selectedText, + onRemoveMapping, + closeContextMenu, + setSelectedWords, + ] + ); + + // Gestion des clics en dehors du menu + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (contextMenu.visible) { + const target = event.target as Element; + const contextMenuElement = document.querySelector( + "[data-context-menu]" + ); + + if (contextMenuElement && !contextMenuElement.contains(target)) { + setTimeout(() => { + closeContextMenu(); + }, 0); + } + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [contextMenu.visible, closeContextMenu]); + + return { + contextMenu, + showContextMenu, + closeContextMenu, + applyLabel, + applyColorDirectly, + removeLabel, + getExistingLabels, + getCurrentColor, + }; +}; diff --git a/app/components/hooks/useTextParsing.ts b/app/components/hooks/useTextParsing.ts new file mode 100644 index 0000000..fc1ba11 --- /dev/null +++ b/app/components/hooks/useTextParsing.ts @@ -0,0 +1,98 @@ +import { useMemo } from "react"; +import { EntityMapping } from "@/app/config/entityLabels"; + +export interface Word { + text: string; + displayText: string; + start: number; + end: number; + isEntity: boolean; + entityType?: string; + entityIndex?: number; + mapping?: EntityMapping; +} + +export const useTextParsing = ( + text: string, + entityMappings: EntityMapping[] +) => { + const words = useMemo((): Word[] => { + const segments: Word[] = []; + let currentIndex = 0; + + const sortedMappings = [...entityMappings].sort( + (a, b) => a.start - b.start // CORRECTION: utiliser 'start' au lieu de 'startIndex' + ); + + sortedMappings.forEach((mapping, mappingIndex) => { + if (currentIndex < mapping.start) { + // CORRECTION: utiliser 'start' + const beforeText = text.slice(currentIndex, mapping.start); + const beforeWords = beforeText.split(/\s+/).filter(Boolean); + + beforeWords.forEach((word) => { + const wordStart = text.indexOf(word, currentIndex); + const wordEnd = wordStart + word.length; + + segments.push({ + text: word, + displayText: word, + start: wordStart, + end: wordEnd, + isEntity: false, + }); + + currentIndex = wordEnd; + }); + } + + // Utiliser displayName au lieu de entity_type + // Ligne 45 - Ajouter du debug + console.log("useTextParsing - mapping:", { + text: mapping.text, + displayName: mapping.displayName, + entity_type: mapping.entity_type, + }); + + const anonymizedText = + mapping.displayName || `[${mapping.entity_type.toUpperCase()}]`; + + segments.push({ + text: mapping.text, + displayText: anonymizedText, + start: mapping.start, + end: mapping.end, + isEntity: true, + entityType: mapping.entity_type, + entityIndex: mappingIndex, + mapping: mapping, + }); + + currentIndex = mapping.end; // CORRECTION: utiliser 'end' + }); + + if (currentIndex < text.length) { + const remainingText = text.slice(currentIndex); + const remainingWords = remainingText.split(/\s+/).filter(Boolean); + + remainingWords.forEach((word) => { + const wordStart = text.indexOf(word, currentIndex); + const wordEnd = wordStart + word.length; + + segments.push({ + text: word, + displayText: word, + start: wordStart, + end: wordEnd, + isEntity: false, + }); + + currentIndex = wordEnd; + }); + } + + return segments; + }, [text, entityMappings]); + + return { words }; +}; diff --git a/app/config/colorPalette.ts b/app/config/colorPalette.ts new file mode 100644 index 0000000..21cf469 --- /dev/null +++ b/app/config/colorPalette.ts @@ -0,0 +1,83 @@ +export interface ColorOption { + name: string; + value: string; + bgClass: string; + textClass: string; +} + +// Palette de couleurs harmonisée (équivalent Tailwind 200) +export const COLOR_PALETTE: ColorOption[] = [ + { + name: 'Rouge', + value: '#fecaca', // red-200 + bgClass: 'bg-red-200', + textClass: 'text-red-800' + }, + { + name: 'Orange', + value: '#fed7aa', // orange-200 + bgClass: 'bg-orange-200', + textClass: 'text-orange-800' + }, + { + name: 'Jaune', + value: '#fef3c7', // yellow-200 + bgClass: 'bg-yellow-200', + textClass: 'text-yellow-800' + }, + { + name: 'Vert', + value: '#bbf7d0', // green-200 + bgClass: 'bg-green-200', + textClass: 'text-green-800' + }, + { + name: 'Bleu', + value: '#bfdbfe', // blue-200 + bgClass: 'bg-blue-200', + textClass: 'text-blue-800' + }, + { + name: 'Indigo', + value: '#c7d2fe', // indigo-200 + bgClass: 'bg-indigo-200', + textClass: 'text-indigo-800' + }, + { + name: 'Violet', + value: '#ddd6fe', // violet-200 + bgClass: 'bg-violet-200', + textClass: 'text-violet-800' + }, + { + name: 'Rose', + value: '#fbcfe8', // pink-200 + bgClass: 'bg-pink-200', + textClass: 'text-pink-800' + } +]; + +// Fonction pour obtenir une couleur par hash +export function generateColorFromName(name: string): ColorOption { + // Vérification de sécurité + if (!name || typeof name !== 'string' || name.length === 0) { + return COLOR_PALETTE[0]; // Retourner la première couleur par défaut + } + + let hash = 0; + for (let i = 0; i < name.length; i++) { + const char = name.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + const index = Math.abs(hash) % COLOR_PALETTE.length; + return COLOR_PALETTE[index]; +} + +// Fonction pour obtenir la couleur hex +export function getHexColorFromName(name: string): string { + return generateColorFromName(name).value; +} + +// Export des valeurs hex pour compatibilité +export const COLOR_VALUES = COLOR_PALETTE.map(color => color.value); \ No newline at end of file diff --git a/app/config/entityLabels.ts b/app/config/entityLabels.ts new file mode 100644 index 0000000..bc18fc9 --- /dev/null +++ b/app/config/entityLabels.ts @@ -0,0 +1,65 @@ +// Configuration des entités basée sur replacements.yaml +// Système 100% dynamique +// Tout est récupéré depuis Presidio + +export interface EntityPattern { + regex: RegExp; + className: string; + label: string; + presidioType: string; +} + +export interface PresidioConfig { + replacements: Record; + default_anonymizers: Record; +} + +// Interface pour les résultats Presidio +/** + * Interfaces pour les données de Presidio et le mapping. + * Simplifié pour ne contenir que les définitions nécessaires. + */ + +// Interface pour un résultat d'analyse de Presidio +export interface PresidioAnalyzerResult { + entity_type: string; + start: number; + end: number; + score: number; +} + +// Interface pour une ligne du tableau de mapping +import { generateColorFromName, getHexColorFromName } from "./colorPalette"; + +export interface EntityMapping { + entity_type: string; + start: number; + end: number; + text: string; + replacementValue?: string; + displayName?: string; // Ajouter cette propriété + customColor?: string; +} + +// Utiliser la palette centralisée +export { generateColorFromName, getHexColorFromName }; + +/** + * Récupère la configuration Presidio depuis l'API + */ +export const fetchPresidioConfig = async (): Promise => { + try { + const response = await fetch("/api/presidio/config"); + if (!response.ok) { + console.warn("Impossible de récupérer la config Presidio"); + return null; + } + return await response.json(); + } catch (error) { + console.warn( + "Erreur lors de la récupération de la config Presidio:", + error + ); + return null; + } +}; diff --git a/app/hooks/useEntityMappings.ts b/app/hooks/useEntityMappings.ts new file mode 100644 index 0000000..7638895 --- /dev/null +++ b/app/hooks/useEntityMappings.ts @@ -0,0 +1,137 @@ +import { useState, useCallback } from "react"; +import { EntityMapping } from "@/app/config/entityLabels"; + +export const useEntityMappings = (initialMappings: EntityMapping[] = []) => { + const [mappings, setMappings] = useState(initialMappings); + + const updateMapping = useCallback( + ( + originalValue: string, + newLabel: string, + entityType: string, + sourceText: string, + applyToAllOccurrences: boolean = false, + customColor?: string + ) => { + setMappings((prevMappings) => { + let baseMappings = [...prevMappings]; + const newMappings: EntityMapping[] = []; + + if (applyToAllOccurrences) { + // Supprimer toutes les anciennes occurrences et les recréer + baseMappings = baseMappings.filter((m) => m.text !== originalValue); + + let searchIndex = 0; + while (true) { + const foundIndex = sourceText.indexOf(originalValue, searchIndex); + if (foundIndex === -1) break; + + newMappings.push({ + text: originalValue, + entity_type: entityType, + start: foundIndex, + end: foundIndex + originalValue.length, + customColor: customColor, + }); + searchIndex = foundIndex + originalValue.length; + } + } else { + // Mettre à jour une seule occurrence ou en créer une nouvelle + const existingMapping = prevMappings.find( + (m) => m.text === originalValue + ); + + if (existingMapping) { + // Remplacer le mapping existant au lieu de filtrer + baseMappings = prevMappings.map((m) => { + if ( + m.start === existingMapping.start && + m.end === existingMapping.end + ) { + return { + ...m, + entity_type: entityType, + displayName: newLabel, // Utiliser newLabel au lieu de préserver l'ancien + customColor: customColor, + }; + } + return m; + }); + } else { + // Créer un nouveau mapping pour du texte non reconnu + const foundIndex = sourceText.indexOf(originalValue); + if (foundIndex !== -1) { + newMappings.push({ + text: originalValue, + entity_type: entityType, + start: foundIndex, + end: foundIndex + originalValue.length, + displayName: newLabel, // Utiliser newLabel au lieu de entityType + customColor: customColor, + }); + } + } + } + + // Combiner, dédupliquer et trier + const allMappings = [...baseMappings, ...newMappings]; + const uniqueMappings = allMappings.filter( + (mapping, index, self) => + index === + self.findIndex( + (m) => m.start === mapping.start && m.end === mapping.end + ) + ); + + return uniqueMappings.sort((a, b) => a.start - b.start); + }); + }, + [] + ); + + const addMapping = useCallback((mapping: EntityMapping) => { + setMappings((prev) => [...prev, mapping]); + }, []); + + const removeMapping = useCallback((index: number) => { + setMappings((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const removeMappingByValue = useCallback((originalValue: string) => { + setMappings((prev) => + prev.filter((mapping) => mapping.text !== originalValue) + ); + }, []); + + // NOUVELLE FONCTION: Suppression avec gestion d'applyToAll + const removeMappingByValueWithOptions = useCallback( + (originalValue: string, applyToAll: boolean = false) => { + setMappings((prev) => { + if (applyToAll) { + // Supprimer toutes les occurrences du texte + return prev.filter((mapping) => mapping.text !== originalValue); + } else { + // Supprimer seulement la première occurrence ou celle à la position actuelle + // Pour l'instant, on supprime la première occurrence trouvée + const indexToRemove = prev.findIndex( + (mapping) => mapping.text === originalValue + ); + if (indexToRemove !== -1) { + return prev.filter((_, index) => index !== indexToRemove); + } + return prev; + } + }); + }, + [] + ); + + return { + mappings, + updateMapping, + addMapping, + removeMapping, + removeMappingByValue, + removeMappingByValueWithOptions, // Ajouter la nouvelle fonction + }; +}; diff --git a/app/page.tsx b/app/page.tsx index 0fdfd3f..58fab85 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,31 +1,25 @@ "use client"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import { FileUploadComponent } from "./components/FileUploadComponent"; - import { EntityMappingTable } from "./components/EntityMappingTable"; import { ProgressBar } from "./components/ProgressBar"; import { useFileHandler } from "./components/FileHandler"; import { useAnonymization } from "./components/AnonymizationLogic"; import { useDownloadActions } from "./components/DownloadActions"; +import { ResultPreviewComponent } from "./components/ResultPreviewComponent"; +import { EntityMapping } from "./config/entityLabels"; // Importer l'interface unifiée -interface EntityMapping { - originalValue: string; - anonymizedValue: string; - entityType: string; - startIndex: number; - endIndex: number; -} +// Supprimer l'interface locale EntityMapping (lignes 12-18) export default function Home() { const [sourceText, setSourceText] = useState(""); const [outputText, setOutputText] = useState(""); const [uploadedFile, setUploadedFile] = useState(null); - const [fileContent, setFileContent] = useState(""); const [error, setError] = useState(null); const [isLoadingFile, setIsLoadingFile] = useState(false); const [entityMappings, setEntityMappings] = useState([]); - const [isExampleLoaded, setIsExampleLoaded] = useState(false); // NOUVEAU + const [isExampleLoaded, setIsExampleLoaded] = useState(false); const progressSteps = ["Téléversement", "Prévisualisation", "Anonymisation"]; @@ -40,35 +34,46 @@ export default function Home() { setSourceText(""); setOutputText(""); setUploadedFile(null); - setFileContent(""); setError(null); setIsLoadingFile(false); setEntityMappings([]); - setIsExampleLoaded(false); // NOUVEAU + setIsExampleLoaded(false); }; + // Fonction pour mettre à jour les mappings depuis l'éditeur interactif + const handleMappingsUpdate = useCallback( + (updatedMappings: EntityMapping[]) => { + setEntityMappings(updatedMappings); + }, + [] + ); + // Hooks personnalisés pour la logique métier const { handleFileChange } = useFileHandler({ setUploadedFile, setSourceText, - setFileContent, setError, - setIsLoadingFile, // Passer le setter + setIsLoadingFile, }); const { anonymizeData, isProcessing } = useAnonymization({ - sourceText, - fileContent, - uploadedFile, setOutputText, setError, setEntityMappings, }); - const { copyToClipboard, downloadText } = useDownloadActions({ outputText }); + const { copyToClipboard, downloadText } = useDownloadActions({ + outputText, + entityMappings, + }); + + // Fonction wrapper pour appeler anonymizeData avec les bonnes données + const handleAnonymize = () => { + anonymizeData({ file: uploadedFile, text: sourceText }); + }; return ( -
+
{/* Main Content */}
{/* Progress Bar */} @@ -83,8 +88,7 @@ export default function Home() { sourceText={sourceText} setSourceText={setSourceText} setUploadedFile={setUploadedFile} - setFileContent={setFileContent} - onAnonymize={anonymizeData} + onAnonymize={handleAnonymize} isProcessing={isProcessing} canAnonymize={ uploadedFile !== null || @@ -97,11 +101,27 @@ export default function Home() { downloadText={downloadText} isExampleLoaded={isExampleLoaded} setIsExampleLoaded={setIsExampleLoaded} - entityMappings={entityMappings} // Ajouter cette ligne + entityMappings={entityMappings} />
+ {/* Interactive Text Editor - Nouveau composant pour l'édition interactive */} + {outputText && ( +
+
+ +
+
+ )} + {/* Entity Mapping Table - Seulement si outputText existe */} {outputText && (
diff --git a/app/utils/entityBoundary.ts b/app/utils/entityBoundary.ts new file mode 100644 index 0000000..534cce5 --- /dev/null +++ b/app/utils/entityBoundary.ts @@ -0,0 +1,17 @@ +/** + * Vérifie si une entité est située à une limite de mot valide + * @param index Position de début de l'entité dans le texte + * @param text Texte complet + * @param word Mot/entité à vérifier + * @returns true si l'entité est à une limite de mot valide + */ +export const isValidEntityBoundary = ( + index: number, + text: string, + word: string +): boolean => { + const before = index === 0 || /\s/.test(text[index - 1]); + const after = + index + word.length === text.length || /\s/.test(text[index + word.length]); + return before && after; +}; diff --git a/app/utils/generateAnonymizedText.ts b/app/utils/generateAnonymizedText.ts new file mode 100644 index 0000000..2ec87d4 --- /dev/null +++ b/app/utils/generateAnonymizedText.ts @@ -0,0 +1,32 @@ +import { EntityMapping } from "@/app/config/entityLabels"; + +export const generateAnonymizedText = ( + originalText: string, + mappings: EntityMapping[] +): string => { + if (!originalText || !mappings || mappings.length === 0) { + return originalText; + } + + // Trier les mappings par position de début + const sortedMappings = [...mappings].sort((a, b) => a.start - b.start); + + let result = ""; + let lastIndex = 0; + + for (const mapping of sortedMappings) { + // Ajouter le texte avant l'entité + result += originalText.slice(lastIndex, mapping.start); + + // Utiliser displayName comme dans le tableau de mapping + result += mapping.displayName; + + // Mettre à jour la position + lastIndex = mapping.end; + } + + // Ajouter le reste du texte + result += originalText.slice(lastIndex); + + return result; +}; diff --git a/app/utils/highlightEntities.tsx b/app/utils/highlightEntities.tsx index 4de9974..64ecc4d 100644 --- a/app/utils/highlightEntities.tsx +++ b/app/utils/highlightEntities.tsx @@ -1,199 +1,50 @@ -import { ReactNode } from "react"; - -export const patterns = [ - { - regex: //g, - className: "bg-blue-200 text-blue-800", - label: "Personne", - }, - { - regex: //g, - className: "bg-green-200 text-green-800", - label: "Adresse Email", - }, - { - regex: //g, - className: "bg-purple-200 text-purple-800", - label: "N° de Téléphone", - }, - { - regex: //g, - className: "bg-red-200 text-red-800", - label: "Lieu", - }, - { - regex: //g, - className: "bg-yellow-200 text-yellow-800", - label: "IBAN", - }, - { - regex: //g, - className: "bg-indigo-200 text-indigo-800", - label: "Organisation", - }, - { - regex: //g, - className: "bg-pink-200 text-pink-800", - label: "Date", - }, - { - regex: //g, - className: "bg-cyan-200 text-cyan-800", - label: "Adresse (BE)", - }, - { - regex: //g, - className: "bg-violet-200 text-violet-800", - label: "N° de Tél. (BE)", - }, - { - regex: //g, - className: "bg-orange-200 text-orange-800", - label: "Carte de Crédit", - }, - { - regex: //g, - className: "bg-teal-200 text-teal-800", - label: "URL", - }, - { - regex: //g, - className: "bg-gray-300 text-gray-900", - label: "Adresse IP", - }, - { - regex: //g, - className: "bg-pink-300 text-pink-900", - label: "Date & Heure", - }, - { - regex: //g, - className: "bg-red-300 text-red-900", - label: "N° Registre National", - }, - { - regex: //g, - className: "bg-yellow-300 text-yellow-900", - label: "TVA (BE)", - }, - { - regex: //g, - className: "bg-lime-200 text-lime-800", - label: "N° d'entreprise (BE)", - }, - { - regex: //g, - className: "bg-emerald-200 text-emerald-800", - label: "ID Pro (BE)", - }, -]; - -interface EntityMapping { - originalValue: string; - anonymizedValue: string; - entityType: string; - startIndex: number; - endIndex: number; -} +import React, { ReactNode } from "react"; +import { + generateColorFromName, + EntityMapping, +} from "@/app/config/entityLabels"; export const highlightEntities = ( - text: string, - entityMappings?: EntityMapping[] -): ReactNode => { - if (!text) return text; - - const replacements: Array<{ - start: number; - end: number; - element: ReactNode; - }> = []; - - // Trouver toutes les correspondances - patterns.forEach((pattern, patternIndex) => { - const regex = new RegExp(pattern.regex.source, pattern.regex.flags); - let match; - let matchCount = 0; // Compteur pour ce type d'entité - - while ((match = regex.exec(text)) !== null) { - const start = match.index; - const end = match.index + match[0].length; - - // Vérifier qu'il n'y a pas de chevauchement avec des remplacements existants - const hasOverlap = replacements.some( - (r) => - (start >= r.start && start < r.end) || (end > r.start && end <= r.end) - ); - - if (!hasOverlap) { - matchCount++; // Incrémenter le compteur pour ce type - let displayLabel = pattern.label; - const displayClass = pattern.className; - - if (entityMappings) { - // Chercher le mapping correspondant à cette position et ce type - const matchingMapping = entityMappings.find( - (mapping) => mapping.entityType === pattern.label - ); - - if (matchingMapping) { - // Utiliser directement la valeur anonymisée du mapping - // qui correspond à cette occurrence (basée sur l'ordre d'apparition) - const entityType = pattern.label; - const mappingsOfThisType = entityMappings.filter( - (m) => m.entityType === entityType - ); - - // Prendre le mapping correspondant à cette occurrence - if (mappingsOfThisType[matchCount - 1]) { - displayLabel = mappingsOfThisType[matchCount - 1].anonymizedValue; - } else { - // Fallback si pas de mapping trouvé - displayLabel = `${entityType} [${matchCount}]`; - } - } - } - - const element = ( - - {displayLabel} - - ); - - replacements.push({ start, end, element }); - } - } - }); - - // Trier les remplacements par position - replacements.sort((a, b) => a.start - b.start); - - // Construire le résultat final - if (replacements.length === 0) { - return text; + originalText: string, + mappings?: EntityMapping[] +): ReactNode[] => { + if (!originalText || !mappings || mappings.length === 0) { + return [originalText]; } const parts: ReactNode[] = []; let lastIndex = 0; - replacements.forEach((replacement) => { - // Ajouter le texte avant le remplacement - if (replacement.start > lastIndex) { - parts.push(text.slice(lastIndex, replacement.start)); + // Les mappings sont triés par `start` + mappings.forEach((mapping, index) => { + const { start, end, entity_type, text } = mapping; + + // Ajouter le segment de texte AVANT l'entité actuelle + if (start > lastIndex) { + parts.push(originalText.slice(lastIndex, start)); } - // Ajouter l'élément de remplacement - parts.push(replacement.element); + // Créer et ajouter le badge stylisé pour l'entité + const colorOption = generateColorFromName(entity_type); + const displayText = mapping.displayName || `[${entity_type.toUpperCase()}]`; + + parts.push( + + {displayText} + + ); - lastIndex = replacement.end; + // Mettre à jour la position pour la prochaine itération + lastIndex = end; }); - // Ajouter le texte restant - if (lastIndex < text.length) { - parts.push(text.slice(lastIndex)); + // Ajouter le reste du texte après la dernière entité + if (lastIndex < originalText.length) { + parts.push(originalText.slice(lastIndex)); } return parts; diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/tailwind.config.ts b/tailwind.config.ts index 13736eb..f97f1c8 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -11,9 +11,7 @@ const config: Config = { theme: { extend: {}, }, - plugins: [ - typography, // C'est ici qu'on active le plugin pour la classe 'prose' - ], + plugins: [typography], }; export default config;