Compare commits
32 Commits
67075d28a5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
050474e95b | ||
|
|
130929b756 | ||
|
|
d7d3a3c7e9 | ||
|
|
0360e1ca9f | ||
|
|
3a84da5c74 | ||
|
|
a0e033b7eb | ||
|
|
f3c2cb6ff5 | ||
|
|
88a94c46fe | ||
|
|
ef0819ae90 | ||
|
|
74e56c956c | ||
|
|
e4c735cdc4 | ||
|
|
5c612bf07f | ||
|
|
ad92302461 | ||
|
|
b1de50cbc2 | ||
|
|
1f26a3de5c | ||
|
|
85288890cc | ||
|
|
bc07ea6077 | ||
|
|
cb2c17ce2b | ||
|
|
a7b0b32582 | ||
|
|
499362fb3f | ||
|
|
7a086c4749 | ||
|
|
dc734e08f0 | ||
|
|
6d12017561 | ||
|
|
5ba4fdc450 | ||
|
|
aa19bb82a0 | ||
|
|
52d02a967f | ||
|
|
df2e4ad1b4 | ||
|
|
e61be83cb6 | ||
|
|
7803cc4598 | ||
|
|
1b483b63ff | ||
|
|
08afa534e1 | ||
|
|
082d47d951 |
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
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 mammoth from "mammoth";
|
||||||
|
import { PresidioAnalyzerResult } from "@/app/config/entityLabels";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
console.log("🔍 Début du traitement de la requête");
|
console.log("🔍 Début du traitement de la requête");
|
||||||
@@ -8,31 +9,97 @@ export async function POST(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
const file = formData.get("file") as File | null;
|
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) {
|
if (!file) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Aucun fichier reçu." },
|
{ error: "Aucun fichier reçu." },
|
||||||
{ status: 400 }
|
{ 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 = "";
|
let fileContent = "";
|
||||||
const fileType = file.type;
|
const fileType = file.type;
|
||||||
|
|
||||||
// --- LOGIQUE D'EXTRACTION DE TEXTE (INCHANGÉE) ---
|
// --- LOGIQUE D'EXTRACTION DE TEXTE ---
|
||||||
if (fileType === "application/pdf") {
|
if (fileType === "application/pdf") {
|
||||||
console.log("📄 Traitement PDF en cours...");
|
console.log("📄 Traitement PDF en cours...");
|
||||||
|
console.log("📊 Taille du fichier:", file.size, "bytes");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
console.log("📦 Buffer créé, taille:", buffer.length);
|
||||||
|
|
||||||
const data = await pdf(buffer);
|
const data = await pdf(buffer);
|
||||||
fileContent = data.text;
|
fileContent = data.text || "";
|
||||||
|
|
||||||
console.log("✅ Extraction PDF réussie, longueur:", fileContent.length);
|
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) {
|
} 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(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: `Erreur traitement PDF: ${
|
error: `Impossible de traiter ce PDF (${file.name}). Erreur: ${
|
||||||
pdfError instanceof Error ? pdfError.message : "Erreur inconnue"
|
pdfError instanceof Error ? pdfError.message : "Erreur inconnue"
|
||||||
}`,
|
}. Vérifiez que le PDF n'est pas protégé, corrompu ou scanné.`,
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
@@ -45,12 +112,13 @@ export async function POST(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const result = await mammoth.extractRawText({ arrayBuffer });
|
const result = await mammoth.extractRawText({ arrayBuffer });
|
||||||
fileContent = result.value;
|
fileContent = result.value || "";
|
||||||
console.log(
|
console.log(
|
||||||
"✅ Extraction Word réussie, longueur:",
|
"✅ Extraction Word réussie, longueur:",
|
||||||
fileContent.length
|
fileContent.length
|
||||||
);
|
);
|
||||||
} catch (wordError) {
|
} catch (wordError) {
|
||||||
|
console.error("❌ Erreur Word:", wordError);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: `Erreur traitement Word: ${
|
error: `Erreur traitement Word: ${
|
||||||
@@ -69,6 +137,7 @@ export async function POST(req: NextRequest) {
|
|||||||
fileContent.length
|
fileContent.length
|
||||||
);
|
);
|
||||||
} catch (textError) {
|
} catch (textError) {
|
||||||
|
console.error("❌ Erreur texte:", textError);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: `Erreur lecture texte: ${
|
error: `Erreur lecture texte: ${
|
||||||
@@ -88,23 +157,33 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// Vérifier si c'est juste pour l'extraction de texte (lecture simple)
|
||||||
// CONFIGURATION PRESIDIO ANALYZER (SIMPLIFIÉE)
|
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 = {
|
const analyzerConfig = {
|
||||||
text: fileContent,
|
text: fileContent,
|
||||||
language: "fr",
|
language: "fr",
|
||||||
// Plus de ad_hoc_recognizers ici !
|
mode: category, // Ajouter le mode basé sur la catégorie
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🔍 Appel à Presidio Analyzer...");
|
console.log("🔍 Appel à Presidio Analyzer...");
|
||||||
// Mettez votre URL externe ici, ou utilisez le nom de service Docker si approprié
|
console.log("📊 Configuration:", analyzerConfig);
|
||||||
|
|
||||||
|
// ✅ Définir l'URL AVANT de l'utiliser
|
||||||
const presidioAnalyzerUrl =
|
const presidioAnalyzerUrl =
|
||||||
"http://analyzer.151.80.20.211.sslip.io/analyze";
|
"http://analyzer.151.80.20.211.sslip.io/analyze";
|
||||||
|
// "http://localhost:5001/analyze";
|
||||||
|
try {
|
||||||
const analyzeResponse = await fetch(presidioAnalyzerUrl, {
|
const analyzeResponse = await fetch(presidioAnalyzerUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -115,12 +194,15 @@ export async function POST(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log("📊 Statut Analyzer:", analyzeResponse.status);
|
console.log("📊 Statut Analyzer:", analyzeResponse.status);
|
||||||
|
console.log("📊 Headers Analyzer:", analyzeResponse.headers);
|
||||||
|
|
||||||
if (!analyzeResponse.ok) {
|
if (!analyzeResponse.ok) {
|
||||||
const errorBody = await analyzeResponse.text();
|
const errorBody = await analyzeResponse.text();
|
||||||
return NextResponse.json(
|
console.error("❌ Erreur Analyzer:", errorBody);
|
||||||
{ error: `Erreur Analyzer: ${errorBody}` },
|
console.error("❌ URL utilisée:", presidioAnalyzerUrl);
|
||||||
{ status: 500 }
|
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();
|
const analyzerResults = await analyzeResponse.json();
|
||||||
@@ -130,17 +212,16 @@ export async function POST(req: NextRequest) {
|
|||||||
// CONFIGURATION PRESIDIO ANONYMIZER
|
// 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 = {
|
const anonymizerConfig = {
|
||||||
text: fileContent,
|
text: fileContent,
|
||||||
analyzer_results: analyzerResults,
|
analyzer_results: analyzerResults,
|
||||||
|
mode: category, // Ajouter le mode pour l'anonymizer aussi
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🔍 Appel à Presidio Anonymizer...");
|
console.log("🔍 Appel à Presidio Anonymizer...");
|
||||||
const presidioAnonymizerUrl =
|
const presidioAnonymizerUrl =
|
||||||
"http://anonymizer.151.80.20.211.sslip.io/anonymize";
|
"http://analyzer.151.80.20.211.sslip.io/anonymize";
|
||||||
|
// "http://localhost:5001/anonymize";
|
||||||
|
|
||||||
const anonymizeResponse = await fetch(presidioAnonymizerUrl, {
|
const anonymizeResponse = await fetch(presidioAnonymizerUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -154,21 +235,49 @@ export async function POST(req: NextRequest) {
|
|||||||
console.log("📊 Statut Anonymizer:", anonymizeResponse.status);
|
console.log("📊 Statut Anonymizer:", anonymizeResponse.status);
|
||||||
if (!anonymizeResponse.ok) {
|
if (!anonymizeResponse.ok) {
|
||||||
const errorBody = await anonymizeResponse.text();
|
const errorBody = await anonymizeResponse.text();
|
||||||
return NextResponse.json(
|
console.error("❌ Erreur Anonymizer:", errorBody);
|
||||||
{ error: `Erreur Anonymizer: ${errorBody}` },
|
// Fallback: retourner juste le texte si Presidio n'est pas disponible
|
||||||
{ status: 500 }
|
return NextResponse.json({ text: fileContent }, { status: 200 });
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const anonymizerResult = await anonymizeResponse.json();
|
const anonymizerResult = await anonymizeResponse.json();
|
||||||
console.log("✅ Anonymisation réussie.");
|
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 = {
|
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,
|
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 });
|
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) {
|
} catch (err: unknown) {
|
||||||
console.error("❌ Erreur générale:", err);
|
console.error("❌ Erreur générale:", err);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
300
app/components/AnonymizationInterface.tsx
Normal file
300
app/components/AnonymizationInterface.tsx
Normal 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'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'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 "Toutes les occurrences" 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;
|
||||||
|
};
|
||||||
154
app/components/AnonymizationLogic.tsx
Normal file
154
app/components/AnonymizationLogic.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
387
app/components/ContextMenu.tsx
Normal file
387
app/components/ContextMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
app/components/DocumentPreview.tsx
Normal file
48
app/components/DocumentPreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
34
app/components/DownloadActions.tsx
Normal file
34
app/components/DownloadActions.tsx
Normal 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 };
|
||||||
|
};
|
||||||
135
app/components/EntityMappingTable.tsx
Normal file
135
app/components/EntityMappingTable.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
};
|
||||||
110
app/components/FileHandler.tsx
Normal file
110
app/components/FileHandler.tsx
Normal 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 };
|
||||||
|
};
|
||||||
778
app/components/FileUploadComponent.tsx
Normal file
778
app/components/FileUploadComponent.tsx
Normal 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'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 </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>
|
||||||
|
);
|
||||||
|
};
|
||||||
27
app/components/InstructionsPanel.tsx
Normal file
27
app/components/InstructionsPanel.tsx
Normal 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'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 "Toutes les occurrences" pour appliquer à
|
||||||
|
tous les mots similaires
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
125
app/components/InteractiveTextEditor.tsx
Normal file
125
app/components/InteractiveTextEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
91
app/components/ProgressBar.tsx
Normal file
91
app/components/ProgressBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
236
app/components/ResultPreviewComponent.tsx
Normal file
236
app/components/ResultPreviewComponent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
50
app/components/SampleTextComponent.tsx
Normal file
50
app/components/SampleTextComponent.tsx
Normal 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'exemple
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
31
app/components/SupportedDataTypes.tsx
Normal file
31
app/components/SupportedDataTypes.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
};
|
||||||
121
app/components/TextDisplay.tsx
Normal file
121
app/components/TextDisplay.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
61
app/components/hooks/useColorMapping.ts
Normal file
61
app/components/hooks/useColorMapping.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
325
app/components/hooks/useContextMenu.ts
Normal file
325
app/components/hooks/useContextMenu.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
93
app/components/hooks/useTextParsing.ts
Normal file
93
app/components/hooks/useTextParsing.ts
Normal 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 };
|
||||||
|
};
|
||||||
83
app/config/colorPalette.ts
Normal file
83
app/config/colorPalette.ts
Normal 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);
|
||||||
65
app/config/entityLabels.ts
Normal file
65
app/config/entityLabels.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
137
app/hooks/useEntityMappings.ts
Normal file
137
app/hooks/useEntityMappings.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -25,7 +25,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-white`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
633
app/page.tsx
633
app/page.tsx
@@ -1,536 +1,171 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import Image from "next/image";
|
import { FileUploadComponent } from "./components/FileUploadComponent";
|
||||||
import { Button } from "@/components/ui/button";
|
import { EntityMappingTable } from "./components/EntityMappingTable";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { ProgressBar } from "./components/ProgressBar";
|
||||||
import { Input } from "@/components/ui/input";
|
import { useFileHandler } from "./components/FileHandler";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { useAnonymization } from "./components/AnonymizationLogic";
|
||||||
import {
|
import { useDownloadActions } from "./components/DownloadActions";
|
||||||
Upload,
|
|
||||||
FileText,
|
|
||||||
ShieldCheck,
|
|
||||||
Download,
|
|
||||||
Trash2,
|
|
||||||
AlertCircle,
|
|
||||||
X,
|
|
||||||
Zap,
|
|
||||||
Lock,
|
|
||||||
Shield,
|
|
||||||
Clock,
|
|
||||||
Eye,
|
|
||||||
TestTube,
|
|
||||||
} from "lucide-react";
|
|
||||||
import PresidioModal from "./components/PresidioModal";
|
|
||||||
|
|
||||||
export type PageObject = {
|
import { EntityMapping } from "./config/entityLabels"; // Importer l'interface unifiée
|
||||||
pageNumber: number;
|
|
||||||
htmlContent: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ProcessedFile {
|
// Supprimer l'interface locale EntityMapping (lignes 12-18)
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
status: "processing" | "completed" | "error";
|
|
||||||
timestamp: Date;
|
|
||||||
originalSize?: string;
|
|
||||||
processedSize?: string;
|
|
||||||
piiCount?: number;
|
|
||||||
errorMessage?: string;
|
|
||||||
processedBlob?: Blob;
|
|
||||||
anonymizedText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [sourceText, setSourceText] = useState("");
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [outputText, setOutputText] = useState("");
|
||||||
const [progress, setProgress] = useState(0);
|
const [anonymizedText, setAnonymizedText] = useState(""); // Nouveau state pour le texte anonymisé de Presidio
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||||
const [history, setHistory] = useState<ProcessedFile[]>([]);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showPresidioModal, setShowPresidioModal] = useState(false);
|
const [isLoadingFile, setIsLoadingFile] = useState(false);
|
||||||
const [anonymizedResult, setAnonymizedResult] = useState<{
|
const [entityMappings, setEntityMappings] = useState<EntityMapping[]>([]);
|
||||||
text: string;
|
const [isExampleLoaded, setIsExampleLoaded] = useState(false);
|
||||||
piiCount: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const progressSteps = ["Téléversement", "Prévisualisation", "Anonymisation"];
|
||||||
if (e.target.files?.length) {
|
|
||||||
setFile(e.target.files[0]);
|
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);
|
setError(null);
|
||||||
}
|
setIsLoadingFile(false);
|
||||||
|
setEntityMappings([]);
|
||||||
|
setIsExampleLoaded(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
// Fonction pour mettre à jour les mappings depuis l'éditeur interactif
|
||||||
e.preventDefault();
|
const handleMappingsUpdate = useCallback(
|
||||||
setIsDragOver(false);
|
(updatedMappings: EntityMapping[]) => {
|
||||||
if (e.dataTransfer.files?.length) {
|
setEntityMappings(updatedMappings);
|
||||||
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),
|
|
||||||
},
|
},
|
||||||
...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;
|
// Hooks personnalisés pour la logique métier
|
||||||
|
const { handleFileChange } = useFileHandler({
|
||||||
const processedBlob = new Blob([anonymizedText], {
|
setUploadedFile,
|
||||||
type: "text/plain;charset=utf-8",
|
setSourceText,
|
||||||
|
setError,
|
||||||
|
setIsLoadingFile,
|
||||||
});
|
});
|
||||||
|
|
||||||
setHistory((prev) =>
|
const { anonymizeData, isProcessing } = useAnonymization({
|
||||||
prev.map((item) =>
|
setOutputText,
|
||||||
item.id === fileId
|
setError,
|
||||||
? {
|
setEntityMappings,
|
||||||
...item,
|
setAnonymizedText, // Passer la fonction pour stocker le texte anonymisé
|
||||||
status: "completed",
|
});
|
||||||
processedSize: formatFileSize(processedBlob.size),
|
|
||||||
piiCount,
|
const { copyToClipboard, downloadText } = useDownloadActions({
|
||||||
processedBlob,
|
outputText,
|
||||||
anonymizedText,
|
entityMappings,
|
||||||
}
|
anonymizedText, // Passer le texte anonymisé de Presidio
|
||||||
: item
|
});
|
||||||
)
|
|
||||||
);
|
// Fonction wrapper pour appeler anonymizeData avec les bonnes données
|
||||||
setProgress(100);
|
const handleAnonymize = (category?: string) => {
|
||||||
} catch (err) {
|
anonymizeData({ file: uploadedFile, text: sourceText, category });
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen bg-[#061717] flex flex-col md:flex-row overflow-hidden">
|
<div className="min-h-screen w-full 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]">
|
{/* Main Content */}
|
||||||
<div className="p-4 border-b-4 border-white bg-[#F7AB6E] shadow-[0_4px_0_0_black] flex items-center justify-between">
|
<div className="max-w-6xl mx-auto px-2 sm:px-4 py-4 sm:py-8 space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
{/* Progress Bar */}
|
||||||
<Image
|
<ProgressBar currentStep={getCurrentStep()} steps={progressSteps} />
|
||||||
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>
|
|
||||||
|
|
||||||
<main className="flex-1 flex flex-col items-center justify-center p-4 md:p-6 bg-[#061717] overflow-y-auto">
|
{/* Upload Section */}
|
||||||
<div className="w-full max-w-xl flex flex-col ">
|
<div className="bg-white rounded-2xl border border-gray-50 overflow-hidden">
|
||||||
<header className="text-center ">
|
<div className="p-1 sm:p-3">
|
||||||
<div className="inline-block p-2 bg-[#F7AB6E] border-1 border-white shadow-[6px_6px_0_0_black] mb-3">
|
<FileUploadComponent
|
||||||
<Lock className="h-8 w-8 text-white" />
|
uploadedFile={uploadedFile}
|
||||||
</div>
|
handleFileChange={handleFileChange}
|
||||||
<h1 className="text-3xl md:text-4xl font-black text-white uppercase tracking-tighter mb-1">
|
sourceText={sourceText}
|
||||||
LeCercle.IA
|
setSourceText={setSourceText}
|
||||||
</h1>
|
setUploadedFile={setUploadedFile}
|
||||||
<p className="text-base md:text-lg font-bold text-white/90 uppercase tracking-wider">
|
onAnonymize={handleAnonymize}
|
||||||
Anonymisation • RGPD • Sécurisé
|
isProcessing={isProcessing}
|
||||||
</p>
|
canAnonymize={
|
||||||
</header>
|
uploadedFile !== null ||
|
||||||
<Card className="flex-grow my-4 bg-[#061717] border-4 border-white shadow-[10px_10px_0_0_black] flex flex-col">
|
Boolean(sourceText && sourceText.trim())
|
||||||
<CardContent className="p-4 md:p-6 space-y-4 flex-grow flex flex-col">
|
}
|
||||||
<div
|
isLoadingFile={isLoadingFile}
|
||||||
onDrop={handleDrop}
|
onRestart={handleRestart}
|
||||||
onDragOver={handleDragOver}
|
outputText={outputText}
|
||||||
onDragLeave={handleDragLeave}
|
copyToClipboard={copyToClipboard}
|
||||||
className={`relative border-4 border-dashed p-7 text-center transition-all duration-200 ${
|
downloadText={downloadText}
|
||||||
isDragOver
|
isExampleLoaded={isExampleLoaded}
|
||||||
? "border-[#F7AB6E] bg-white/5"
|
setIsExampleLoaded={setIsExampleLoaded}
|
||||||
: file
|
entityMappings={entityMappings}
|
||||||
? "border-[#F7AB6E] bg-white/5 border-solid"
|
onMappingsUpdate={handleMappingsUpdate}
|
||||||
: "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}%` }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{error && (
|
||||||
<div className="flex items-start gap-3 p-3 bg-[#F7AB6E] border-2 border-white shadow-[4px_4px_0_0_black]">
|
<div className="bg-red-50 border border-red-200 rounded-xl p-3 sm:p-4 mx-2 sm:mx-0">
|
||||||
<AlertCircle className="h-5 w-5 text-white flex-shrink-0 mt-0.5" />
|
<div className="flex items-start space-x-3">
|
||||||
<p className="text-sm font-bold text-white uppercase">
|
<svg
|
||||||
{error}
|
className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5"
|
||||||
</p>
|
fill="none"
|
||||||
</div>
|
stroke="currentColor"
|
||||||
)}
|
viewBox="0 0 24 24"
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
<path
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-4 border-white border-t-transparent" />
|
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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
line
|
||||||
<Zap className="h-5 w-5 mr-2" />
|
|
||||||
Anonymiser
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
</div>
|
||||||
{showPresidioModal && (
|
|
||||||
<PresidioModal
|
|
||||||
anonymizedText={anonymizedResult?.text || null}
|
|
||||||
piiCount={anonymizedResult?.piiCount || 0}
|
|
||||||
onClose={() => setShowPresidioModal(false)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
17
app/utils/entityBoundary.ts
Normal file
17
app/utils/entityBoundary.ts
Normal 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;
|
||||||
|
};
|
||||||
96
app/utils/generateAnonymizedText.ts
Normal file
96
app/utils/generateAnonymizedText.ts
Normal 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;
|
||||||
|
};
|
||||||
51
app/utils/highlightEntities.tsx
Normal file
51
app/utils/highlightEntities.tsx
Normal 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
116
components/ui/table.tsx
Normal 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,
|
||||||
|
};
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -11,9 +11,7 @@ const config: Config = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [typography],
|
||||||
typography, // C'est ici qu'on active le plugin pour la classe 'prose'
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
Reference in New Issue
Block a user