Compare commits

..

34 Commits

Author SHA1 Message Date
Biqoz
050474e95b new interactive 2025-09-15 19:05:59 +02:00
nBiqoz
130929b756 ok 2025-09-12 16:54:40 +02:00
nBiqoz
d7d3a3c7e9 mac multi select 2025-09-12 13:28:39 +02:00
nBiqoz
0360e1ca9f logs good, replacement good 2025-09-07 18:01:14 +02:00
nBiqoz
3a84da5c74 select 2025-09-07 16:41:22 +02:00
nBiqoz
a0e033b7eb fr 2025-09-07 13:39:30 +02:00
nBiqoz
f3c2cb6ff5 fr 2025-09-07 13:36:00 +02:00
nBiqoz
88a94c46fe url 2025-09-07 12:47:06 +02:00
nBiqoz
ef0819ae90 interface interactive 2025-09-07 12:30:23 +02:00
nBiqoz
74e56c956c glisser 2025-08-09 15:23:20 +02:00
nBiqoz
e4c735cdc4 no header 2025-08-05 16:11:59 +02:00
nBiqoz
5c612bf07f finalyse 2025-08-04 00:55:00 +02:00
nBiqoz
ad92302461 finalyse 2025-08-04 00:14:55 +02:00
nBiqoz
b1de50cbc2 responsive 2025-08-01 20:26:47 +02:00
nBiqoz
1f26a3de5c select 2025-08-01 14:30:25 +02:00
nBiqoz
85288890cc side by side txte exemple 2025-07-29 10:41:21 +02:00
nBiqoz
bc07ea6077 presidio ok v.1 button disposition 2025-07-28 23:14:26 +02:00
nBiqoz
cb2c17ce2b presidio ok v.1 button 2025-07-28 22:52:46 +02:00
nBiqoz
a7b0b32582 presidio ok v.1 button 2025-07-28 22:40:53 +02:00
nBiqoz
499362fb3f presidio ok v.1 title green 2025-07-28 22:20:42 +02:00
nBiqoz
7a086c4749 ok error 2025-07-28 21:15:41 +02:00
nBiqoz
dc734e08f0 presidio ok v.1 2025-07-28 20:55:11 +02:00
nBiqoz
6d12017561 version nice 2025-07-26 21:46:14 +02:00
nBiqoz
5ba4fdc450 version nice 2025-07-26 21:39:49 +02:00
nBiqoz
aa19bb82a0 version pre 2025-07-26 00:12:57 +02:00
nBiqoz
52d02a967f 1st https 2025-07-25 19:09:56 +02:00
nBiqoz
df2e4ad1b4 push 1st https 2025-07-25 18:48:52 +02:00
nBiqoz
e61be83cb6 webhook depot 1st new gitea 2025-07-25 18:00:36 +02:00
nBiqoz
7803cc4598 webhook depot 1st new gitea 2025-07-25 17:59:35 +02:00
nBiqoz
1b483b63ff webhook depot 1st new gitea 2025-07-25 17:47:45 +02:00
nBiqoz
08afa534e1 webhook depot gitea 2025-07-25 17:39:37 +02:00
nBiqoz
082d47d951 presidio gitea 2025-07-25 17:15:13 +02:00
nBiqoz
67075d28a5 first gitea 2025-07-25 17:01:28 +02:00
nBiqoz
d6324f2ee7 first gitea 2025-07-25 16:52:24 +02:00
32 changed files with 3994 additions and 584 deletions

View File

@@ -1,6 +1,7 @@
import { NextResponse, type NextRequest } from "next/server";
import pdf from "pdf-parse/lib/pdf-parse";
import pdf from "pdf-parse"; // ✅ Import correct
import mammoth from "mammoth";
import { PresidioAnalyzerResult } from "@/app/config/entityLabels";
export async function POST(req: NextRequest) {
console.log("🔍 Début du traitement de la requête");
@@ -8,31 +9,97 @@ export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const file = formData.get("file") as File | null;
const category = (formData.get("category") as string) || "pii"; // Récupérer la catégorie
console.log("📊 Catégorie sélectionnée:", category);
// ✅ Validation améliorée du fichier
if (!file) {
return NextResponse.json(
{ error: "Aucun fichier reçu." },
{ status: 400 }
);
}
console.log("📁 Fichier reçu:", file.name, "| Type:", file.type);
// Vérifications supplémentaires
if (file.size === 0) {
return NextResponse.json(
{ error: "Le fichier est vide (0 bytes)." },
{ status: 400 }
);
}
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(),
});
let fileContent = "";
const fileType = file.type;
// --- LOGIQUE D'EXTRACTION DE TEXTE (INCHANGÉE) ---
// --- LOGIQUE D'EXTRACTION DE TEXTE ---
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;
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,
});
// 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
? `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 });
}
} catch (pdfError) {
console.error("❌ Erreur PDF détaillée:", {
message:
pdfError instanceof Error ? pdfError.message : "Erreur inconnue",
stack: pdfError instanceof Error ? pdfError.stack : undefined,
fileName: file.name,
fileSize: file.size,
fileType: file.type,
});
return NextResponse.json(
{
error: `Erreur traitement PDF: ${
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 }
);
@@ -45,12 +112,13 @@ export async function POST(req: NextRequest) {
try {
const arrayBuffer = await file.arrayBuffer();
const result = await mammoth.extractRawText({ arrayBuffer });
fileContent = result.value;
fileContent = result.value || "";
console.log(
"✅ Extraction Word réussie, longueur:",
fileContent.length
);
} catch (wordError) {
console.error("❌ Erreur Word:", wordError);
return NextResponse.json(
{
error: `Erreur traitement Word: ${
@@ -69,6 +137,7 @@ export async function POST(req: NextRequest) {
fileContent.length
);
} catch (textError) {
console.error("❌ Erreur texte:", textError);
return NextResponse.json(
{
error: `Erreur lecture texte: ${
@@ -88,23 +157,33 @@ export async function POST(req: NextRequest) {
);
}
// =========================================================================
// CONFIGURATION PRESIDIO ANALYZER (SIMPLIFIÉE)
// =========================================================================
// Vérifier si c'est juste pour l'extraction de texte (lecture simple)
const isSimpleExtraction =
req.headers.get("x-simple-extraction") === "true";
if (isSimpleExtraction) {
// Retourner juste le texte extrait
return NextResponse.json({ text: fileContent }, { status: 200 });
}
// ==========================================================
// CONFIGURATION PRESIDIO ANALYZER (pour l'anonymisation complète)
// ==========================================================
// Toute la configuration (recognizers, allow_list, etc.) est maintenant dans le default.yaml du service.
// L'API a juste besoin d'envoyer le texte et la langue.
const analyzerConfig = {
text: fileContent,
language: "fr",
// Plus de ad_hoc_recognizers ici !
mode: category, // Ajouter le mode basé sur la catégorie
};
console.log("🔍 Appel à Presidio Analyzer...");
// Mettez votre URL externe ici, ou utilisez le nom de service Docker si approprié
const presidioAnalyzerUrl =
"http://mk88kg840wk8www8sscok8oo.51.68.233.212.sslip.io/analyze";
console.log("📊 Configuration:", analyzerConfig);
// ✅ Définir l'URL AVANT de l'utiliser
const presidioAnalyzerUrl =
"http://analyzer.151.80.20.211.sslip.io/analyze";
// "http://localhost:5001/analyze";
try {
const analyzeResponse = await fetch(presidioAnalyzerUrl, {
method: "POST",
headers: {
@@ -115,12 +194,15 @@ export async function POST(req: NextRequest) {
});
console.log("📊 Statut Analyzer:", analyzeResponse.status);
console.log("📊 Headers Analyzer:", analyzeResponse.headers);
if (!analyzeResponse.ok) {
const errorBody = await analyzeResponse.text();
return NextResponse.json(
{ error: `Erreur Analyzer: ${errorBody}` },
{ status: 500 }
);
console.error("❌ Erreur Analyzer:", errorBody);
console.error("❌ URL utilisée:", presidioAnalyzerUrl);
console.error("❌ Config envoyée:", analyzerConfig);
// Fallback: retourner juste le texte si Presidio n'est pas disponible
return NextResponse.json({ text: fileContent }, { status: 200 });
}
const analyzerResults = await analyzeResponse.json();
@@ -130,17 +212,16 @@ export async function POST(req: NextRequest) {
// CONFIGURATION PRESIDIO ANONYMIZER
// =========================================================================
// L'Anonymizer lira la config d'anonymisation du default.yaml de l'Analyzer
// ou vous pouvez définir des transformations spécifiques ici si besoin.
// Pour commencer, on envoie juste les résultats de l'analyse.
const anonymizerConfig = {
text: fileContent,
analyzer_results: analyzerResults,
mode: category, // Ajouter le mode pour l'anonymizer aussi
};
console.log("🔍 Appel à Presidio Anonymizer...");
const presidioAnonymizerUrl =
"http://zgccw8o0cw4wo0wckk8kg4cg.51.68.233.212.sslip.io/anonymize";
"http://analyzer.151.80.20.211.sslip.io/anonymize";
// "http://localhost:5001/anonymize";
const anonymizeResponse = await fetch(presidioAnonymizerUrl, {
method: "POST",
@@ -154,21 +235,49 @@ export async function POST(req: NextRequest) {
console.log("📊 Statut Anonymizer:", anonymizeResponse.status);
if (!anonymizeResponse.ok) {
const errorBody = await anonymizeResponse.text();
return NextResponse.json(
{ error: `Erreur Anonymizer: ${errorBody}` },
{ status: 500 }
);
console.error("❌ Erreur Anonymizer:", errorBody);
// Fallback: retourner juste le texte si Presidio n'est pas disponible
return NextResponse.json({ text: fileContent }, { status: 200 });
}
const anonymizerResult = await anonymizeResponse.json();
console.log("✅ Anonymisation réussie.");
// 🎯 SOLUTION SIMPLIFIÉE : Utiliser directement le texte anonymisé de Presidio
console.log(
"✅ Texte anonymisé reçu de Presidio:",
anonymizerResult.anonymized_text
);
// Créer un mapping simple basé sur les entités détectées
const replacementValues: Record<string, string> = {};
analyzerResults.forEach((result: PresidioAnalyzerResult) => {
const originalValue = fileContent.substring(result.start, result.end);
const replacementValue = result.entity_type; // ✅ CORRECTION : Utiliser entity_type au lieu de [ENTITY_TYPE]
replacementValues[originalValue] = replacementValue;
console.log(
`📝 Mapping créé: "${originalValue}" -> "${replacementValue}"`
);
});
const result = {
anonymizedText: anonymizerResult.text,
text: fileContent, // Texte original pour référence
anonymizedText: anonymizerResult.anonymized_text, // Texte déjà anonymisé par Presidio
piiCount: analyzerResults.length,
analyzerResults: analyzerResults,
replacementValues: replacementValues,
// 🎯 NOUVEAU : Indiquer qu'on utilise directement le texte de Presidio
usePresidioText: true,
};
return NextResponse.json(result, { status: 200 });
} catch (presidioError) {
console.error("❌ Erreur Presidio:", presidioError);
// Fallback: retourner juste le texte extrait
return NextResponse.json({ text: fileContent }, { status: 200 });
}
} catch (err: unknown) {
console.error("❌ Erreur générale:", err);
return NextResponse.json(

View File

@@ -0,0 +1,300 @@
import { CheckCircle, Info } from "lucide-react";
interface AnonymizationInterfaceProps {
isProcessing: boolean;
outputText?: string;
sourceText?: string;
}
export const AnonymizationInterface = ({
isProcessing,
outputText,
sourceText,
}: AnonymizationInterfaceProps) => {
// Fonction pour détecter quels types de données ont été anonymisés
const getAnonymizedDataTypes = () => {
if (!outputText || !sourceText) return new Set();
const anonymizedTypes = new Set<string>();
// PII - Données personnelles
if (outputText.includes("[PERSONNE]")) {
anonymizedTypes.add("Noms et prénoms");
}
if (outputText.includes("[DATE]")) {
anonymizedTypes.add("Dates");
}
if (outputText.includes("[ADRESSE_EMAIL]")) {
anonymizedTypes.add("Adresses e-mail");
}
if (
outputText.includes("[TELEPHONE_FRANCAIS]") ||
outputText.includes("[TELEPHONE_BELGE]") ||
outputText.includes("[TELEPHONE]")
) {
anonymizedTypes.add("Numéros de téléphone");
}
if (
outputText.includes("[ADRESSE_FRANCAISE]") ||
outputText.includes("[ADRESSE_BELGE]") ||
outputText.includes("[ADRESSE]")
) {
anonymizedTypes.add("Adresses postales");
}
if (outputText.includes("[LOCATION]")) {
anonymizedTypes.add("Lieux géographiques");
}
if (
outputText.includes("[CARTE_IDENTITE_FRANCAISE]") ||
outputText.includes("[CARTE_IDENTITE_BELGE]") ||
outputText.includes("[PASSEPORT_FRANCAIS]") ||
outputText.includes("[PASSEPORT_BELGE]") ||
outputText.includes("[PERMIS_CONDUIRE_FRANCAIS]")
) {
anonymizedTypes.add("Documents d'identité");
}
if (outputText.includes("[NUMERO_SECURITE_SOCIALE_FRANCAIS]")) {
anonymizedTypes.add("Numéros de sécurité sociale");
}
if (outputText.includes("[BIOMETRIC_DATA]")) {
anonymizedTypes.add("Données biométriques");
}
if (outputText.includes("[HEALTH_DATA]")) {
anonymizedTypes.add("Données de santé");
}
if (
outputText.includes("[SEXUAL_ORIENTATION]") ||
outputText.includes("[POLITICAL_OPINIONS]")
) {
anonymizedTypes.add("Données sensibles RGPD");
}
// Données financières
if (
outputText.includes("[IBAN]") ||
outputText.includes("[COMPTE_BANCAIRE_FRANCAIS]")
) {
anonymizedTypes.add("Comptes bancaires");
}
if (outputText.includes("[CREDIT_CARD]")) {
anonymizedTypes.add("Cartes de crédit");
}
if (outputText.includes("[MONTANT_FINANCIER]")) {
anonymizedTypes.add("Montants financiers");
}
if (outputText.includes("[NUMERO_FISCAL_FRANCAIS]")) {
anonymizedTypes.add("Numéros fiscaux");
}
if (outputText.includes("[RGPD_FINANCIAL_DATA]")) {
anonymizedTypes.add("Données financières RGPD");
}
// Business - Données d'entreprise
if (outputText.includes("[ORGANISATION]")) {
anonymizedTypes.add("Noms d'organisations");
}
if (
outputText.includes("[SIRET_SIREN_FRANCAIS]") ||
outputText.includes("[SOCIETE_FRANCAISE]") ||
outputText.includes("[SOCIETE_BELGE]")
) {
anonymizedTypes.add("Entreprises et sociétés");
}
if (
outputText.includes("[TVA_FRANCAISE]") ||
outputText.includes("[TVA_BELGE]")
) {
anonymizedTypes.add("Numéros de TVA");
}
if (
outputText.includes("[NUMERO_ENTREPRISE_BELGE]") ||
outputText.includes("[REGISTRE_NATIONAL_BELGE]")
) {
anonymizedTypes.add("Identifiants d'entreprise");
}
if (outputText.includes("[SECRET_COMMERCIAL]")) {
anonymizedTypes.add("Secrets commerciaux");
}
if (outputText.includes("[REFERENCE_CONTRAT]")) {
anonymizedTypes.add("Références de contrats");
}
if (outputText.includes("[MARKET_SHARE]")) {
anonymizedTypes.add("Parts de marché");
}
if (
outputText.includes("[ID_PROFESSIONNEL_BELGE]") ||
outputText.includes("[DONNEES_PROFESSIONNELLES]")
) {
anonymizedTypes.add("Identifiants professionnels");
}
// Données techniques
if (outputText.includes("[ADRESSE_IP]")) {
anonymizedTypes.add("Adresses IP");
}
if (outputText.includes("[URL_IDENTIFIANT]")) {
anonymizedTypes.add("URLs et identifiants web");
}
if (outputText.includes("[CLE_API_SECRETE]")) {
anonymizedTypes.add("Clés API secrètes");
}
if (outputText.includes("[IDENTIFIANT_PERSONNEL]")) {
anonymizedTypes.add("Identifiants personnels");
}
if (outputText.includes("[LOCALISATION_GPS]")) {
anonymizedTypes.add("Coordonnées GPS");
}
if (outputText.includes("[TITRE_CIVILITE]")) {
anonymizedTypes.add("Titres de civilité");
}
return anonymizedTypes;
};
// Structure mise à jour avec les vrais types de données
const supportedDataStructure = [
{
items: [
"Noms et prénoms",
"Numéros de téléphone",
"URLs et identifiants web",
],
},
{
items: ["Adresses postales", "Lieux géographiques", "Dates"],
},
{
items: ["Documents d'identité", "Comptes bancaires", "Cartes de crédit"],
},
{
items: ["Adresses e-mail", "Montants financiers", "Adresses IP"],
},
{
items: [
"Noms d'organisations",
"Entreprises et sociétés",
"Numéros de TVA",
],
},
{
items: [
"Parts de marché",
"Secrets commerciaux",
"Références de contrats",
],
},
{
items: [
"Données biométriques",
"Données de santé",
"Données sensibles RGPD",
],
},
{
items: ["Clés API secrètes", "Coordonnées GPS", "Titres de civilité"],
},
];
if (isProcessing) {
return (
<div className="bg-gray-50 border border-gray-200 rounded-xl p-6">
<div className="flex items-center justify-center space-x-3 mb-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-500"></div>
<h4 className="text-sm font-semibold text-gray-700">
Anonymisation en cours...
</h4>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full animate-pulse"></div>
<span className="text-xs text-gray-600">Analyse du contenu</span>
</div>
<div className="flex items-center space-x-2">
<div
className="w-2 h-2 bg-gray-500 rounded-full animate-pulse"
style={{ animationDelay: "0.5s" }}
></div>
<span className="text-xs text-gray-600">
Détection des données sensibles
</span>
</div>
<div className="flex items-center space-x-2">
<div
className="w-2 h-2 bg-gray-500 rounded-full animate-pulse"
style={{ animationDelay: "1s" }}
></div>
<span className="text-xs text-gray-600">
Application de l&apos;anonymisation
</span>
</div>
</div>
</div>
);
}
if (outputText) {
const anonymizedTypes = getAnonymizedDataTypes();
return (
<div className="space-y-4">
{/* Instructions Panel */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<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, Ctrl/CMD (ou Shift) +
clic.
</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>
{/* Bloc vert existant */}
<div className="bg-green-50 border border-green-200 rounded-xl p-6">
<div className="flex items-center space-x-3 mb-4">
<CheckCircle className="h-5 w-5 text-green-600" />
<h4 className="text-sm font-semibold text-green-700">
Anonymisation terminée avec succès
</h4>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-xs">
{supportedDataStructure.map((column, columnIndex) => (
<div key={columnIndex} className="flex flex-col space-y-2">
{column.items.map((item, itemIndex) => {
const isAnonymized = anonymizedTypes.has(item);
return (
<span
key={itemIndex}
className={
isAnonymized
? "text-green-700 font-medium"
: "text-gray-400"
}
>
{isAnonymized ? "✓" : "•"} {item}
</span>
);
})}
</div>
))}
</div>
</div>
</div>
);
}
return null;
};

View File

@@ -0,0 +1,154 @@
import { useState } from "react";
import {
PresidioAnalyzerResult,
EntityMapping,
} from "@/app/config/entityLabels";
// Interface pour la réponse de l'API process-document
interface ProcessDocumentResponse {
text?: string;
anonymizedText?: string;
analyzerResults?: PresidioAnalyzerResult[];
replacementValues?: Record<string, string>; // Nouvelle propriété
error?: string;
}
// Props du hook - Renommer pour correspondre à l'utilisation
interface UseAnonymizationProps {
setOutputText: (text: string) => void;
setError: (error: string | null) => void;
setEntityMappings: (mappings: EntityMapping[]) => void;
setAnonymizedText?: (text: string) => void; // Nouveau paramètre optionnel
}
// NOUVEAU: Définir les types pour le paramètre de anonymizeData
interface AnonymizeDataParams {
file?: File | null;
text?: string;
category?: string; // Ajouter le paramètre catégorie
}
/**
* 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 = ({
setOutputText,
setError,
setEntityMappings,
setAnonymizedText,
}: UseAnonymizationProps) => {
const [isProcessing, setIsProcessing] = useState(false);
const anonymizeData = async ({ file, text, category = 'pii' }: AnonymizeDataParams) => {
setIsProcessing(true);
setError(null);
setEntityMappings([]);
setOutputText("");
try {
// ÉTAPE 1: Construire le FormData ici pour garantir le bon format
const formData = new FormData();
// Ajouter la catégorie au FormData
formData.append('category', category);
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 {
throw new Error("Aucune donnée à anonymiser (ni fichier, ni texte).");
}
const response = await fetch("/api/process-document", {
method: "POST",
body: formData, // Le Content-Type sera automatiquement défini par le navigateur
});
const data: ProcessDocumentResponse = await response.json();
if (!response.ok || data.error) {
throw new Error(
data.error || "Erreur lors de la communication avec l'API."
);
}
const originalText = data.text || "";
const presidioResults = data.analyzerResults || [];
const replacementValues = data.replacementValues || {}; // Récupérer les valeurs de remplacement
// 🔍 AJOUT DES CONSOLE.LOG POUR DÉBOGUER
console.log("📊 Réponse de l'API:", {
originalTextLength: originalText.length,
presidioResultsCount: presidioResults.length,
presidioResults: presidioResults,
replacementValues: replacementValues,
replacementValuesKeys: Object.keys(replacementValues),
replacementValuesEntries: Object.entries(replacementValues),
});
// ÉTAPE 2 : Utiliser le texte ANONYMISÉ de Presidio au lieu du texte original
setOutputText(data.anonymizedText || originalText);
// NOUVEAU : Stocker le texte anonymisé de Presidio séparément
if (setAnonymizedText && data.anonymizedText) {
setAnonymizedText(data.anonymizedText);
}
// É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}]`,
});
mappings.push({
entity_type: entity_type,
start: start,
end: end,
text: detectedText,
replacementValue: replacementValues[detectedText],
displayName: replacementValues[detectedText]
? replacementValues[detectedText].replace(/[\[\]]/g, "")
: entity_type,
customColor: undefined,
});
}
// 🔍 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
: "Une erreur inconnue est survenue."
);
} finally {
setIsProcessing(false);
}
};
return {
anonymizeData,
isProcessing,
};
};

View File

@@ -0,0 +1,387 @@
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";
interface ContextMenuProps {
contextMenu: {
visible: boolean;
x: number;
y: number;
selectedText: string;
wordIndices: number[];
};
existingLabels: string[];
entityMappings?: EntityMapping[];
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,
onApplyLabel,
onApplyColor,
onRemoveLabel,
getCurrentColor,
}) => {
const [showNewLabelInput, setShowNewLabelInput] = useState(false);
const [newLabelValue, setNewLabelValue] = useState("");
const [showColorPalette, setShowColorPalette] = useState(false);
const [tempSelectedColor, setTempSelectedColor] = useState('');
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 (newLabelValue.trim()) {
console.log(
"Application du label personnalisé:",
newLabelValue.trim(),
"À toutes les occurrences:",
applyToAll
);
// Appliquer d'abord le label
onApplyLabel(newLabelValue.trim(), applyToAll);
// Puis appliquer la couleur temporaire si elle existe
if (tempSelectedColor) {
setTimeout(() => {
onApplyColor(tempSelectedColor, 'Couleur personnalisée', applyToAll);
}, 100);
}
setNewLabelValue("");
setShowNewLabelInput(false);
setTempSelectedColor(''); // Reset de la couleur temporaire
}
};
// 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);
setNewLabelValue("");
};
// 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 - toujours visible */}
<div className="flex-shrink-0">
<select
onChange={(e) => {
e.stopPropagation();
if (e.target.value) {
const selectedDisplayName = e.target.value;
console.log("📋 Label sélectionné:", selectedDisplayName);
// Appliquer d'abord le label
onApplyLabel(selectedDisplayName, applyToAll);
// Puis appliquer la couleur temporaire si elle existe
if (tempSelectedColor) {
setTimeout(() => {
onApplyColor(tempSelectedColor, 'Couleur personnalisée', applyToAll);
}, 100);
}
// Reset du select et de la couleur temporaire
e.target.value = "";
setTempSelectedColor('');
}
}}
onClick={(e) => e.stopPropagation()}
className="text-xs border border-blue-300 rounded px-2 py-1 bg-blue-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-[120px] cursor-pointer hover:bg-blue-100 transition-colors"
defaultValue=""
>
<option value="" disabled className="text-gray-500">
{existingLabels.length > 0
? `📋 Labels (${existingLabels.length})`
: "📋 Aucun label"}
</option>
{existingLabels.map((label) => (
<option key={label} value={label} className="text-gray-800">
{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={newLabelValue}
onChange={(e) => {
e.stopPropagation();
setNewLabelValue(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={!newLabelValue.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: tempSelectedColor || 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) => {
return (
<button
key={color.value}
type="button"
onClick={(e) => {
e.stopPropagation();
// Vérifier si le texte a déjà un mapping (modification)
const existingMapping = entityMappings?.find(mapping =>
mapping.text === contextMenu.selectedText
);
if (existingMapping) {
// MODIFICATION : Appliquer directement la couleur
onApplyColor(color.value, color.name, applyToAll);
} else {
// CRÉATION : Juste stocker la couleur temporaire
setTempSelectedColor(color.value);
}
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

@@ -0,0 +1,48 @@
import React from "react";
import { FileText } from "lucide-react";
interface DocumentPreviewProps {
uploadedFile: File | null;
fileContent: string;
sourceText: string;
}
export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
uploadedFile,
fileContent,
sourceText,
}) => {
if (!uploadedFile && (!sourceText || !sourceText.trim())) {
return null;
}
return (
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
<div className="bg-orange-50 border-b border-orange-200 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<FileText className="h-5 w-5 text-orange-600" />
</div>
<div>
{uploadedFile && (
<p className="text-sm text-orange-600">
{uploadedFile.name} {(uploadedFile.size / 1024).toFixed(1)}{" "}
KB
</p>
)}
</div>
</div>
</div>
</div>
<div className="p-6">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 max-h-64 overflow-y-auto">
<pre className="text-sm text-gray-700 whitespace-pre-wrap font-mono">
{sourceText || fileContent || "Aucun contenu à afficher"}
</pre>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,34 @@
import { EntityMapping } from "@/app/config/entityLabels";
interface DownloadActionsProps {
outputText: string;
entityMappings?: EntityMapping[];
anonymizedText?: string; // Nouveau paramètre pour le texte déjà anonymisé par Presidio
}
export const useDownloadActions = ({
outputText,
anonymizedText, // Texte déjà anonymisé par Presidio
}: DownloadActionsProps) => {
const copyToClipboard = () => {
// Toujours utiliser le texte anonymisé de Presidio
const textToCopy = anonymizedText || outputText;
navigator.clipboard.writeText(textToCopy);
};
const downloadText = () => {
// Utiliser le texte anonymisé de Presidio si disponible, sinon fallback sur outputText
const textToDownload = anonymizedText || outputText;
const blob = new Blob([textToDownload], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "texte-anonymise.txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return { copyToClipboard, downloadText };
};

View File

@@ -0,0 +1,135 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EntityMapping } from "../config/entityLabels";
interface EntityMappingTableProps {
mappings: EntityMapping[];
}
export const EntityMappingTable = ({ mappings }: EntityMappingTableProps) => {
if (!mappings || mappings.length === 0) {
return (
<Card className="mt-8">
<CardHeader>
<CardTitle className="text-lg font-medium text-[#092727]">
Entités détectées
</CardTitle>
</CardHeader>
<CardContent>
<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.entity_type,
};
});
return (
<Card className="mt-8">
<CardHeader>
<CardTitle className="text-lg font-medium text-[#092727]">
Entités détectées ({mappings.length})
</CardTitle>
</CardHeader>
<CardContent>
{/* Version mobile : Cards empilées */}
<div className="sm:hidden space-y-4">
{mappingsWithNumbers.map((mapping, index) => (
<div
key={index}
className="border rounded-lg p-4 bg-white shadow-sm"
>
<div className="space-y-3">
<div>
<Badge
variant="outline"
className="bg-[#f7ab6e] bg-opacity-20 text-[#092727] border-[#f7ab6e]"
>
{mapping.displayName}
</Badge>
</div>
<div className="space-y-2">
<div>
<span className="text-xs font-medium text-gray-600 block mb-1">
Texte détecté
</span>
<div className="font-mono text-xs bg-red-50 text-red-700 p-2 rounded border break-all">
{mapping.text}
</div>
</div>
<div>
<span className="text-xs font-medium text-gray-600 block mb-1">
Identifiant
</span>
<div className="font-mono text-xs bg-green-50 text-green-700 p-2 rounded border break-all">
{mapping.displayName} #{mapping.entityNumber}
</div>
</div>
</div>
</div>
</div>
))}
</div>
{/* Version desktop : Table classique */}
<div className="hidden sm:block overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold text-[#092727] min-w-[120px]">
Type d&apos;entité
</TableHead>
<TableHead className="font-semibold text-[#092727] min-w-[150px]">
Texte détecté
</TableHead>
<TableHead className="font-semibold text-[#092727] min-w-[100px]">
Identifiant
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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.displayName}
</Badge>
</TableCell>
<TableCell className="font-mono text-sm bg-red-50 text-red-700 py-4 max-w-[200px] break-all">
{mapping.text}
</TableCell>
<TableCell className="font-mono text-sm bg-green-50 text-green-700 py-4">
{mapping.displayName} #{mapping.entityNumber}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,110 @@
interface FileHandlerProps {
setUploadedFile: (file: File | null) => void;
setSourceText: (text: string) => void;
setError: (error: string | null) => void;
setIsLoadingFile: (loading: boolean) => void;
}
export const useFileHandler = ({
setUploadedFile,
setSourceText,
setError,
setIsLoadingFile,
}: FileHandlerProps) => {
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0];
if (!file) return;
setUploadedFile(file);
setError(null);
setSourceText("");
if (file.type === "text/plain") {
try {
const text = await file.text();
setSourceText(text);
} catch {
setError("Erreur lors de la lecture du fichier texte");
setUploadedFile(null);
}
} else if (file.type === "application/pdf") {
// Activer le loader immédiatement pour les PDF
setIsLoadingFile?.(true);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/process-document", {
method: "POST",
body: formData,
});
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) {
errorMessage = errorData.error;
console.log("✅ Message détaillé récupéré:", errorMessage);
}
} 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"
}`;
}
} catch (readError) {
console.error("❌ Impossible de lire la réponse:", readError);
}
throw new Error(errorMessage);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
const extractedText = data.text || data.anonymizedText || "";
if (!extractedText || extractedText.trim().length === 0) {
throw new Error(
"Le fichier PDF ne contient pas de texte extractible"
);
}
setSourceText(extractedText);
} catch (error) {
console.error("Erreur PDF:", error);
setError(
error instanceof Error
? error.message
: "Erreur lors de la lecture du fichier PDF"
);
setUploadedFile(null);
setSourceText("");
} finally {
// Désactiver le loader une fois terminé
setIsLoadingFile?.(false);
}
} else {
setError(
"Type de fichier non supporté. Veuillez utiliser un fichier TXT ou PDF."
);
setUploadedFile(null);
}
};
return { handleFileChange };
};

View File

@@ -0,0 +1,778 @@
import {
Upload,
FileText,
AlertTriangle,
Shield,
Copy,
Download,
} from "lucide-react";
import { SampleTextComponent } from "./SampleTextComponent";
import { SupportedDataTypes } from "./SupportedDataTypes";
import { AnonymizationInterface } from "./AnonymizationInterface";
import { InteractiveTextEditor } from "./InteractiveTextEditor";
import React, { useState } from "react";
import { EntityMapping } from "../config/entityLabels"; // Importer l'interface unifiée
// Supprimer l'interface locale EntityMapping (lignes 15-21)
interface FileUploadComponentProps {
uploadedFile: File | null;
handleFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
sourceText: string;
setSourceText: (text: string) => void;
setUploadedFile: (file: File | null) => void;
onAnonymize?: (category?: string) => void;
isProcessing?: boolean;
canAnonymize?: boolean;
isLoadingFile?: boolean;
onRestart?: () => void;
outputText?: string;
copyToClipboard?: () => void;
downloadText?: () => void;
isExampleLoaded?: boolean;
setIsExampleLoaded?: (loaded: boolean) => void;
entityMappings?: EntityMapping[];
onMappingsUpdate?: (mappings: EntityMapping[]) => void;
}
export const FileUploadComponent = ({
uploadedFile,
handleFileChange,
sourceText,
setSourceText,
setUploadedFile,
onAnonymize,
isProcessing = false,
canAnonymize = false,
isLoadingFile = false,
onRestart,
outputText,
copyToClipboard,
downloadText,
setIsExampleLoaded,
entityMappings,
onMappingsUpdate,
}: FileUploadComponentProps) => {
const [isDragOver, setIsDragOver] = useState(false);
const [selectedCategory, setSelectedCategory] = useState("pii");
// Fonction pour valider le type de fichier
const isValidFileType = (file: File) => {
const allowedTypes = ["text/plain", "application/pdf"];
const allowedExtensions = [".txt", ".pdf"];
return (
allowedTypes.includes(file.type) ||
allowedExtensions.some((ext) => file.name.toLowerCase().endsWith(ext))
);
};
// Gestionnaires de glisser-déposer
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Vérifier si on quitte vraiment la zone de drop
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
const file = files[0];
// Vérifier le type de fichier
if (!isValidFileType(file)) {
alert(
"Type de fichier non supporté. Veuillez sélectionner un fichier PDF ou TXT."
);
return;
}
// Vérifier la taille (5MB max)
if (file.size > 5 * 1024 * 1024) {
alert("Le fichier est trop volumineux. Taille maximale : 5MB.");
return;
}
const syntheticEvent = {
target: { files: [file] },
} as unknown as React.ChangeEvent<HTMLInputElement>;
handleFileChange(syntheticEvent);
}
};
// Gestionnaire de changement de fichier modifié pour valider le type
const handleFileChangeWithValidation = (
e: React.ChangeEvent<HTMLInputElement>
) => {
const file = e.target.files?.[0];
if (file && !isValidFileType(file)) {
alert(
"Type de fichier non supporté. Veuillez sélectionner un fichier PDF ou TXT."
);
e.target.value = ""; // Reset l'input
return;
}
handleFileChange(e);
};
// On passe en preview seulement si :
// 1. Un fichier est uploadé OU
// 2. On a un résultat d'anonymisation
// (On retire isExampleLoaded pour permettre l'édition du texte d'exemple)
if (uploadedFile || outputText) {
return (
<div className="w-full flex flex-col space-y-6">
{/* Si on a un résultat, afficher 2 blocs côte à côte */}
{outputText ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Preview du document original */}
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
<div className="bg-orange-50 border-b border-orange-200 px-4 sm:px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<FileText className="h-4 w-4 sm:h-5 sm:w-5 text-orange-600" />
</div>
<div className="min-w-0 flex-1">
{uploadedFile ? (
<p className="text-xs sm:text-sm text-orange-600 truncate">
{uploadedFile.name} {" "}
{(uploadedFile.size / 1024).toFixed(1)} KB
</p>
) : (
<p className="text-xs sm:text-sm text-orange-600">
Demo - Exemple de texte
</p>
)}
</div>
</div>
</div>
</div>
<div className="p-1">
<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">
<pre className="text-xs sm:text-sm text-gray-700 whitespace-pre-wrap font-mono break-words overflow-wrap-anywhere">
{sourceText || "Aucun contenu à afficher"}
</pre>
</div>
</div>
</div>
{/* Bloc résultat anonymisé - MODE INTERACTIF */}
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
<div className="bg-green-50 border-b border-green-200 px-4 sm:px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-green-100 rounded-lg flex items-center justify-center">
<Shield className="h-4 w-4 sm:h-5 sm:w-5 text-green-600" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs sm:text-sm text-green-600">
DOCUMENT ANONYMISÉ MODE INTERACTIF
</p>
</div>
</div>
{/* Boutons d'action */}
<div className="flex items-center gap-2">
{copyToClipboard && (
<button
onClick={copyToClipboard}
className="p-2 text-green-600 hover:text-green-700 hover:bg-green-100 rounded-lg transition-colors duration-200"
title="Copier le texte"
>
<Copy className="h-4 w-4" />
</button>
)}
{downloadText && (
<button
onClick={downloadText}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-2 rounded-lg text-xs font-medium transition-colors duration-200 flex items-center space-x-1"
title="Télécharger le fichier"
>
<Download className="h-3 w-3" />
<span className="hidden sm:inline">Télécharger</span>
</button>
)}
</div>
</div>
</div>
<div className="p-1">
<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">
<InteractiveTextEditor
text={sourceText}
entityMappings={entityMappings || []}
onUpdateMapping={(
originalValue,
newLabel,
entityType,
applyToAllOccurrences,
customColor,
wordStart,
wordEnd
) => {
if (onMappingsUpdate && entityMappings) {
console.log("🔄 Mise à jour mapping:", {
originalValue,
newLabel,
entityType,
applyToAllOccurrences,
customColor,
wordStart,
wordEnd,
});
let updatedMappings: EntityMapping[];
if (applyToAllOccurrences) {
// CORRECTION: Créer des mappings pour toutes les occurrences dans le texte
const existingMappingsForOtherTexts =
entityMappings.filter(
(mapping) => mapping.text !== originalValue
);
const newMappings: EntityMapping[] = [];
let searchIndex = 0;
// Chercher toutes les occurrences dans le texte source
while (true) {
const foundIndex = sourceText.indexOf(
originalValue,
searchIndex
);
if (foundIndex === -1) break;
// Vérifier que c'est une occurrence valide (limites de mots)
const isValidBoundary =
(foundIndex === 0 ||
!/\w/.test(sourceText[foundIndex - 1])) &&
(foundIndex + originalValue.length ===
sourceText.length ||
!/\w/.test(
sourceText[foundIndex + originalValue.length]
));
if (isValidBoundary) {
newMappings.push({
text: originalValue,
entity_type: entityType,
start: foundIndex,
end: foundIndex + originalValue.length,
displayName: newLabel,
customColor: customColor,
});
}
searchIndex = foundIndex + 1;
}
updatedMappings = [
...existingMappingsForOtherTexts,
...newMappings,
];
} else {
// Logique existante pour une seule occurrence
if (
wordStart !== undefined &&
wordEnd !== undefined
) {
const targetMapping = entityMappings.find(
(mapping) =>
mapping.start === wordStart &&
mapping.end === wordEnd
);
if (targetMapping) {
updatedMappings = entityMappings.map(
(mapping) => {
if (
mapping.start === wordStart &&
mapping.end === wordEnd
) {
return {
...mapping,
displayName: newLabel,
entity_type: entityType,
customColor: customColor,
};
}
return mapping;
}
);
} else {
const newMapping: EntityMapping = {
text: originalValue,
entity_type: entityType,
start: wordStart,
end: wordEnd,
displayName: newLabel,
customColor: customColor,
};
updatedMappings = [...entityMappings, newMapping];
}
} else {
// Fallback: logique existante
const existingMappingIndex =
entityMappings.findIndex(
(mapping) => mapping.text === originalValue
);
if (existingMappingIndex !== -1) {
updatedMappings = entityMappings.map(
(mapping, index) => {
if (index === existingMappingIndex) {
return {
...mapping,
displayName: newLabel,
entity_type: entityType,
customColor: customColor,
};
}
return mapping;
}
);
} else {
const foundIndex =
sourceText.indexOf(originalValue);
if (foundIndex !== -1) {
const newMapping: EntityMapping = {
text: originalValue,
entity_type: entityType,
start: foundIndex,
end: foundIndex + originalValue.length,
displayName: newLabel,
customColor: customColor,
};
updatedMappings = [
...entityMappings,
newMapping,
];
} else {
updatedMappings = entityMappings;
}
}
}
}
console.log(
"✅ Mappings mis à jour:",
updatedMappings.length
);
onMappingsUpdate(
updatedMappings.sort((a, b) => a.start - b.start)
);
}
}}
onRemoveMapping={(originalValue, applyToAll) => {
if (onMappingsUpdate && entityMappings) {
console.log("🗑️ Suppression mapping:", {
originalValue,
applyToAll,
});
let filteredMappings: EntityMapping[];
if (applyToAll) {
// Supprimer toutes les occurrences
filteredMappings = entityMappings.filter(
(mapping) => mapping.text !== originalValue
);
} else {
// Supprimer seulement la première occurrence
const firstIndex = entityMappings.findIndex(
(mapping) => mapping.text === originalValue
);
if (firstIndex !== -1) {
filteredMappings = entityMappings.filter(
(_, index) => index !== firstIndex
);
} else {
filteredMappings = entityMappings;
}
}
console.log(
"✅ Mappings après suppression:",
filteredMappings.length
);
onMappingsUpdate(
filteredMappings.sort((a, b) => a.start - b.start)
);
}
}}
/>
</div>
</div>
</div>
</div>
) : (
/* Preview normal quand pas de résultat */
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
<div className="bg-orange-50 border-b border-orange-200 px-4 sm:px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<FileText className="h-4 w-4 sm:h-5 sm:w-5 text-orange-600" />
</div>
<div className="min-w-0 flex-1">
{uploadedFile ? (
<p className="text-xs sm:text-sm text-orange-600 truncate">
{uploadedFile.name} {" "}
{(uploadedFile.size / 1024).toFixed(1)} KB
</p>
) : (
<p className="text-xs sm:text-sm text-orange-600">
Demo - Exemple de texte
</p>
)}
</div>
</div>
</div>
</div>
<div className="p-2 ">
{/* Zone de texte avec limite de hauteur et scroll */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 sm:p-4 max-h-48 overflow-y-auto overflow-x-hidden">
{isLoadingFile ? (
<div className="flex items-center justify-center py-8">
<div className="flex items-center space-x-3">
<div className="animate-spin rounded-full h-5 w-5 sm:h-6 sm:w-6 border-b-2 border-[#f7ab6e]"></div>
<span className="text-xs sm:text-sm text-gray-600">
Chargement du fichier en cours...
</span>
</div>
</div>
) : (
<pre className="text-xs sm:text-sm text-gray-700 whitespace-pre-wrap font-mono break-words overflow-wrap-anywhere">
{sourceText || "Aucun contenu à afficher"}
</pre>
)}
</div>
{/* Disclaimer déplacé en dessous du texte */}
<div className="mt-4">
<div className="flex items-start gap-2 p-3 bg-[#f7ab6e] bg-opacity-10 border border-[#f7ab6e] border-opacity-30 rounded-lg">
<AlertTriangle className="h-4 w-4 text-[#f7ab6e] mt-0.5 flex-shrink-0" />
<p className="text-[10px] sm:text-[11px] text-[#092727] leading-relaxed">
Cet outil IA peut ne pas détecter toutes les informations
sensibles. Vérifiez le résultat avant de le partager.
</p>
</div>
</div>
</div>
</div>
)}
{/* 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">
{/* Sélecteur de catégorie - NOUVEAU */}
{onAnonymize && !outputText && (
<div className="flex flex-col space-y-2">
<label className="text-xs font-medium text-gray-700 text-center">
Catégorie d&apos;anonymisation
</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#f7ab6e] focus:border-[#f7ab6e] bg-white"
>
<option value="pii">🔒 PII (Données Personnelles)</option>
<option value="business">🏢 Business (Données Métier)</option>
<option value="pii_business">
🔒🏢 PII + Business (Tout)
</option>
</select>
</div>
)}
{/* Bouton Anonymiser - seulement si pas encore anonymisé */}
{onAnonymize && !outputText && (
<button
onClick={() => onAnonymize?.(selectedCategory)}
disabled={isProcessing || !sourceText.trim()}
className="w-full bg-[#f7ab6e] hover:bg-[#f7ab6e]/90 text-black px-4 py-2 rounded-lg text-xs font-medium transition-colors duration-300 flex items-center justify-center space-x-2 shadow-sm disabled:bg-gray-300 disabled:text-gray-800 disabled:font-bold disabled:cursor-not-allowed"
title={
sourceText.trim()
? "Anonymiser les données"
: "Saisissez du texte pour anonymiser"
}
>
{isProcessing ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Anonymisation en cours...</span>
</>
) : (
<>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
<span>Anonymiser mes données</span>
</>
)}
</button>
)}
{/* Bouton Recommencer - toujours visible */}
{onRestart && (
<button
onClick={onRestart}
className="w-full sm:w-auto bg-gray-500 hover:bg-gray-600 text-white px-6 py-3 rounded-lg text-sm font-medium transition-colors duration-300 flex items-center justify-center space-x-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>Recommencer</span>
</button>
)}
</div>
)}
{/* Affichage conditionnel : Interface d'anonymisation OU Types de données supportées */}
{isProcessing || outputText ? (
<AnonymizationInterface
isProcessing={isProcessing}
outputText={outputText}
sourceText={sourceText}
/>
) : (
<SupportedDataTypes />
)}
</div>
);
}
return (
<div className="w-full flex flex-col space-y-3">
{/* Deux colonnes côte à côte */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Colonne gauche - Zone de texte */}
<div className="border-2 border-dashed border-[#092727] rounded-xl bg-gray-50 hover:bg-gray-100 hover:border-[#0a3030] transition-all duration-300">
<div className="p-3 sm:p-4">
{/* Header avec icône */}
<div className="flex items-center justify-center mb-2">
<div className="w-8 h-8 bg-[#f7ab6e] rounded-full flex items-center justify-center">
<FileText className="h-4 w-4 text-white" />
</div>
</div>
{/* Titre */}
<h3 className="text-lg font-semibold text-[#092727] mb-1 text-center">
Saisissez votre texte
</h3>
<p className="text-md text-[#092727] opacity-80 mb-2 text-center">
Tapez ou collez votre texte ici
</p>
{/* Zone de texte éditable */}
<div className="relative border-2 border-gray-200 rounded-lg bg-white focus-within:border-[#f7ab6e] focus-within:ring-1 focus-within:ring-[#f7ab6e]/20 transition-all duration-300">
{/* Zone pour le texte - SANS overflow */}
<div className="h-40 p-2 pb-6 relative">
{" "}
{/* Ajout de pb-6 pour le compteur */}
{/* Placeholder personnalisé avec lien cliquable */}
{!sourceText && (
<div className="absolute inset-2 text-gray-400 text-md leading-relaxed pointer-events-none">
<span>Commencez à taper du texte, ou&nbsp;</span>
<SampleTextComponent
setSourceText={setSourceText}
setUploadedFile={setUploadedFile}
setIsExampleLoaded={setIsExampleLoaded}
variant="link"
/>
</div>
)}
<textarea
value={sourceText}
onChange={(e) => setSourceText(e.target.value)}
placeholder="" // Placeholder vide car on utilise le custom
className="w-full h-full border-none outline-none resize-none text-[#092727] text-xs leading-relaxed bg-transparent overflow-y-auto"
style={{
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace',
}}
/>
{/* Compteur de caractères en bas à gauche */}
<div className="absolute bottom-1 left-2 text-gray-400 text-xs pointer-events-none">
{sourceText.length} caractères
</div>
</div>
{/* Barre du bas avec sélecteur et bouton */}
<div className="flex flex-col p-2 border-t border-gray-200 bg-gray-50 space-y-2">
{/* Sélecteur de type d'anonymisation */}
<div className="flex flex-col w-full">
<label className="text-xs text-gray-500 mb-1">
Type de données :
</label>
<div className="relative">
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="w-full appearance-none bg-white border border-gray-300 text-gray-700 text-xs rounded-md pl-3 pr-8 py-2 focus:outline-none focus:ring-1 focus:ring-[#f7ab6e] focus:border-[#f7ab6e] transition-colors duration-200"
>
<option value="pii">🔒 PII (Données Personnelles)</option>
<option value="business">
🏢 Business (Données Métier)
</option>
<option value="pii_business">🔒🏢 PII + Business </option>
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700">
<svg
className="fill-current h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
>
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" />
</svg>
</div>
</div>
</div>
{/* Bouton Anonymiser */}
<button
onClick={() => onAnonymize?.(selectedCategory)}
disabled={isProcessing || !sourceText.trim()}
className="w-full bg-[#f7ab6e] hover:bg-[#f7ab6e]/90 text-black px-4 py-2 rounded-lg text-xs font-medium transition-colors duration-300 flex items-center justify-center space-x-2 shadow-sm disabled:bg-gray-300 disabled:text-gray-800 disabled:font-bold disabled:cursor-not-allowed"
title={
sourceText.trim()
? "Anonymiser les données"
: "Saisissez du texte pour anonymiser"
}
>
{isProcessing ? (
<>
<div className="animate-spin rounded-full h-3 w-3 border-b border-white"></div>
<span>Traitement...</span>
</>
) : (
<>
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
<span>Anonymisez les données</span>
</>
)}
</button>
</div>
</div>
</div>
</div>
{/* Colonne droite - Zone upload */}
<div
className={`border-2 border-dashed rounded-xl transition-all duration-300 ${
isDragOver
? "border-[#f7ab6e] bg-[#f7ab6e]/10 scale-105"
: "border-[#092727] bg-gray-50 hover:bg-gray-100 hover:border-[#0a3030]"
}`}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<label className="flex flex-col items-center justify-center cursor-pointer group p-3 sm:p-4 h-full min-h-[200px]">
{/* Upload Icon */}
<div
className={`w-10 h-10 rounded-full flex items-center justify-center mb-3 transition-colors duration-300 ${
isDragOver ? "bg-[#f7ab6e] scale-110" : "bg-[#f7ab6e]"
}`}
>
<Upload className="h-5 w-5 text-white" />
</div>
{/* Titre */}
<h3
className={`text-lg font-semibold mb-1 transition-colors duration-300 text-center ${
isDragOver
? "text-[#f7ab6e]"
: "text-[#092727] group-hover:text-[#0a3030]"
}`}
>
{isDragOver
? "Déposez votre fichier"
: "Déposez votre fichier ici"}
</h3>
<p
className={`text-sm mb-3 text-center transition-opacity duration-300 ${
isDragOver
? "text-[#f7ab6e] opacity-90"
: "text-[#092727] opacity-80 group-hover:opacity-90"
}`}
>
ou cliquez pour sélectionner
</p>
{/* File Info */}
<div className="flex items-center gap-1 text-xs text-[#092727] opacity-60">
<span>📄 Fichiers PDF et TXT uniquement</span> -{" "}
<span>Max 5MB</span>
</div>
{/* Hidden Input */}
<input
type="file"
onChange={handleFileChangeWithValidation}
accept=".txt,.pdf"
className="hidden"
/>
</label>
</div>
</div>
{/* Supported Data Types */}
<SupportedDataTypes />
</div>
);
};

View File

@@ -0,0 +1,27 @@
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, Ctrl/CMD (ou Shift) + clic.
</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,125 @@
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";
interface InteractiveTextEditorProps {
text: string;
entityMappings: EntityMapping[];
onUpdateMapping: (
originalValue: string,
newLabel: string,
entityType: string,
applyToAllOccurrences?: boolean,
customColor?: string,
wordStart?: number,
wordEnd?: number
) => void;
onRemoveMapping?: (originalValue: string, applyToAll?: boolean) => 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();
// Support multi-sélection avec Ctrl, Cmd et Shift
const isMultiSelect = event.ctrlKey || event.metaKey || event.shiftKey;
if (isMultiSelect) {
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">
<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

@@ -0,0 +1,91 @@
import { Check, Upload, Eye, Shield } from "lucide-react";
import React from "react";
interface ProgressBarProps {
currentStep: number;
steps: string[];
}
export const ProgressBar = ({ currentStep, steps }: ProgressBarProps) => {
// Icônes pour chaque étape
const getStepIcon = (stepNumber: number, isCompleted: boolean) => {
if (isCompleted) {
return <Check className="h-3 w-3" />;
}
switch (stepNumber) {
case 1:
return <Upload className="h-3 w-3" />;
case 2:
return <Eye className="h-3 w-3" />;
case 3:
return <Shield className="h-3 w-3" />;
default:
return stepNumber;
}
};
return (
<div className="w-full max-w-2xl mx-auto mb-4 px-2">
<div className="flex items-start justify-center">
<div className="flex items-start w-full max-w-md">
{steps.map((step, index) => {
const stepNumber = index + 1;
const isCompleted = stepNumber < currentStep;
const isCurrent = stepNumber === currentStep;
return (
<React.Fragment key={index}>
{/* Step Circle and Label */}
<div
className="flex flex-col items-center text-center"
style={{ flex: "0 0 auto" }}
>
<div
className={`w-5 h-5 sm:w-6 sm:h-6 rounded-full flex items-center justify-center font-medium text-xs transition-all duration-300 ${
isCompleted
? "bg-[#f7ab6e] text-white"
: isCurrent
? "bg-[#f7ab6e] text-white ring-2 ring-[#f7ab6e] ring-opacity-20"
: "bg-gray-200 text-gray-500"
}`}
>
{getStepIcon(stepNumber, isCompleted)}
</div>
<span
className={`mt-1 text-sm font-medium leading-tight ${
isCompleted || isCurrent
? "text-[#092727]"
: "text-gray-500"
}`}
style={{
wordBreak: "break-word",
hyphens: "auto",
minWidth: "60px", // Assure un espace minimum
maxWidth: "120px", // Empêche de devenir trop large
}}
>
{step}
</span>
</div>
{/* Connector Line */}
{index < steps.length - 1 && (
<div className="flex-1 flex items-center justify-center px-1 sm:px-2 mt-2.5 sm:mt-3">
<div
className={`h-0.5 w-full transition-all duration-300 ${
stepNumber < currentStep
? "bg-[#f7ab6e]"
: "bg-gray-200"
}`}
/>
</div>
)}
</React.Fragment>
);
})}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,236 @@
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
// Supprimer l'interface locale et utiliser celle de entityLabels.ts
interface ResultPreviewComponentProps {
outputText: string;
sourceText: string;
copyToClipboard: () => void;
downloadText: () => void;
entityMappings?: EntityMapping[];
onMappingsUpdate?: (mappings: EntityMapping[]) => void;
}
export const ResultPreviewComponent = ({
outputText,
sourceText,
copyToClipboard,
downloadText,
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é (Mode interactif)
</h3>
<div className="flex items-center gap-2">
<button
onClick={copyToClipboard}
disabled={!outputText}
className="p-2 text-[#092727] hover:text-[#f7ab6e] disabled:opacity-50"
title="Copier"
>
<Copy className="h-4 w-4" />
</button>
<button
onClick={downloadText}
disabled={!outputText}
className="bg-[#f7ab6e] hover:bg-[#f7ab6e]/90 disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-300 flex items-center space-x-2"
title="Télécharger"
>
<Download className="h-4 w-4" />
<span>Télécharger</span>
</button>
</div>
</div>
<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">
<InteractiveTextEditor
text={sourceText}
entityMappings={entityMappings}
onUpdateMapping={handleUpdateMapping}
onRemoveMapping={handleRemoveMapping}
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,50 @@
interface SampleTextComponentProps {
setSourceText: (text: string) => void;
setUploadedFile: (file: File | null) => void;
setIsExampleLoaded?: (loaded: boolean) => void;
variant?: "button" | "link";
}
export const SampleTextComponent = ({
setSourceText,
setUploadedFile,
setIsExampleLoaded,
variant = "button",
}: SampleTextComponentProps) => {
const loadSampleText = () => {
const sampleText = `Date : 15 mars 2025
Dans le cadre du litige opposant Madame Els Vandermeulen (née le 12/08/1978, demeurant 45 Avenue Louise, 1050 Ixelles, Tel: 02/456.78.90) à Monsieur Karel Derycke, gérant de la SPRL DigitalConsult (BCE: 0123.456.789), nous analysons les éléments suivants :
**Contexte financier :**
Le contrat de prestation signé le 3 janvier 2024 prévoyait un montant de 75 000 € HTVA. Les virements effectués depuis le compte IBAN BE68 5390 0754 7034 (BNP Paribas Fortis) vers le compte bénéficiaire BE71 0961 2345 6789 montrent des irrégularités.
**Témoins clés :**
- Dr. Marie (expert-comptable, n° IEC: 567890)
- M. Pieter Van Der Berg (consultant IT, email: p.vanderberg@itconsult.be)
**Données sensibles :**
Le serveur compromis contenait 12 000 dossiers clients avec numéros de registre national. L'incident du 28 février 2024 a exposé les données personnelles stockées sur l'adresse IP 10.0.0.45 dans les bureaux situés Rue de la Loi 200, 1040 Etterbeek.
Coordonnées bancaires : BE43 0017 5555 5557 (CBC Banque)
TVA intracommunautaire : BE0987.654.321`;
setSourceText(sampleText);
setUploadedFile(null);
if (setIsExampleLoaded) {
setIsExampleLoaded(true);
}
};
if (variant === "link") {
return (
<span
onClick={loadSampleText}
className="text-[#f7ab6e] hover:text-[#f7ab6e]/80 underline pointer-events-auto transition-colors duration-200 cursor-pointer"
title="Cliquez pour charger un exemple de texte"
>
générez un texte d&apos;exemple
</span>
);
}
};

View File

@@ -0,0 +1,31 @@
export const SupportedDataTypes = () => {
return (
<div className="bg-gray-50 border border-gray-200 rounded-xl p-6">
<h4 className="text-sm font-semibold text-[#092727] mb-4">
Types de données supportées :
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-xs text-[#092727] opacity-80">
<div className="flex flex-col space-y-2">
<span> Prénoms</span>
<span> Numéros de téléphone</span>
<span> Noms de domaine</span>
</div>
<div className="flex flex-col space-y-2">
<span> Noms de famille</span>
<span> Adresses</span>
<span> Dates</span>
</div>
<div className="flex flex-col space-y-2">
<span> Noms complets</span>
<span> Numéros d&apos;ID</span>
<span> Coordonnées bancaires</span>
</div>
<div className="flex flex-col space-y-2">
<span> Adresses e-mail</span>
<span> Valeurs monétaires</span>
<span> Texte personnalisé</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,121 @@
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 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,
userSelect: "none",
WebkitUserSelect: "none",
}}
onMouseEnter={() => onWordHover(index)}
onMouseLeave={() => onWordHover(null)}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey) {
e.preventDefault();
e.stopPropagation();
}
onWordClick(index, e);
}}
onContextMenu={onContextMenu}
onMouseDown={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey) {
e.preventDefault();
}
}}
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 '#e5e7eb'; // Couleur grise par défaut au lieu du bleu
}
// 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;
}
// Retourner gris par défaut si aucun mapping
return '#e5e7eb';
},
[entityMappings]
);
const getColorByText = useCallback(
(selectedText: string) => {
return getCurrentColor(selectedText);
},
[getCurrentColor]
);
return {
colorOptions,
tailwindToHex,
getCurrentColor,
getColorByText,
};
};

View File

@@ -0,0 +1,325 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { EntityMapping } from "@/app/config/entityLabels";
import { Word } from "./useTextParsing";
interface ContextMenuState {
visible: boolean;
x: number;
y: number;
selectedText: string;
wordIndices: number[];
}
interface UseContextMenuProps {
entityMappings: EntityMapping[];
words: Word[];
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,
onUpdateMapping,
onRemoveMapping,
getCurrentColor,
setSelectedWords,
}: UseContextMenuProps) => {
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
visible: false,
x: 0,
y: 0,
selectedText: "",
wordIndices: [],
});
// Référence pour tracker les mappings précédents
const previousMappingsRef = useRef<EntityMapping[]>([]);
const previousLabelsRef = useRef<string[]>([]);
const closeContextMenu = useCallback(() => {
setContextMenu((prev) => ({ ...prev, visible: false }));
}, []);
const showContextMenu = useCallback(
(menuData: Omit<ContextMenuState, "visible">) => {
setContextMenu({ ...menuData, visible: true });
},
[]
);
// OPTIMISATION INTELLIGENTE: Ne log que les changements
const existingLabels = useMemo(() => {
const uniqueLabels = new Set<string>();
const newMappings: EntityMapping[] = [];
const changedMappings: EntityMapping[] = [];
const removedMappings: EntityMapping[] = [];
// Détecter les changements
const previousMap = new Map(
previousMappingsRef.current.map((m) => [m.text, m])
);
const currentMap = new Map(entityMappings.map((m) => [m.text, m]));
// Nouveaux mappings
entityMappings.forEach((mapping) => {
if (!previousMap.has(mapping.text)) {
newMappings.push(mapping);
} else {
const previous = previousMap.get(mapping.text)!;
if (JSON.stringify(previous) !== JSON.stringify(mapping)) {
changedMappings.push(mapping);
}
}
});
// Mappings supprimés
previousMappingsRef.current.forEach((mapping) => {
if (!currentMap.has(mapping.text)) {
removedMappings.push(mapping);
}
});
// Logger seulement les changements
if (newMappings.length > 0) {
console.log("🆕 Nouveaux mappings détectés:", newMappings.length);
newMappings.forEach((mapping) => {
console.log("📋 Nouveau mapping:", {
text: mapping.text,
displayName: mapping.displayName,
entity_type: mapping.entity_type,
});
});
}
if (changedMappings.length > 0) {
console.log("🔄 Mappings modifiés:", changedMappings.length);
changedMappings.forEach((mapping) => {
console.log("📝 Mapping modifié:", {
text: mapping.text,
displayName: mapping.displayName,
entity_type: mapping.entity_type,
});
});
}
if (removedMappings.length > 0) {
console.log("🗑️ Mappings supprimés:", removedMappings.length);
removedMappings.forEach((mapping) => {
console.log("❌ Mapping supprimé:", {
text: mapping.text,
displayName: mapping.displayName,
});
});
}
// Traitement de tous les mappings pour les labels
entityMappings.forEach((mapping) => {
if (
mapping.displayName &&
typeof mapping.displayName === "string" &&
mapping.displayName.trim().length > 0
) {
// Accepter tous les displayName non vides, pas seulement ceux avec crochets
uniqueLabels.add(mapping.displayName);
}
});
const result = Array.from(uniqueLabels).sort();
// Logger seulement si les labels ont changé
const previousLabels = previousLabelsRef.current;
if (JSON.stringify(previousLabels) !== JSON.stringify(result)) {
console.log("🎯 Labels mis à jour:", {
ajoutés: result.filter((l) => !previousLabels.includes(l)),
supprimés: previousLabels.filter((l) => !result.includes(l)),
total: result.length,
});
}
// Mettre à jour les références
previousMappingsRef.current = [...entityMappings];
previousLabelsRef.current = [...result];
return result;
}, [entityMappings]);
const getExistingLabels = useCallback(() => {
return existingLabels;
}, [existingLabels]);
const applyLabel = useCallback(
(displayName: string, applyToAll?: boolean) => {
if (!contextMenu.selectedText) return;
const originalText = contextMenu.selectedText;
const selectedIndices = contextMenu.wordIndices;
// Calculer les positions de début et fin pour tous les mots sélectionnés
const sortedIndices = selectedIndices.sort((a, b) => a - b);
const firstWord = words[sortedIndices[0]];
const lastWord = words[sortedIndices[sortedIndices.length - 1]];
const wordStart = firstWord?.start;
const wordEnd = lastWord?.end;
const existingMapping = entityMappings.find(
(m) => m.text === originalText
);
const entityType =
existingMapping?.entity_type ||
displayName.replace(/[\[\]]/g, "").toUpperCase();
console.log("🏷️ Application de label:", {
text: originalText,
label: displayName,
entityType,
applyToAll,
wordIndices: selectedIndices,
positions: { start: wordStart, end: wordEnd },
});
onUpdateMapping(
originalText,
displayName,
entityType,
applyToAll,
undefined,
wordStart,
wordEnd
);
setSelectedWords(new Set());
closeContextMenu();
},
[
contextMenu,
words,
entityMappings,
onUpdateMapping,
closeContextMenu,
setSelectedWords,
]
);
const applyColorDirectly = useCallback(
(color: string, colorName: string, applyToAll?: boolean) => {
if (!contextMenu.selectedText) return;
const existingMapping = entityMappings.find(
(mapping) => mapping.text === contextMenu.selectedText
);
console.log("🎨 Application de couleur:", {
text: contextMenu.selectedText,
color,
colorName,
applyToAll,
existingMapping: !!existingMapping,
});
if (existingMapping) {
// MODIFICATION : Appliquer directement la couleur pour un label existant
onUpdateMapping(
contextMenu.selectedText,
existingMapping.displayName || existingMapping.entity_type,
existingMapping.entity_type,
applyToAll,
color
);
setSelectedWords(new Set());
closeContextMenu();
} else {
// CRÉATION : Créer un nouveau label avec la couleur
// Utiliser le texte sélectionné comme nom de label par défaut
const defaultLabel = contextMenu.selectedText.toUpperCase();
console.log("🆕 Création d'un nouveau label avec couleur:", {
text: contextMenu.selectedText,
label: defaultLabel,
color,
applyToAll
});
onUpdateMapping(
contextMenu.selectedText,
defaultLabel,
defaultLabel,
applyToAll,
color
);
setSelectedWords(new Set());
closeContextMenu();
}
},
[
contextMenu.selectedText,
entityMappings,
onUpdateMapping,
closeContextMenu,
setSelectedWords,
]
);
const removeLabel = useCallback(
(applyToAll?: boolean) => {
if (!contextMenu.selectedText || !onRemoveMapping) return;
console.log("🗑️ Suppression de label:", {
text: contextMenu.selectedText,
applyToAll,
});
onRemoveMapping(contextMenu.selectedText, applyToAll);
setSelectedWords(new Set());
closeContextMenu();
},
[
contextMenu.selectedText,
onRemoveMapping,
closeContextMenu,
setSelectedWords,
]
);
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,93 @@
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 directement SANS fallback
const anonymizedText = mapping.displayName;
// Ne créer le segment que si displayName existe
if (anonymizedText) {
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

@@ -25,7 +25,7 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-white`}
>
{children}
</body>

View File

@@ -1,536 +1,171 @@
"use client";
import { useState, useCallback } from "react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Upload,
FileText,
ShieldCheck,
Download,
Trash2,
AlertCircle,
X,
Zap,
Lock,
Shield,
Clock,
Eye,
TestTube,
} from "lucide-react";
import PresidioModal from "./components/PresidioModal";
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";
export type PageObject = {
pageNumber: number;
htmlContent: string;
};
import { EntityMapping } from "./config/entityLabels"; // Importer l'interface unifiée
interface ProcessedFile {
id: string;
name: string;
status: "processing" | "completed" | "error";
timestamp: Date;
originalSize?: string;
processedSize?: string;
piiCount?: number;
errorMessage?: string;
processedBlob?: Blob;
anonymizedText?: string;
}
// Supprimer l'interface locale EntityMapping (lignes 12-18)
export default function Home() {
const [file, setFile] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [isDragOver, setIsDragOver] = useState(false);
const [history, setHistory] = useState<ProcessedFile[]>([]);
const [sourceText, setSourceText] = useState("");
const [outputText, setOutputText] = useState("");
const [anonymizedText, setAnonymizedText] = useState(""); // Nouveau state pour le texte anonymisé de Presidio
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const [showPresidioModal, setShowPresidioModal] = useState(false);
const [anonymizedResult, setAnonymizedResult] = useState<{
text: string;
piiCount: number;
} | null>(null);
const [isLoadingFile, setIsLoadingFile] = useState(false);
const [entityMappings, setEntityMappings] = useState<EntityMapping[]>([]);
const [isExampleLoaded, setIsExampleLoaded] = useState(false);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) {
setFile(e.target.files[0]);
const progressSteps = ["Téléversement", "Prévisualisation", "Anonymisation"];
const getCurrentStep = () => {
if (outputText) return 3;
if (uploadedFile || (sourceText && sourceText.trim())) return 2;
return 1;
};
// Fonction pour recommencer (retourner à l'état initial)
const handleRestart = () => {
setSourceText("");
setOutputText("");
setUploadedFile(null);
setError(null);
}
setIsLoadingFile(false);
setEntityMappings([]);
setIsExampleLoaded(false);
};
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
if (e.dataTransfer.files?.length) {
setFile(e.dataTransfer.files[0]);
setError(null);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback(() => {
setIsDragOver(false);
}, []);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
const clearFile = () => {
setFile(null);
setError(null);
};
const clearHistory = () => setHistory([]);
const removeFromHistory = (id: string) =>
setHistory((prev) => prev.filter((item) => item.id !== id));
const handleDownloadTxt = (id: string) => {
const fileToDownload = history.find((item) => item.id === id);
if (!fileToDownload?.processedBlob) return;
const url = URL.createObjectURL(fileToDownload.processedBlob);
const a = document.createElement("a");
a.href = url;
a.download = `anonymized_${fileToDownload.name
.split(".")
.slice(0, -1)
.join(".")}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handlePreview = (id: string) => {
const fileToPreview = history.find((item) => item.id === id);
if (fileToPreview?.anonymizedText && fileToPreview.piiCount !== undefined) {
setAnonymizedResult({
text: fileToPreview.anonymizedText,
piiCount: fileToPreview.piiCount,
});
setShowPresidioModal(true);
}
};
const getStatusInfo = (item: ProcessedFile) => {
switch (item.status) {
case "completed":
return {
icon: <ShieldCheck className="h-4 w-4 text-white" />,
color: "bg-[#061717]",
};
case "error":
return {
icon: <AlertCircle className="h-4 w-4 text-white" />,
color: "bg-[#F7AB6E]",
};
default:
return {
icon: (
<div className="h-2 w-2 rounded-full bg-[#F7AB6E] animate-pulse" />
),
color: "bg-[#061717]",
};
}
};
const loadTestDocument = () => {
try {
// Créer un document de test avec des données PII fictives
const testContent = `Rapport pour la société belge "Solution Globale SPRL" (BCE : BE 0987.654.321).
Contact principal : M. Luc Dubois, né le 15/03/1975.
Son numéro de registre national est le 75.03.15-123.45.
Adresse : Avenue des Arts 56, 1000 Bruxelles.
Téléphone : +32 470 12 34 56. Email : luc.dubois@solutionglobale.be.
Le paiement de la facture a été effectué par carte VISA 4979 1234 5678 9012.
Le remboursement sera versé sur le compte IBAN BE12 3456 7890 1234, code SWIFT : GEBABEBB.`;
const testBlob = new Blob([testContent], { type: "text/plain" });
const testFile = new File([testBlob], "document-test.txt", {
type: "text/plain",
});
setFile(testFile);
setError(null);
} catch {
setError("Erreur lors du chargement du document de test");
}
};
const processFile = async () => {
if (!file) return;
setIsProcessing(true);
setProgress(0);
setError(null);
const fileId = `${Date.now()}-${file.name}`;
setHistory((prev) => [
{
id: fileId,
name: file.name,
status: "processing",
timestamp: new Date(),
originalSize: formatFileSize(file.size),
// Fonction pour mettre à jour les mappings depuis l'éditeur interactif
const handleMappingsUpdate = useCallback(
(updatedMappings: EntityMapping[]) => {
setEntityMappings(updatedMappings);
},
...prev,
]);
setFile(null);
try {
const formData = new FormData();
formData.append("file", file);
setProgress(25);
const response = await fetch("/api/process-document", {
method: "POST",
body: formData,
});
setProgress(75);
const result = await response.json();
if (!response.ok) {
throw new Error(
result.error || "Une erreur est survenue lors du traitement."
[]
);
}
const { anonymizedText, piiCount } = result;
const processedBlob = new Blob([anonymizedText], {
type: "text/plain;charset=utf-8",
// Hooks personnalisés pour la logique métier
const { handleFileChange } = useFileHandler({
setUploadedFile,
setSourceText,
setError,
setIsLoadingFile,
});
setHistory((prev) =>
prev.map((item) =>
item.id === fileId
? {
...item,
status: "completed",
processedSize: formatFileSize(processedBlob.size),
piiCount,
processedBlob,
anonymizedText,
}
: item
)
);
setProgress(100);
} catch (err) {
const errorMessage =
err instanceof Error
? err.message
: "Une erreur inconnue est survenue.";
setError(errorMessage);
setHistory((prev) =>
prev.map((item) =>
item.id === fileId ? { ...item, status: "error", errorMessage } : item
)
);
} finally {
setIsProcessing(false);
setTimeout(() => setProgress(0), 1000);
}
const { anonymizeData, isProcessing } = useAnonymization({
setOutputText,
setError,
setEntityMappings,
setAnonymizedText, // Passer la fonction pour stocker le texte anonymisé
});
const { copyToClipboard, downloadText } = useDownloadActions({
outputText,
entityMappings,
anonymizedText, // Passer le texte anonymisé de Presidio
});
// Fonction wrapper pour appeler anonymizeData avec les bonnes données
const handleAnonymize = (category?: string) => {
anonymizeData({ file: uploadedFile, text: sourceText, category });
};
return (
<div className="h-screen w-screen bg-[#061717] flex flex-col md:flex-row overflow-hidden">
<aside className="w-full md:w-80 md:flex-shrink-0 bg-[#061717] border-b-4 md:border-b-0 md:border-r-4 border-white flex flex-col shadow-[4px_0_0_0_black]">
<div className="p-4 border-b-4 border-white bg-[#F7AB6E] shadow-[0_4px_0_0_black] flex items-center justify-between">
<div className="flex items-center gap-3">
<Image
src="/logo.svg"
alt="LeCercle.IA Logo"
width={32}
height={32}
/>
<h2 className="text-lg font-black text-white uppercase tracking-wide">
Historique
</h2>
</div>
{history.length > 0 && (
<Button
onClick={clearHistory}
variant="ghost"
size="icon"
className="bg-[#061717] text-white border-2 border-white shadow-[3px_3px_0_0_black] hover:shadow-[1px_1px_0_0_black] active:shadow-none active:translate-x-0.5 active:translate-y-0.5"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{history.length > 0 ? (
history.map((item) => {
const status = getStatusInfo(item);
return (
<div
key={item.id}
className="bg-[#061717] border-2 border-white shadow-[4px_4px_0_0_black] p-3 transition-all"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3 min-w-0">
<div
className={`flex-shrink-0 w-7 h-7 border-2 border-white shadow-[2px_2px_0_0_black] flex items-center justify-center ${status.color}`}
>
{status.icon}
</div>
<div className="min-w-0">
<p
className="text-sm font-black text-white uppercase truncate"
title={item.name}
>
{item.name}
</p>
{item.status === "completed" && (
<div className="flex items-center gap-2 mt-1">
<span className="text-xs font-bold text-white/70">
{item.originalSize} {item.processedSize}
</span>
<Badge className="bg-[#F7AB6E] text-white border-2 border-white shadow-[2px_2px_0_0_black] font-black uppercase text-[10px] px-1.5 py-0">
{item.piiCount} PII
</Badge>
</div>
)}
{item.status === "error" && (
<p className="text-xs font-bold text-[#F7AB6E] mt-1 uppercase">
Erreur
</p>
)}
{item.status === "processing" && (
<p className="text-xs font-bold text-[#F7AB6E] mt-1 uppercase">
Traitement...
</p>
)}
</div>
</div>
<div className="flex items-center gap-1.5 ml-2">
{item.status === "completed" && item.anonymizedText && (
<Button
onClick={() => handlePreview(item.id)}
variant="ghost"
size="icon"
className="h-7 w-7 bg-[#061717] text-white border-2 border-white shadow-[2px_2px_0_0_black] hover:shadow-[1px_1px_0_0_black] active:shadow-none active:translate-x-0.5 active:translate-y-0.5"
>
<Eye className="h-3 w-3" />
</Button>
)}
{item.status === "completed" && (
<Button
onClick={() => handleDownloadTxt(item.id)}
variant="ghost"
size="icon"
className="h-7 w-7 bg-[#061717] text-white border-2 border-white shadow-[2px_2px_0_0_black] hover:shadow-[1px_1px_0_0_black] active:shadow-none active:translate-x-0.5 active:translate-y-0.5"
>
<Download className="h-3 w-3" />
</Button>
)}
<Button
onClick={() => removeFromHistory(item.id)}
variant="ghost"
size="icon"
className="h-7 w-7 bg-[#F7AB6E] text-white border-2 border-white shadow-[2px_2px_0_0_black] hover:shadow-[1px_1px_0_0_black] active:shadow-none active:translate-x-0.5 active:translate-y-0.5"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
</div>
);
})
) : (
<div className="text-center py-10 px-4 flex flex-col justify-center h-full">
<div className="w-14 h-14 bg-[#F7AB6E] border-4 border-white shadow-[6px_6px_0_0_black] mx-auto mb-4 flex items-center justify-center">
<FileText className="h-7 w-7 text-white" />
</div>
<p className="text-white font-black text-base uppercase">
Aucun Document
</p>
<p className="text-white/70 font-bold mt-1 text-xs">
Vos fichiers apparaîtront ici.
</p>
</div>
)}
</div>
</aside>
<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 */}
<ProgressBar currentStep={getCurrentStep()} steps={progressSteps} />
<main className="flex-1 flex flex-col items-center justify-center p-4 md:p-6 bg-[#061717] overflow-y-auto">
<div className="w-full max-w-xl flex flex-col ">
<header className="text-center ">
<div className="inline-block p-2 bg-[#F7AB6E] border-1 border-white shadow-[6px_6px_0_0_black] mb-3">
<Lock className="h-8 w-8 text-white" />
</div>
<h1 className="text-3xl md:text-4xl font-black text-white uppercase tracking-tighter mb-1">
LeCercle.IA
</h1>
<p className="text-base md:text-lg font-bold text-white/90 uppercase tracking-wider">
Anonymisation RGPD Sécurisé
</p>
</header>
<Card className="flex-grow my-4 bg-[#061717] border-4 border-white shadow-[10px_10px_0_0_black] flex flex-col">
<CardContent className="p-4 md:p-6 space-y-4 flex-grow flex flex-col">
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={`relative border-4 border-dashed p-7 text-center transition-all duration-200 ${
isDragOver
? "border-[#F7AB6E] bg-white/5"
: file
? "border-[#F7AB6E] bg-white/5 border-solid"
: "border-white hover:border-[#F7AB6E] hover:bg-white/5"
}`}
>
<Input
type="file"
onChange={handleFileChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
accept=".txt,.json,.csv,.pdf,.docx"
/>
<div className="flex flex-col items-center justify-center space-y-4">
<div
className={`w-14 h-14 border-4 border-white shadow-[5px_5px_0_0_black] flex items-center justify-center transition-all duration-200 ${
file ? "bg-[#F7AB6E]" : "bg-[#061717]"
}`}
>
{file ? (
<FileText className="h-7 w-7 text-white" />
) : (
<Upload className="h-7 w-7 text-white" />
)}
</div>
{file ? (
<div>
<p className="text-base font-black text-white uppercase">
{file.name}
</p>
<p className="text-sm font-bold text-white/70">
{formatFileSize(file.size)}
</p>
</div>
) : (
<div>
<p className="text-lg font-black text-white uppercase tracking-wide">
Glissez votre document
</p>
<p className="text-base font-bold text-white/70 uppercase mt-1">
Ou cliquez ici
</p>
</div>
)}
</div>
</div>
{/* Bouton pour charger un document de test */}
<div className="flex-grow flex items-center justify-center">
<Button
onClick={loadTestDocument}
disabled={isProcessing}
className="bg-[#061717] text-white border-4 border-white shadow-[6px_6px_0_0_black] hover:shadow-[3px_3px_0_0_black] active:shadow-none active:translate-x-1 active:translate-y-1 h-12 px-6 text-base font-black uppercase tracking-wide disabled:opacity-50"
>
<TestTube className="h-5 w-5 mr-2" />
Charger Document de Test
</Button>
</div>
{isProcessing && (
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-base font-black text-white uppercase">
Traitement...
</span>
<span className="text-lg font-black text-[#F7AB6E]">
{Math.round(progress)}%
</span>
</div>
<div className="h-4 bg-[#061717] border-2 border-white shadow-[3px_3px_0_0_black]">
<div
className="h-full bg-[#F7AB6E] transition-all duration-300"
style={{ width: `${progress}%` }}
{/* Upload Section */}
<div className="bg-white rounded-2xl border border-gray-50 overflow-hidden">
<div className="p-1 sm:p-3">
<FileUploadComponent
uploadedFile={uploadedFile}
handleFileChange={handleFileChange}
sourceText={sourceText}
setSourceText={setSourceText}
setUploadedFile={setUploadedFile}
onAnonymize={handleAnonymize}
isProcessing={isProcessing}
canAnonymize={
uploadedFile !== null ||
Boolean(sourceText && sourceText.trim())
}
isLoadingFile={isLoadingFile}
onRestart={handleRestart}
outputText={outputText}
copyToClipboard={copyToClipboard}
downloadText={downloadText}
isExampleLoaded={isExampleLoaded}
setIsExampleLoaded={setIsExampleLoaded}
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">
<div className="p-1 sm:p-3">
<EntityMappingTable mappings={entityMappings} />
</div>
</div>
)}
{/* Error Message - Version améliorée */}
{error && (
<div className="flex items-start gap-3 p-3 bg-[#F7AB6E] border-2 border-white shadow-[4px_4px_0_0_black]">
<AlertCircle className="h-5 w-5 text-white flex-shrink-0 mt-0.5" />
<p className="text-sm font-bold text-white uppercase">
{error}
</p>
</div>
)}
<div className="flex items-stretch gap-3 pt-2">
<Button
onClick={processFile}
disabled={!file || isProcessing}
className="flex-1 bg-[#F7AB6E] text-white border-4 border-white shadow-[6px_6px_0_0_black] hover:shadow-[3px_3px_0_0_black] active:shadow-none active:translate-x-1 active:translate-y-1 h-12 text-base font-black uppercase tracking-wide disabled:opacity-50"
<div className="bg-red-50 border border-red-200 rounded-xl p-3 sm:p-4 mx-2 sm:mx-0">
<div className="flex items-start space-x-3">
<svg
className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{isProcessing ? (
<div className="animate-spin rounded-full h-6 w-6 border-4 border-white border-t-transparent" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div className="flex-1">
<h3 className="text-red-800 text-sm font-semibold mb-2">
{error.includes("scanné")
? "📄 PDF Scanné Détecté"
: error.includes("HTTP")
? "🚨 Erreur de Traitement"
: "⚠️ Erreur"}
</h3>
<div className="text-red-700 text-xs sm:text-sm leading-relaxed">
{error.split("\n").map((line, index) => (
<div key={index} className={index > 0 ? "mt-2" : ""}>
{line.startsWith("💡") ? (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mt-3">
<div className="text-blue-800 font-medium text-sm">
{line}
</div>
</div>
) : line.startsWith("-") ? (
<div className="ml-4 text-blue-700">{line}</div>
) : (
<>
<Zap className="h-5 w-5 mr-2" />
Anonymiser
</>
line
)}
</Button>
{file && !isProcessing && (
<Button
onClick={clearFile}
className="h-12 w-12 p-0 bg-[#061717] text-white border-4 border-white shadow-[6px_6px_0_0_black] hover:shadow-[3px_3px_0_0_black] active:shadow-none active:translate-x-1 active:translate-y-1"
>
<X className="h-6 w-6" />
</Button>
)}
</div>
</CardContent>
</Card>
<div className="grid grid-cols-3 gap-3 pb-4">
{[
{ icon: Shield, title: "RGPD", subtitle: "Conforme" },
{ icon: Clock, title: "Rapide", subtitle: "Local" },
{ icon: Lock, title: "Sécurisé", subtitle: "Sans Serveur" },
].map((item, index) => (
<div
key={index}
className="bg-[#061717] border-2 border-white shadow-[4px_4px_0_0_black] p-3 text-center"
>
<div className="w-8 h-8 bg-[#F7AB6E] border-2 border-white shadow-[2px_2px_0_0_black] mx-auto mb-2 flex items-center justify-center">
<item.icon className="h-4 w-4 text-white" />
</div>
<p className="text-sm font-black text-white uppercase">
{item.title}
</p>
<p className="text-xs font-bold text-white/70 uppercase">
{item.subtitle}
</p>
</div>
))}
</div>
</div>
</main>
{showPresidioModal && (
<PresidioModal
anonymizedText={anonymizedResult?.text || null}
piiCount={anonymizedResult?.piiCount || 0}
onClose={() => setShowPresidioModal(false)}
/>
</div>
</div>
)}
</div>
</div>
);
}

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,96 @@
import { EntityMapping } from "@/app/config/entityLabels";
// Fonction améliorée pour résoudre les chevauchements d'entités
const resolveOverlaps = (mappings: EntityMapping[]): EntityMapping[] => {
if (mappings.length <= 1) return mappings;
// Trier par position de début, puis par score/longueur
const sorted = [...mappings].sort((a, b) => {
if (a.start !== b.start) return a.start - b.start;
// En cas d'égalité, privilégier l'entité la plus longue
return (b.end - b.start) - (a.end - a.start);
});
const resolved: EntityMapping[] = [];
for (const current of sorted) {
// Vérifier si cette entité chevauche avec une entité déjà acceptée
let hasConflict = false;
for (const existing of resolved) {
// Détecter tout type de chevauchement
const overlap = (
(current.start >= existing.start && current.start < existing.end) ||
(current.end > existing.start && current.end <= existing.end) ||
(current.start <= existing.start && current.end >= existing.end)
);
if (overlap) {
hasConflict = true;
break;
}
}
if (!hasConflict) {
resolved.push(current);
}
}
return resolved.sort((a, b) => a.start - b.start);
};
// Fonction pour nettoyer et valider les mappings
const cleanMappings = (mappings: EntityMapping[], originalText: string): EntityMapping[] => {
return mappings.filter(mapping => {
// Vérifier que les indices sont valides
if (mapping.start < 0 || mapping.end < 0) return false;
if (mapping.start >= originalText.length) return false;
if (mapping.end > originalText.length) return false;
if (mapping.start >= mapping.end) return false;
// Vérifier que le texte correspond
const extractedText = originalText.slice(mapping.start, mapping.end);
return extractedText === mapping.text;
});
};
export const generateAnonymizedText = (
originalText: string,
mappings: EntityMapping[]
): string => {
if (!originalText || !mappings || mappings.length === 0) {
return originalText;
}
// Nettoyer et valider les mappings
const cleanedMappings = cleanMappings(mappings, originalText);
// Résoudre les chevauchements
const resolvedMappings = resolveOverlaps(cleanedMappings);
let result = "";
let lastIndex = 0;
for (const mapping of resolvedMappings) {
// Sécurité supplémentaire
if (mapping.start < lastIndex) continue;
// Ajouter le texte avant l'entité
result += originalText.slice(lastIndex, mapping.start);
// Utiliser la valeur de remplacement appropriée
let replacement = mapping.replacementValue;
if (!replacement) {
replacement = mapping.displayName || `[${mapping.entity_type}]`;
}
result += replacement;
lastIndex = mapping.end;
}
// Ajouter le reste du texte
result += originalText.slice(lastIndex);
return result;
};

View File

@@ -0,0 +1,51 @@
import React, { ReactNode } from "react";
import {
generateColorFromName,
EntityMapping,
} from "@/app/config/entityLabels";
export const highlightEntities = (
originalText: string,
mappings?: EntityMapping[]
): ReactNode[] => {
if (!originalText || !mappings || mappings.length === 0) {
return [originalText];
}
const parts: ReactNode[] = [];
let lastIndex = 0;
// 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));
}
// Créer et ajouter le badge stylisé pour l'entité
const colorOption = generateColorFromName(entity_type);
const displayText = entity_type;
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>
);
// Mettre à jour la position pour la prochaine itération
lastIndex = end;
});
// Ajouter le reste du texte après la dernière entité
if (lastIndex < originalText.length) {
parts.push(originalText.slice(lastIndex));
}
return parts;
};

116
components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

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;