interface interactive

This commit is contained in:
nBiqoz
2025-09-07 12:30:23 +02:00
parent 74e56c956c
commit ef0819ae90
27 changed files with 1827 additions and 515 deletions

View File

@@ -17,7 +17,7 @@ export const AnonymizationInterface = ({
const anonymizedTypes = new Set<string>();
if (outputText.includes("<PERSON>")) {
if (outputText.includes("<PERSONNE>")) {
anonymizedTypes.add("Prénoms");
anonymizedTypes.add("Noms de famille");
anonymizedTypes.add("Noms complets");

View File

@@ -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<string, string>; // 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<string, string>();
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<string, number>();
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<string, number>();
// 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<string>();
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,
};
};

View File

@@ -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<ContextMenuProps> = ({
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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div
ref={menuRef}
data-context-menu
className="fixed z-50 bg-white border border-gray-300 rounded-md"
style={{
left: position.x,
top: position.y,
transform: "translate(-50%, -10px)",
minWidth: "fit-content",
whiteSpace: "nowrap",
}}
onClick={handleMenuClick}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Une seule ligne avec tous les contrôles */}
<div className="flex items-center px-2 py-1 space-x-2">
{/* Texte sélectionné complet */}
<div className="flex-shrink-0">
<div className="text-xs text-gray-800 bg-gray-50 px-2 py-1 rounded font-mono border">
{contextMenu.selectedText}
</div>
</div>
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
{/* Labels existants */}
{existingLabels.length > 0 && (
<>
<div className="flex-shrink-0">
<select
onChange={(e) => {
e.stopPropagation();
if (e.target.value) {
const selectedDisplayName = e.target.value; // displayName
// CORRECTION: Plus besoin de chercher entity_type !
onApplyLabel(selectedDisplayName, applyToAll);
}
}}
onClick={(e) => e.stopPropagation()}
className="text-xs border border-gray-300 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-500"
defaultValue=""
>
<option value="" disabled>
Chosi
</option>
{existingLabels.map((label) => (
<option key={label} value={label}>
{label}
</option>
))}
</select>
</div>
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
</>
)}
{/* Nouveau label */}
<div className="flex-shrink-0">
<div className="flex items-center space-x-1">
{!showNewLabelInput ? (
<button
type="button"
onClick={handleNewLabelClick}
onMouseDown={(e) => e.stopPropagation()}
className="px-1 py-1 text-xs text-green-600 border border-green-300 rounded hover:bg-green-50 transition-colors flex items-center justify-center w-6 h-6 focus:outline-none focus:ring-1 focus:ring-green-500"
title="Ajouter un nouveau label"
>
<svg
className="h-3 w-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
) : (
<div className="flex items-center space-x-1">
<input
ref={inputRef}
type="text"
value={customLabel}
onChange={(e) => {
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"
/>
<button
type="button"
onClick={handleApplyCustomLabel}
onMouseDown={(e) => e.stopPropagation()}
disabled={!customLabel.trim()}
className="px-1 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus:outline-none focus:ring-1 focus:ring-blue-500"
title="Appliquer le label"
>
<Check className="h-3 w-3" />
</button>
<button
type="button"
onClick={handleCancelNewLabel}
onMouseDown={(e) => e.stopPropagation()}
className="px-1 py-1 text-gray-500 hover:text-gray-700 transition-colors focus:outline-none focus:ring-1 focus:ring-gray-500"
title="Annuler"
>
<RotateCcw className="h-3 w-3" />
</button>
</div>
)}
</div>
</div>
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
{/* Sélecteur de couleur */}
<div className="flex-shrink-0 relative">
<button
type="button"
className="w-5 h-5 rounded-full border-2 border-gray-300 cursor-pointer hover:border-gray-400 transition-all"
style={{
backgroundColor: getCurrentColor(contextMenu.selectedText),
}}
onClick={(e) => {
e.stopPropagation();
setShowColorPalette(!showColorPalette);
setShowNewLabelInput(false);
}}
onMouseDown={(e) => e.stopPropagation()}
title="Couleur actuelle du label"
/>
{showColorPalette && (
<div className="flex items-center space-x-1 bg-gray-50 p-1 rounded border absolute z-10 mt-1 left-0">
{colorOptions.map((color) => (
<button
key={color.value}
type="button"
onClick={(e) => {
e.stopPropagation();
onApplyColor(color.value, color.name, applyToAll);
setShowColorPalette(false);
}}
onMouseDown={(e) => e.stopPropagation()}
className="w-4 h-4 rounded-full border-2 border-gray-300 cursor-pointer hover:border-gray-400 transition-all"
style={{ backgroundColor: color.value }}
title={color.name}
/>
))}
</div>
)}
</div>
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
{/* Bouton supprimer */}
<div className="flex-shrink-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveLabel(applyToAll);
}}
onMouseDown={(e) => e.stopPropagation()}
className="px-1 py-1 text-xs text-red-600 border border-red-300 rounded hover:bg-red-50 transition-colors flex items-center justify-center w-6 h-6 focus:outline-none focus:ring-1 focus:ring-red-500"
title="Supprimer le label"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
{/* Case à cocher "Toutes les occurrences" */}
<div className="flex-shrink-0">
<div className="flex items-center space-x-1">
<input
type="checkbox"
id="applyToAll"
checked={applyToAll}
onChange={(e) => {
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"
/>
<label
htmlFor="applyToAll"
className="text-xs text-gray-700 cursor-pointer select-none whitespace-nowrap"
onClick={(e) => {
e.stopPropagation();
setApplyToAll(!applyToAll);
}}
>
Toutes les occurences
</label>
</div>
</div>
</div>
</div>
);
};

View File

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

View File

@@ -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 (
<Card className="mt-6">
<CardHeader className="pb-4">
<CardTitle className="text-base sm:text-lg font-medium text-[#092727]">
Tableau de mapping des entités
<Card className="mt-8">
<CardHeader>
<CardTitle className="text-lg font-medium text-[#092727]">
Entités déteces
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500 text-center py-4 text-sm">
Aucune entité sensible détectée dans le texte.
<p className="text-gray-500 text-center py-8">
Aucune entité détectée dans le document.
</p>
</CardContent>
</Card>
);
}
// 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 (
<Card className="mt-6">
<CardHeader className="pb-4">
<CardTitle className="text-base sm:text-lg font-medium text-[#092727]">
Tableau de mapping des entités ({mappings.length} entité
{mappings.length > 1 ? "s" : ""} anonymisée
{mappings.length > 1 ? "s" : ""})
<Card className="mt-8">
<CardHeader>
<CardTitle className="text-lg font-medium text-[#092727]">
Entités déteces ({mappings.length})
</CardTitle>
</CardHeader>
<CardContent className="px-2 sm:px-6">
<CardContent>
{/* Version mobile : Cards empilées */}
<div className="block sm:hidden space-y-4">
{mappings.map((mapping, index) => (
<div className="sm:hidden space-y-4">
{mappingsWithNumbers.map((mapping, index) => (
<div
key={index}
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
className="border rounded-lg p-4 bg-white shadow-sm"
>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-600">
Type d&apos;entité
</span>
<div>
<Badge
variant="outline"
className="bg-[#f7ab6e] bg-opacity-20 text-[#092727] border-[#f7ab6e] text-xs"
className="bg-[#f7ab6e] bg-opacity-20 text-[#092727] border-[#f7ab6e]"
>
{mapping.entityType}
{mapping.displayName}
</Badge>
</div>
<div className="space-y-2">
<div>
<span className="text-xs font-medium text-gray-600 block mb-1">
Valeur originale
Texte détecté
</span>
<div className="font-mono text-xs bg-red-50 text-red-700 p-2 rounded border break-all">
{mapping.originalValue}
{mapping.text}
</div>
</div>
<div>
<span className="text-xs font-medium text-gray-600 block mb-1">
Valeur anonymisée
Identifiant
</span>
<div className="font-mono text-xs bg-green-50 text-green-700 p-2 rounded border break-all">
{mapping.anonymizedValue}
{mapping.displayName} #{mapping.entityNumber}
</div>
</div>
</div>
@@ -100,29 +100,29 @@ export const EntityMappingTable = ({ mappings }: EntityMappingTableProps) => {
Type d&apos;entité
</TableHead>
<TableHead className="font-semibold text-[#092727] min-w-[150px]">
Valeur originale
Texte détecté
</TableHead>
<TableHead className="font-semibold text-[#092727] min-w-[150px]">
Valeur anonymisée
<TableHead className="font-semibold text-[#092727] min-w-[100px]">
Identifiant
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mappings.map((mapping, index) => (
{mappingsWithNumbers.map((mapping, index) => (
<TableRow key={index} className="hover:bg-gray-50">
<TableCell className="py-4">
<Badge
variant="outline"
className="bg-[#f7ab6e] bg-opacity-20 text-[#092727] border-[#f7ab6e]"
>
{mapping.entityType}
{mapping.displayName}
</Badge>
</TableCell>
<TableCell className="font-mono text-sm bg-red-50 text-red-700 py-4 max-w-[200px] break-all">
{mapping.originalValue}
{mapping.text}
</TableCell>
<TableCell className="font-mono text-sm bg-green-50 text-green-700 py-4 max-w-[200px] break-all">
{mapping.anonymizedValue}
<TableCell className="font-mono text-sm bg-green-50 text-green-700 py-4">
{mapping.displayName} #{mapping.entityNumber}
</TableCell>
</TableRow>
))}

View File

@@ -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é

View File

@@ -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 = ({
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 sm:p-4 max-h-72 overflow-y-auto overflow-x-hidden">
<div className="text-xs sm:text-sm text-gray-700 whitespace-pre-wrap break-words overflow-wrap-anywhere leading-relaxed">
{highlightEntities(
outputText || "Aucun contenu à afficher",
entityMappings
sourceText || "Aucun contenu à afficher", // Utiliser sourceText au lieu de outputText
entityMappings || [] // Fournir un tableau vide par défaut
)}
</div>
</div>
@@ -293,8 +286,8 @@ export const FileUploadComponent = ({
{/* Boutons d'action - Responsive mobile */}
{canAnonymize && !isLoadingFile && (
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
{/* Bouton Anonymiser en premier */}
{onAnonymize && (
{/* Bouton Anonymiser - seulement si pas encore anonymisé */}
{onAnonymize && !outputText && (
<button
onClick={onAnonymize}
disabled={isProcessing}
@@ -326,7 +319,7 @@ export const FileUploadComponent = ({
</button>
)}
{/* Bouton Recommencer */}
{/* Bouton Recommencer - toujours visible */}
{onRestart && (
<button
onClick={onRestart}
@@ -399,7 +392,6 @@ export const FileUploadComponent = ({
<span>Commencez à taper du texte, ou&nbsp;</span>
<SampleTextComponent
setSourceText={setSourceText}
setFileContent={setFileContent}
setUploadedFile={setUploadedFile}
setIsExampleLoaded={setIsExampleLoaded}
variant="link"

View File

@@ -0,0 +1,28 @@
import React from "react";
import { Info } from "lucide-react";
export const InstructionsPanel: React.FC = () => {
return (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-2">Instructions d&apos;utilisation :</p>
<ul className="space-y-1 text-blue-700">
<li> Survolez les mots pour les mettre en évidence</li>
<li>
Cliquez pour sélectionner un mot, Crtl + clic pour plusieurs
mots
</li>
<li> Faites clic droit pour ouvrir le menu contextuel</li>
<li> Modifiez les labels et couleurs selon vos besoins</li>
<li>
Utilisez &quot;Toutes les occurrences&quot; pour appliquer à
tous les mots similaires
</li>
</ul>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,123 @@
import React, { useState, useRef, useCallback } from "react";
import { EntityMapping } from "@/app/config/entityLabels";
import { useTextParsing } from "./hooks/useTextParsing";
import { useContextMenu } from "./hooks/useContextMenu";
import { useColorMapping } from "./hooks/useColorMapping";
import { TextDisplay } from "./TextDisplay";
import { ContextMenu } from "./ContextMenu";
import { InstructionsPanel } from "./InstructionsPanel";
interface InteractiveTextEditorProps {
text: string;
entityMappings: EntityMapping[];
onUpdateMapping: (
originalValue: string,
newLabel: string,
entityType: string,
applyToAllOccurrences?: boolean,
customColor?: string // Ajouter ce paramètre
) => void;
onRemoveMapping?: (originalValue: string) => void;
}
export const InteractiveTextEditor: React.FC<InteractiveTextEditorProps> = ({
text,
entityMappings,
onUpdateMapping,
onRemoveMapping,
}) => {
const [selectedWords, setSelectedWords] = useState<Set<number>>(new Set());
const [hoveredWord, setHoveredWord] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { words } = useTextParsing(text, entityMappings);
const { getCurrentColor } = useColorMapping(entityMappings); // CORRECTION: Passer entityMappings
const {
contextMenu,
showContextMenu,
applyLabel,
applyColorDirectly,
removeLabel,
getExistingLabels,
} = useContextMenu({
entityMappings,
words, // NOUVEAU: passer les mots
onUpdateMapping,
onRemoveMapping,
getCurrentColor,
setSelectedWords,
});
const handleWordClick = useCallback(
(index: number, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (event.ctrlKey || event.metaKey) {
setSelectedWords((prev) => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
return newSet;
});
} else {
setSelectedWords(new Set([index]));
}
},
[]
);
const handleContextMenu = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
if (selectedWords.size === 0) return;
const selectedText = Array.from(selectedWords)
.map((index) => {
const word = words[index];
return word?.isEntity ? word.text : word?.text;
})
.filter(Boolean)
.join(" ");
showContextMenu({
x: event.clientX,
y: event.clientY,
selectedText,
wordIndices: Array.from(selectedWords),
});
},
[selectedWords, words, showContextMenu]
);
return (
<div ref={containerRef} className="relative">
<InstructionsPanel />
<TextDisplay
words={words}
text={text}
selectedWords={selectedWords}
hoveredWord={hoveredWord}
onWordClick={handleWordClick}
onContextMenu={handleContextMenu}
onWordHover={setHoveredWord}
/>
{contextMenu.visible && (
<ContextMenu
contextMenu={contextMenu}
existingLabels={getExistingLabels()}
// entityMappings={entityMappings} // SUPPRIMER cette ligne
onApplyLabel={applyLabel}
onApplyColor={applyColorDirectly}
onRemoveLabel={removeLabel}
getCurrentColor={getCurrentColor}
/>
)}
</div>
);
};

View File

@@ -1,36 +1,203 @@
import { Copy, Download, AlertTriangle } from "lucide-react";
import { ReactNode } from "react";
import { Copy, Download } from "lucide-react";
import { InteractiveTextEditor } from "./InteractiveTextEditor";
import { isValidEntityBoundary } from "@/app/utils/entityBoundary";
import { EntityMapping } from "@/app/config/entityLabels"; // Importer l'interface unifiée
interface EntityMapping {
originalValue: string;
anonymizedValue: string;
entityType: string;
startIndex: number;
endIndex: number;
}
// Supprimer l'interface locale et utiliser celle de entityLabels.ts
interface ResultPreviewComponentProps {
outputText: string;
sourceText: string;
copyToClipboard: () => void;
downloadText: () => void;
highlightEntities: (text: string, mappings?: EntityMapping[]) => ReactNode;
entityMappings?: EntityMapping[];
onMappingsUpdate?: (mappings: EntityMapping[]) => void;
}
export const ResultPreviewComponent = ({
outputText,
sourceText,
copyToClipboard,
downloadText,
highlightEntities,
entityMappings,
entityMappings = [],
onMappingsUpdate,
}: ResultPreviewComponentProps) => {
// SUPPRIMER cette ligne
// const { mappings, updateMapping, removeMappingByValueWithOptions } = useEntityMappings(entityMappings);
// Utiliser directement entityMappings du parent
const handleUpdateMapping = (
originalValue: string,
newLabel: string,
entityType: string,
applyToAllOccurrences: boolean = false,
customColor?: string,
wordStart?: number,
wordEnd?: number
) => {
// Créer les nouveaux mappings directement
const filteredMappings = entityMappings.filter(
(mapping) => mapping.text !== originalValue
);
const newMappings: EntityMapping[] = [];
if (applyToAllOccurrences) {
// Appliquer à toutes les occurrences
let searchIndex = 0;
while (true) {
const foundIndex = sourceText.indexOf(originalValue, searchIndex);
if (foundIndex === -1) break;
if (isValidEntityBoundary(foundIndex, sourceText, originalValue)) {
newMappings.push({
text: originalValue,
entity_type: entityType,
start: foundIndex,
end: foundIndex + originalValue.length,
displayName: newLabel,
customColor: customColor,
});
}
searchIndex = foundIndex + 1;
}
} else {
// CORRECTION: Utiliser wordStart/wordEnd pour cibler le mapping exact
if (wordStart !== undefined && wordEnd !== undefined) {
// Chercher le mapping exact avec les coordonnées précises
const targetMapping = entityMappings.find(
(mapping) => mapping.start === wordStart && mapping.end === wordEnd
);
if (targetMapping) {
// Mettre à jour le mapping existant spécifique
const updatedMappings = entityMappings.map((m) => {
if (m.start === wordStart && m.end === wordEnd) {
return {
...m,
entity_type: entityType,
displayName: newLabel,
customColor: customColor,
};
}
return m;
});
onMappingsUpdate?.(updatedMappings);
return;
} else {
// Créer un nouveau mapping aux coordonnées précises
newMappings.push({
text: originalValue,
entity_type: entityType,
start: wordStart,
end: wordEnd,
displayName: newLabel,
customColor: customColor,
});
}
} else {
// Fallback: logique existante si pas de coordonnées précises
const existingMapping = entityMappings.find(
(mapping) => mapping.text === originalValue
);
if (existingMapping) {
const updatedMappings = entityMappings.map((m) => {
if (
m.start === existingMapping.start &&
m.end === existingMapping.end
) {
return {
...m,
entity_type: entityType,
displayName: newLabel,
customColor: customColor,
};
}
return m;
});
onMappingsUpdate?.(updatedMappings);
return;
} else {
const foundIndex = sourceText.indexOf(originalValue);
if (
foundIndex !== -1 &&
isValidEntityBoundary(foundIndex, sourceText, originalValue)
) {
newMappings.push({
text: originalValue,
entity_type: entityType,
start: foundIndex,
end: foundIndex + originalValue.length,
displayName: newLabel,
customColor: customColor,
});
}
}
}
}
// Notifier le parent avec les nouveaux mappings
const allMappings = [...filteredMappings, ...newMappings];
const uniqueMappings = allMappings.filter(
(mapping, index, self) =>
index ===
self.findIndex(
(m) => m.start === mapping.start && m.end === mapping.end
)
);
onMappingsUpdate?.(uniqueMappings.sort((a, b) => a.start - b.start));
};
// NOUVELLE FONCTION: Gestion de la suppression avec applyToAll
const handleRemoveMapping = (
originalValue: string,
applyToAll: boolean = false
) => {
console.log("handleRemoveMapping appelé:", {
originalValue,
applyToAll,
});
// Notifier le parent avec les nouveaux mappings
if (onMappingsUpdate) {
const filteredMappings = entityMappings.filter(
(mapping: EntityMapping) => {
if (applyToAll) {
// Supprimer toutes les occurrences
return mapping.text !== originalValue;
} else {
// Supprimer seulement la première occurrence
const firstOccurrenceIndex = entityMappings.findIndex(
(m: EntityMapping) => m.text === originalValue
);
const currentIndex = entityMappings.indexOf(mapping);
return !(
mapping.text === originalValue &&
currentIndex === firstOccurrenceIndex
);
}
}
);
onMappingsUpdate(
filteredMappings.sort(
(a: EntityMapping, b: EntityMapping) => a.start - b.start
)
);
}
};
if (!outputText) return null;
return (
<div className="mt-8 space-y-4">
<div className="flex items-center justify-between border-b border-[#f7ab6e] border-opacity-30 pb-2">
<h3 className="text-lg font-medium text-[#092727]">
Document anonymisé
Document anonymisé (Mode interactif)
</h3>
<div className="flex items-center gap-2">
<button
@@ -56,21 +223,12 @@ export const ResultPreviewComponent = ({
<div className="border border-[#f7ab6e] border-opacity-30 rounded-lg bg-white min-h-[400px] flex flex-col">
<div className="flex-1 p-4 overflow-hidden">
<div className="h-full min-h-[300px] text-[#092727] whitespace-pre-wrap overflow-y-auto">
<div className="leading-relaxed">
{highlightEntities(outputText, entityMappings)}
</div>
</div>
</div>
<div className="p-4 border-t border-[#f7ab6e] border-opacity-30">
<div className="flex items-start gap-2 p-2 bg-[#f7ab6e] bg-opacity-10 rounded-md">
<AlertTriangle className="h-4 w-4 text-[#f7ab6e] mt-0.5 flex-shrink-0" />
<p className="text-sm text-[#092727]">
Vérifiez le résultat pour vous assurer que toutes les informations
privées sont supprimées et éviter une divulgation accidentelle.
</p>
</div>
<InteractiveTextEditor
text={sourceText}
entityMappings={entityMappings} // Utiliser entityMappings du parent au lieu de mappings
onUpdateMapping={handleUpdateMapping}
onRemoveMapping={handleRemoveMapping}
/>
</div>
</div>
</div>

View File

@@ -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. Lincident du 28 février 2024 a exposé les données personnelles stockées sur ladresse 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);
}
};

View File

@@ -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<number>;
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<TextDisplayProps> = ({
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 (
<span
key={index}
className={className}
style={{
backgroundColor: backgroundColor,
}}
onMouseEnter={() => 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}{" "}
</span>
);
};
return (
<div className="p-4 bg-white border border-gray-200 rounded-lg min-h-[300px] leading-relaxed text-sm">
<div className="whitespace-pre-wrap">
{words.map((word, index) => {
const nextWord = words[index + 1];
const spaceBetween = nextWord
? text.slice(word.end, nextWord.start)
: text.slice(word.end);
return (
<React.Fragment key={index}>
{renderWord(word, index)}
<span>{spaceBetween}</span>
</React.Fragment>
);
})}
</div>
</div>
);
};

View File

@@ -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<string, string> = {};
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,
};
};

View File

@@ -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<number>) => void;
}
export const useContextMenu = ({
entityMappings,
words, // Paramètre ajouté
onUpdateMapping,
onRemoveMapping,
getCurrentColor,
setSelectedWords,
}: UseContextMenuProps) => {
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
visible: false,
x: 0,
y: 0,
selectedText: "",
wordIndices: [],
});
const closeContextMenu = useCallback(() => {
setContextMenu((prev) => ({ ...prev, visible: false }));
}, []);
const showContextMenu = useCallback(
(menuData: Omit<ContextMenuState, "visible">) => {
setContextMenu({ ...menuData, visible: true });
},
[]
);
const getExistingLabels = useCallback(() => {
const uniqueLabels = new Set<string>();
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,
};
};

View File

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