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

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

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

View File

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

View File

@@ -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<string, string>;
default_anonymizers: Record<string, string>;
}
// 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<PresidioConfig | null> => {
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;
}
};

View File

@@ -0,0 +1,137 @@
import { useState, useCallback } from "react";
import { EntityMapping } from "@/app/config/entityLabels";
export const useEntityMappings = (initialMappings: EntityMapping[] = []) => {
const [mappings, setMappings] = useState<EntityMapping[]>(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
};
};

View File

@@ -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<File | null>(null);
const [fileContent, setFileContent] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoadingFile, setIsLoadingFile] = useState(false);
const [entityMappings, setEntityMappings] = useState<EntityMapping[]>([]);
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 (
<div className="min-h-screen w-full overflow-hidden">
<div className="min-h-screen w-full overflow-hidden">
{/* Main Content */}
<div className="max-w-6xl mx-auto px-2 sm:px-4 py-4 sm:py-8 space-y-4">
{/* 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}
/>
</div>
</div>
{/* Interactive Text Editor - Nouveau composant pour l'édition interactive */}
{outputText && (
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
<div className="p-1 sm:p-3">
<ResultPreviewComponent
outputText={outputText}
sourceText={sourceText}
copyToClipboard={copyToClipboard}
downloadText={downloadText}
entityMappings={entityMappings}
onMappingsUpdate={handleMappingsUpdate}
/>
</div>
</div>
)}
{/* Entity Mapping Table - Seulement si outputText existe */}
{outputText && (
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">

View File

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

View File

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

View File

@@ -1,199 +1,50 @@
import { ReactNode } from "react";
export const patterns = [
{
regex: /<PERSON>/g,
className: "bg-blue-200 text-blue-800",
label: "Personne",
},
{
regex: /<EMAIL_ADDRESS>/g,
className: "bg-green-200 text-green-800",
label: "Adresse Email",
},
{
regex: /<PHONE_NUMBER>/g,
className: "bg-purple-200 text-purple-800",
label: "N° de Téléphone",
},
{
regex: /<LOCATION>/g,
className: "bg-red-200 text-red-800",
label: "Lieu",
},
{
regex: /<IBAN>/g,
className: "bg-yellow-200 text-yellow-800",
label: "IBAN",
},
{
regex: /<ORGANIZATION>/g,
className: "bg-indigo-200 text-indigo-800",
label: "Organisation",
},
{
regex: /<FLEXIBLE_DATE>/g,
className: "bg-pink-200 text-pink-800",
label: "Date",
},
{
regex: /<BE_ADDRESS>/g,
className: "bg-cyan-200 text-cyan-800",
label: "Adresse (BE)",
},
{
regex: /<BE_PHONE_NUMBER>/g,
className: "bg-violet-200 text-violet-800",
label: "N° de Tél. (BE)",
},
{
regex: /<CREDIT_CARD>/g,
className: "bg-orange-200 text-orange-800",
label: "Carte de Crédit",
},
{
regex: /<URL>/g,
className: "bg-teal-200 text-teal-800",
label: "URL",
},
{
regex: /<IP_ADDRESS>/g,
className: "bg-gray-300 text-gray-900",
label: "Adresse IP",
},
{
regex: /<DATE_TIME>/g,
className: "bg-pink-300 text-pink-900",
label: "Date & Heure",
},
{
regex: /<NRP>/g,
className: "bg-red-300 text-red-900",
label: "N° Registre National",
},
{
regex: /<BE_VAT>/g,
className: "bg-yellow-300 text-yellow-900",
label: "TVA (BE)",
},
{
regex: /<BE_ENTERPRISE_NUMBER>/g,
className: "bg-lime-200 text-lime-800",
label: "N° d'entreprise (BE)",
},
{
regex: /<BE_PRO_ID>/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 = (
<span
key={`${patternIndex}-${start}`}
className={`${displayClass} px-2 py-1 rounded-md font-medium text-xs inline-block mx-0.5 shadow-sm border`}
title={`${displayLabel} anonymisé`}
>
{displayLabel}
</span>
);
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(
<span
key={index}
className={`${colorOption.bgClass} ${colorOption.textClass} px-2 py-1 rounded-md font-medium text-xs inline-block mx-0.5 shadow-sm border`}
title={`${entity_type}: ${text}`}
>
{displayText}
</span>
);
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;

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

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