interface interactive
This commit is contained in:
@@ -24,7 +24,8 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > 50 * 1024 * 1024) { // 50MB
|
if (file.size > 50 * 1024 * 1024) {
|
||||||
|
// 50MB
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Le fichier est trop volumineux (max 50MB)." },
|
{ error: "Le fichier est trop volumineux (max 50MB)." },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
@@ -35,7 +36,7 @@ export async function POST(req: NextRequest) {
|
|||||||
name: file.name,
|
name: file.name,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
size: `${(file.size / 1024 / 1024).toFixed(2)} MB`,
|
size: `${(file.size / 1024 / 1024).toFixed(2)} MB`,
|
||||||
lastModified: new Date(file.lastModified).toISOString()
|
lastModified: new Date(file.lastModified).toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let fileContent = "";
|
let fileContent = "";
|
||||||
@@ -63,37 +64,38 @@ export async function POST(req: NextRequest) {
|
|||||||
pages: data.numpages,
|
pages: data.numpages,
|
||||||
metadata: data.metadata,
|
metadata: data.metadata,
|
||||||
info: data.info,
|
info: data.info,
|
||||||
extractedLength: fileContent.length
|
extractedLength: fileContent.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Détecter si c'est un PDF scanné
|
// Détecter si c'est un PDF scanné
|
||||||
const isScanned = data.info?.Creator?.includes('RICOH') ||
|
const isScanned =
|
||||||
data.info?.Creator?.includes('Canon') ||
|
data.info?.Creator?.includes("RICOH") ||
|
||||||
data.info?.Creator?.includes('HP') ||
|
data.info?.Creator?.includes("Canon") ||
|
||||||
data.info?.Producer?.includes('Scanner') ||
|
data.info?.Creator?.includes("HP") ||
|
||||||
|
data.info?.Producer?.includes("Scanner") ||
|
||||||
(data.numpages > 0 && fileContent.length < 50);
|
(data.numpages > 0 && fileContent.length < 50);
|
||||||
|
|
||||||
const errorMessage = isScanned
|
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`
|
? `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}`;
|
: `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(
|
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||||
{ error: errorMessage },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (pdfError) {
|
} catch (pdfError) {
|
||||||
console.error("❌ Erreur PDF détaillée:", {
|
console.error("❌ Erreur PDF détaillée:", {
|
||||||
message: pdfError instanceof Error ? pdfError.message : "Erreur inconnue",
|
message:
|
||||||
|
pdfError instanceof Error ? pdfError.message : "Erreur inconnue",
|
||||||
stack: pdfError instanceof Error ? pdfError.stack : undefined,
|
stack: pdfError instanceof Error ? pdfError.stack : undefined,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
fileType: file.type
|
fileType: file.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: `Impossible de traiter ce PDF (${file.name}). Erreur: ${pdfError instanceof Error ? pdfError.message : "Erreur inconnue"}. Vérifiez que le PDF n'est pas protégé, corrompu ou scanné.`,
|
error: `Impossible de traiter ce PDF (${file.name}). Erreur: ${
|
||||||
|
pdfError instanceof Error ? pdfError.message : "Erreur inconnue"
|
||||||
|
}. Vérifiez que le PDF n'est pas protégé, corrompu ou scanné.`,
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
@@ -172,7 +174,7 @@ export async function POST(req: NextRequest) {
|
|||||||
console.log("🔍 Appel à Presidio Analyzer...");
|
console.log("🔍 Appel à Presidio Analyzer...");
|
||||||
|
|
||||||
// ✅ Définir l'URL AVANT de l'utiliser
|
// ✅ Définir l'URL AVANT de l'utiliser
|
||||||
const presidioAnalyzerUrl = "http://analyzer.151.80.20.211.sslip.io/analyze";
|
const presidioAnalyzerUrl = "http://localhost:5001/analyze";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const analyzeResponse = await fetch(presidioAnalyzerUrl, {
|
const analyzeResponse = await fetch(presidioAnalyzerUrl, {
|
||||||
@@ -209,8 +211,7 @@ export async function POST(req: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
console.log("🔍 Appel à Presidio Anonymizer...");
|
console.log("🔍 Appel à Presidio Anonymizer...");
|
||||||
const presidioAnonymizerUrl =
|
const presidioAnonymizerUrl = "http://localhost:5001/anonymize";
|
||||||
"http://anonymizer.151.80.20.211.sslip.io/anonymize";
|
|
||||||
|
|
||||||
const anonymizeResponse = await fetch(presidioAnonymizerUrl, {
|
const anonymizeResponse = await fetch(presidioAnonymizerUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -232,11 +233,64 @@ export async function POST(req: NextRequest) {
|
|||||||
const anonymizerResult = await anonymizeResponse.json();
|
const anonymizerResult = await anonymizeResponse.json();
|
||||||
console.log("✅ Anonymisation réussie.");
|
console.log("✅ Anonymisation réussie.");
|
||||||
|
|
||||||
|
// 🔧 NOUVELLE FONCTION SIMPLIFIÉE pour extraire les valeurs de remplacement
|
||||||
|
// Ajouter cette interface au début du fichier
|
||||||
|
interface AnalyzerResult {
|
||||||
|
entity_type: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puis modifier la fonction
|
||||||
|
const extractReplacementValues = (originalText: string, anonymizedText: string, analyzerResults: AnalyzerResult[]) => {
|
||||||
|
const replacementMap: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Créer une copie du texte anonymisé pour le traitement
|
||||||
|
const workingText = anonymizedText; // ✅ Changé de 'let' à 'const'
|
||||||
|
// Supprimer workingOriginal car elle n'est jamais utilisée
|
||||||
|
|
||||||
|
// Trier les résultats par position (du plus grand au plus petit pour éviter les décalages)
|
||||||
|
const sortedResults = [...analyzerResults].sort((a, b) => b.start - a.start);
|
||||||
|
|
||||||
|
for (const result of sortedResults) {
|
||||||
|
const originalValue = originalText.substring(result.start, result.end);
|
||||||
|
|
||||||
|
// Extraire les parties avant et après l'entité dans le texte original
|
||||||
|
const beforeOriginal = originalText.substring(0, result.start);
|
||||||
|
const afterOriginal = originalText.substring(result.end);
|
||||||
|
|
||||||
|
// Trouver les mêmes parties dans le texte anonymisé
|
||||||
|
const beforeIndex = workingText.indexOf(beforeOriginal);
|
||||||
|
const afterIndex = workingText.lastIndexOf(afterOriginal);
|
||||||
|
|
||||||
|
if (beforeIndex !== -1 && afterIndex !== -1) {
|
||||||
|
// Extraire la valeur de remplacement entre ces deux parties
|
||||||
|
const startPos = beforeIndex + beforeOriginal.length;
|
||||||
|
const endPos = afterIndex;
|
||||||
|
const replacementValue = workingText.substring(startPos, endPos);
|
||||||
|
|
||||||
|
// Vérifier que c'est bien un remplacement (commence par [ et finit par ])
|
||||||
|
if (replacementValue.startsWith('[') && replacementValue.endsWith(']')) {
|
||||||
|
replacementMap[originalValue] = replacementValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return replacementMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replacementValues = extractReplacementValues(fileContent, anonymizerResult.anonymized_text, analyzerResults);
|
||||||
|
|
||||||
|
// 🔍 AJOUT D'UN LOG POUR DÉBOGUER
|
||||||
|
console.log("🔧 Valeurs de remplacement extraites:", replacementValues);
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
text: fileContent,
|
text: fileContent,
|
||||||
anonymizedText: anonymizerResult.text,
|
anonymizedText: anonymizerResult.anonymized_text,
|
||||||
piiCount: analyzerResults.length,
|
piiCount: analyzerResults.length,
|
||||||
analyzerResults: analyzerResults,
|
analyzerResults: analyzerResults,
|
||||||
|
replacementValues: replacementValues // Utiliser les nouvelles valeurs
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(result, { status: 200 });
|
return NextResponse.json(result, { status: 200 });
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const AnonymizationInterface = ({
|
|||||||
|
|
||||||
const anonymizedTypes = new Set<string>();
|
const anonymizedTypes = new Set<string>();
|
||||||
|
|
||||||
if (outputText.includes("<PERSON>")) {
|
if (outputText.includes("<PERSONNE>")) {
|
||||||
anonymizedTypes.add("Prénoms");
|
anonymizedTypes.add("Prénoms");
|
||||||
anonymizedTypes.add("Noms de famille");
|
anonymizedTypes.add("Noms de famille");
|
||||||
anonymizedTypes.add("Noms complets");
|
anonymizedTypes.add("Noms complets");
|
||||||
|
|||||||
@@ -1,211 +1,140 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { patterns } from "@/app/utils/highlightEntities";
|
import {
|
||||||
|
PresidioAnalyzerResult,
|
||||||
|
EntityMapping,
|
||||||
|
} from "@/app/config/entityLabels";
|
||||||
|
|
||||||
interface EntityMapping {
|
// Interface pour la réponse de l'API process-document
|
||||||
originalValue: string;
|
|
||||||
anonymizedValue: string;
|
|
||||||
entityType: string;
|
|
||||||
startIndex: number;
|
|
||||||
endIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// L'API retourne des objets avec snake_case
|
|
||||||
interface PresidioAnalyzerResult {
|
|
||||||
entity_type: string;
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// La réponse de l'API utilise camelCase pour les clés principales
|
|
||||||
interface ProcessDocumentResponse {
|
interface ProcessDocumentResponse {
|
||||||
text?: string; // Texte original en cas de fallback
|
text?: string;
|
||||||
anonymizedText?: string;
|
anonymizedText?: string;
|
||||||
analyzerResults?: PresidioAnalyzerResult[];
|
analyzerResults?: PresidioAnalyzerResult[];
|
||||||
|
replacementValues?: Record<string, string>; // Nouvelle propriété
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Props du hook
|
||||||
interface AnonymizationLogicProps {
|
interface AnonymizationLogicProps {
|
||||||
sourceText: string;
|
|
||||||
fileContent: string;
|
|
||||||
uploadedFile: File | null;
|
|
||||||
setOutputText: (text: string) => void;
|
setOutputText: (text: string) => void;
|
||||||
setError: (error: string | null) => void;
|
setError: (error: string | null) => void;
|
||||||
setEntityMappings: (mappings: EntityMapping[]) => void;
|
setEntityMappings: (mappings: EntityMapping[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOUVEAU: Définir les types pour le paramètre de anonymizeData
|
||||||
|
interface AnonymizeDataParams {
|
||||||
|
file?: File | null;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook pour la logique d'anonymisation.
|
||||||
|
* Gère l'appel API et la création du tableau de mapping de manière simple et directe.
|
||||||
|
*/
|
||||||
export const useAnonymization = ({
|
export const useAnonymization = ({
|
||||||
sourceText,
|
|
||||||
fileContent,
|
|
||||||
uploadedFile,
|
|
||||||
setOutputText,
|
setOutputText,
|
||||||
setError,
|
setError,
|
||||||
setEntityMappings,
|
setEntityMappings,
|
||||||
}: AnonymizationLogicProps) => {
|
}: AnonymizationLogicProps) => {
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
const anonymizeData = async () => {
|
const anonymizeData = async ({ file, text }: AnonymizeDataParams) => {
|
||||||
const textToProcess = sourceText || fileContent || "";
|
|
||||||
|
|
||||||
if (!textToProcess.trim()) {
|
|
||||||
setError(
|
|
||||||
"Veuillez saisir du texte à anonymiser ou télécharger un fichier"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setOutputText("");
|
|
||||||
setEntityMappings([]);
|
setEntityMappings([]);
|
||||||
|
setOutputText("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// ÉTAPE 1: Construire le FormData ici pour garantir le bon format
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
if (uploadedFile) {
|
if (file) {
|
||||||
formData.append("file", uploadedFile);
|
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 {
|
} else {
|
||||||
const textBlob = new Blob([textToProcess], { type: "text/plain" });
|
throw new Error("Aucune donnée à anonymiser (ni fichier, ni texte).");
|
||||||
const textFile = new File([textBlob], "input.txt", {
|
|
||||||
type: "text/plain",
|
|
||||||
});
|
|
||||||
formData.append("file", textFile);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch("/api/process-document", {
|
const response = await fetch("/api/process-document", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData, // Le Content-Type sera automatiquement défini par le navigateur
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorMessage = `Erreur HTTP: ${response.status}`;
|
|
||||||
try {
|
|
||||||
const errorData = await response.json();
|
|
||||||
if (errorData.error) errorMessage = errorData.error;
|
|
||||||
} catch {
|
|
||||||
/* Ignore */
|
|
||||||
}
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: ProcessDocumentResponse = await response.json();
|
const data: ProcessDocumentResponse = await response.json();
|
||||||
|
|
||||||
if (data.error) {
|
if (!response.ok || data.error) {
|
||||||
throw new Error(data.error);
|
throw new Error(
|
||||||
}
|
data.error || "Erreur lors de la communication avec l'API."
|
||||||
|
|
||||||
// Utiliser camelCase pour les propriétés de la réponse principale
|
|
||||||
if (data.anonymizedText && data.analyzerResults) {
|
|
||||||
setOutputText(data.anonymizedText);
|
|
||||||
|
|
||||||
const entityTypeMap = new Map<string, string>();
|
|
||||||
patterns.forEach((p) => {
|
|
||||||
const match = p.regex.toString().match(/<([A-Z_]+)>/);
|
|
||||||
if (match && match[1]) {
|
|
||||||
entityTypeMap.set(match[1], p.label);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1. Compter les occurrences de chaque tag d'entité dans le texte anonymisé
|
|
||||||
const tagCounts = new Map<string, number>();
|
|
||||||
data.analyzerResults.forEach((result) => {
|
|
||||||
const tag = `<${result.entity_type}>`;
|
|
||||||
if (!tagCounts.has(result.entity_type)) {
|
|
||||||
const count = (
|
|
||||||
data.anonymizedText?.match(new RegExp(tag, "g")) || []
|
|
||||||
).length;
|
|
||||||
tagCounts.set(result.entity_type, count);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Créer un mapping basé sur l'ordre d'apparition dans le texte anonymisé
|
|
||||||
const uniqueMappings: EntityMapping[] = [];
|
|
||||||
|
|
||||||
// Vérifier que les données nécessaires sont disponibles
|
|
||||||
if (!data.analyzerResults || !data.anonymizedText) {
|
|
||||||
setEntityMappings([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entityCounters = new Map<string, number>();
|
|
||||||
|
|
||||||
// Parcourir le texte anonymisé pour trouver les tags dans l'ordre
|
|
||||||
const anonymizedText = data.anonymizedText;
|
|
||||||
const allMatches: Array<{
|
|
||||||
match: RegExpMatchArray;
|
|
||||||
entityType: string;
|
|
||||||
position: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
// Trouver tous les tags dans le texte anonymisé
|
|
||||||
patterns.forEach(pattern => {
|
|
||||||
const entityTypeKey = pattern.regex.toString().match(/<([A-Z_]+)>/)?.[1];
|
|
||||||
if (entityTypeKey) {
|
|
||||||
const regex = new RegExp(pattern.regex.source, 'g');
|
|
||||||
let match;
|
|
||||||
while ((match = regex.exec(anonymizedText)) !== null) {
|
|
||||||
allMatches.push({
|
|
||||||
match,
|
|
||||||
entityType: entityTypeKey,
|
|
||||||
position: match.index
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trier par position dans le texte anonymisé
|
|
||||||
allMatches.sort((a, b) => a.position - b.position);
|
|
||||||
|
|
||||||
// Créer les mappings dans l'ordre d'apparition
|
|
||||||
const seen = new Set<string>();
|
|
||||||
allMatches.forEach(({ entityType }) => {
|
|
||||||
const frenchLabel = entityTypeMap.get(entityType) || entityType;
|
|
||||||
const currentCount = (entityCounters.get(entityType) || 0) + 1;
|
|
||||||
entityCounters.set(entityType, currentCount);
|
|
||||||
|
|
||||||
// Trouver l'entité correspondante dans les résultats d'analyse
|
|
||||||
const correspondingResult = data.analyzerResults
|
|
||||||
?.filter(result => result.entity_type === entityType)
|
|
||||||
.find(result => {
|
|
||||||
const originalValue = textToProcess.substring(result.start, result.end);
|
|
||||||
const uniqueKey = `${frenchLabel}|${originalValue}|${currentCount}`;
|
|
||||||
return !seen.has(uniqueKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (correspondingResult) {
|
|
||||||
const originalValue = textToProcess.substring(
|
|
||||||
correspondingResult.start,
|
|
||||||
correspondingResult.end
|
|
||||||
);
|
);
|
||||||
const uniqueKey = `${frenchLabel}|${originalValue}|${currentCount}`;
|
}
|
||||||
|
|
||||||
if (!seen.has(uniqueKey)) {
|
const originalText = data.text || "";
|
||||||
uniqueMappings.push({
|
const presidioResults = data.analyzerResults || [];
|
||||||
entityType: frenchLabel,
|
const replacementValues = data.replacementValues || {}; // Récupérer les valeurs de remplacement
|
||||||
originalValue: originalValue,
|
|
||||||
anonymizedValue: `${frenchLabel} [${currentCount}]`,
|
// 🔍 AJOUT DES CONSOLE.LOG POUR DÉBOGUER
|
||||||
startIndex: correspondingResult.start,
|
console.log("📊 Données reçues de Presidio:", {
|
||||||
endIndex: correspondingResult.end,
|
originalTextLength: originalText.length,
|
||||||
});
|
presidioResultsCount: presidioResults.length,
|
||||||
seen.add(uniqueKey);
|
presidioResults: presidioResults,
|
||||||
}
|
replacementValues: replacementValues,
|
||||||
}
|
replacementValuesKeys: Object.keys(replacementValues),
|
||||||
|
replacementValuesEntries: Object.entries(replacementValues)
|
||||||
});
|
});
|
||||||
|
|
||||||
setEntityMappings(uniqueMappings);
|
// ÉTAPE 2 : Passer le texte ORIGINAL à l'état de sortie.
|
||||||
} else if (data.text) {
|
setOutputText(originalText);
|
||||||
setOutputText(data.text);
|
|
||||||
setError("Presidio temporairement indisponible. Texte non anonymisé.");
|
// É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] || `[${entity_type}]`,
|
||||||
|
displayName: replacementValues[detectedText] || `[${entity_type}]`, // Ajouter cette ligne
|
||||||
|
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) {
|
} catch (error) {
|
||||||
|
console.error("Erreur dans useAnonymization:", error);
|
||||||
setError(
|
setError(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: "Erreur lors de l'anonymisation avec Presidio"
|
: "Une erreur inconnue est survenue."
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { anonymizeData, isProcessing };
|
return {
|
||||||
|
anonymizeData,
|
||||||
|
isProcessing,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
352
app/components/ContextMenu.tsx
Normal file
352
app/components/ContextMenu.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { Trash2, Check, RotateCcw } from "lucide-react";
|
||||||
|
import { COLOR_PALETTE, type ColorOption } from "../config/colorPalette";
|
||||||
|
// import { EntityMapping } from "../config/entityLabels"; // SUPPRIMER cette ligne
|
||||||
|
|
||||||
|
interface ContextMenuProps {
|
||||||
|
contextMenu: {
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
selectedText: string;
|
||||||
|
wordIndices: number[];
|
||||||
|
};
|
||||||
|
existingLabels: string[];
|
||||||
|
// entityMappings: EntityMapping[]; // SUPPRIMER cette ligne
|
||||||
|
onApplyLabel: (
|
||||||
|
displayName: string,
|
||||||
|
applyToAll?: boolean
|
||||||
|
) => void;
|
||||||
|
onApplyColor: (
|
||||||
|
color: string,
|
||||||
|
colorName: string,
|
||||||
|
applyToAll?: boolean
|
||||||
|
) => void;
|
||||||
|
onRemoveLabel: (applyToAll?: boolean) => void;
|
||||||
|
getCurrentColor: (selectedText: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorOptions: ColorOption[] = COLOR_PALETTE;
|
||||||
|
|
||||||
|
export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||||
|
contextMenu,
|
||||||
|
existingLabels,
|
||||||
|
// entityMappings, // SUPPRIMER cette ligne
|
||||||
|
onApplyLabel,
|
||||||
|
onApplyColor,
|
||||||
|
onRemoveLabel,
|
||||||
|
getCurrentColor,
|
||||||
|
}) => {
|
||||||
|
const [customLabel, setCustomLabel] = useState("");
|
||||||
|
const [showNewLabelInput, setShowNewLabelInput] = useState(false);
|
||||||
|
const [showColorPalette, setShowColorPalette] = useState(false);
|
||||||
|
const [applyToAll, setApplyToAll] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Fonction corrigée pour le bouton +
|
||||||
|
const handleNewLabelClick = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
console.log("Bouton + cliqué - Ouverture du champ de saisie");
|
||||||
|
setShowNewLabelInput(true);
|
||||||
|
setShowColorPalette(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyCustomLabel = (e?: React.MouseEvent) => {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
if (customLabel.trim()) {
|
||||||
|
console.log(
|
||||||
|
"Application du label personnalisé:",
|
||||||
|
customLabel.trim(),
|
||||||
|
"À toutes les occurrences:",
|
||||||
|
applyToAll
|
||||||
|
);
|
||||||
|
onApplyLabel(customLabel.trim(), applyToAll); // CORRIGER: 2 paramètres seulement
|
||||||
|
setCustomLabel("");
|
||||||
|
setShowNewLabelInput(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modifier la fonction handleCancelNewLabel pour accepter les deux types d'événements
|
||||||
|
const handleCancelNewLabel = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
console.log("Annulation du nouveau label");
|
||||||
|
setShowNewLabelInput(false);
|
||||||
|
setCustomLabel("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour empêcher la propagation des événements
|
||||||
|
const handleMenuClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-focus sur l'input quand il apparaît
|
||||||
|
useEffect(() => {
|
||||||
|
if (showNewLabelInput && inputRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}, [showNewLabelInput]);
|
||||||
|
|
||||||
|
if (!contextMenu.visible) return null;
|
||||||
|
|
||||||
|
// Calcul du positionnement pour s'assurer que le menu reste visible
|
||||||
|
const calculatePosition = () => {
|
||||||
|
const menuWidth = Math.max(600, contextMenu.selectedText.length * 8 + 400); // Largeur dynamique basée sur le texte
|
||||||
|
const menuHeight = 60; // Hauteur fixe pour une seule ligne
|
||||||
|
const padding = 10;
|
||||||
|
|
||||||
|
let x = contextMenu.x;
|
||||||
|
let y = contextMenu.y;
|
||||||
|
|
||||||
|
// Ajuster X pour rester dans la fenêtre
|
||||||
|
if (x + menuWidth / 2 > window.innerWidth - padding) {
|
||||||
|
x = window.innerWidth - menuWidth / 2 - padding;
|
||||||
|
}
|
||||||
|
if (x - menuWidth / 2 < padding) {
|
||||||
|
x = menuWidth / 2 + padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajuster Y pour rester dans la fenêtre
|
||||||
|
if (y + menuHeight > window.innerHeight - padding) {
|
||||||
|
y = contextMenu.y - menuHeight - 20; // Afficher au-dessus
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
const position = calculatePosition();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
data-context-menu
|
||||||
|
className="fixed z-50 bg-white border border-gray-300 rounded-md"
|
||||||
|
style={{
|
||||||
|
left: position.x,
|
||||||
|
top: position.y,
|
||||||
|
transform: "translate(-50%, -10px)",
|
||||||
|
minWidth: "fit-content",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Une seule ligne avec tous les contrôles */}
|
||||||
|
<div className="flex items-center px-2 py-1 space-x-2">
|
||||||
|
{/* Texte sélectionné complet */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="text-xs text-gray-800 bg-gray-50 px-2 py-1 rounded font-mono border">
|
||||||
|
{contextMenu.selectedText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
|
||||||
|
|
||||||
|
{/* Labels existants */}
|
||||||
|
{existingLabels.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<select
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.target.value) {
|
||||||
|
const selectedDisplayName = e.target.value; // displayName
|
||||||
|
// CORRECTION: Plus besoin de chercher entity_type !
|
||||||
|
onApplyLabel(selectedDisplayName, applyToAll);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-xs border border-gray-300 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
defaultValue=""
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Chosi
|
||||||
|
</option>
|
||||||
|
{existingLabels.map((label) => (
|
||||||
|
<option key={label} value={label}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nouveau label */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
{!showNewLabelInput ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNewLabelClick}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
className="px-1 py-1 text-xs text-green-600 border border-green-300 rounded hover:bg-green-50 transition-colors flex items-center justify-center w-6 h-6 focus:outline-none focus:ring-1 focus:ring-green-500"
|
||||||
|
title="Ajouter un nouveau label"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={customLabel}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCustomLabel(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleApplyCustomLabel();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCancelNewLabel(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onFocus={(e) => e.stopPropagation()}
|
||||||
|
className="text-xs border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500 w-20"
|
||||||
|
placeholder="Label"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplyCustomLabel}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
disabled={!customLabel.trim()}
|
||||||
|
className="px-1 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
title="Appliquer le label"
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelNewLabel}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
className="px-1 py-1 text-gray-500 hover:text-gray-700 transition-colors focus:outline-none focus:ring-1 focus:ring-gray-500"
|
||||||
|
title="Annuler"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
|
||||||
|
|
||||||
|
{/* Sélecteur de couleur */}
|
||||||
|
<div className="flex-shrink-0 relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-5 h-5 rounded-full border-2 border-gray-300 cursor-pointer hover:border-gray-400 transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: getCurrentColor(contextMenu.selectedText),
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowColorPalette(!showColorPalette);
|
||||||
|
setShowNewLabelInput(false);
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
title="Couleur actuelle du label"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showColorPalette && (
|
||||||
|
<div className="flex items-center space-x-1 bg-gray-50 p-1 rounded border absolute z-10 mt-1 left-0">
|
||||||
|
{colorOptions.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.value}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onApplyColor(color.value, color.name, applyToAll);
|
||||||
|
setShowColorPalette(false);
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
className="w-4 h-4 rounded-full border-2 border-gray-300 cursor-pointer hover:border-gray-400 transition-all"
|
||||||
|
style={{ backgroundColor: color.value }}
|
||||||
|
title={color.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
|
||||||
|
|
||||||
|
{/* Bouton supprimer */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemoveLabel(applyToAll);
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
className="px-1 py-1 text-xs text-red-600 border border-red-300 rounded hover:bg-red-50 transition-colors flex items-center justify-center w-6 h-6 focus:outline-none focus:ring-1 focus:ring-red-500"
|
||||||
|
title="Supprimer le label"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
|
||||||
|
|
||||||
|
{/* Case à cocher "Toutes les occurrences" */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="applyToAll"
|
||||||
|
checked={applyToAll}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setApplyToAll(e.target.checked);
|
||||||
|
console.log(
|
||||||
|
"Appliquer à toutes les occurrences:",
|
||||||
|
e.target.checked
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
className="h-3 w-3 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="applyToAll"
|
||||||
|
className="text-xs text-gray-700 cursor-pointer select-none whitespace-nowrap"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setApplyToAll(!applyToAll);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Toutes les occurences
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
|
import { generateAnonymizedText } from "@/app/utils/generateAnonymizedText";
|
||||||
|
import { EntityMapping } from "@/app/config/entityLabels";
|
||||||
|
|
||||||
interface DownloadActionsProps {
|
interface DownloadActionsProps {
|
||||||
outputText: string;
|
outputText: string;
|
||||||
|
entityMappings?: EntityMapping[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDownloadActions = ({ outputText }: DownloadActionsProps) => {
|
export const useDownloadActions = ({
|
||||||
|
outputText,
|
||||||
|
entityMappings = [],
|
||||||
|
}: DownloadActionsProps) => {
|
||||||
const copyToClipboard = () => {
|
const copyToClipboard = () => {
|
||||||
navigator.clipboard.writeText(outputText);
|
const anonymizedText = generateAnonymizedText(outputText, entityMappings);
|
||||||
|
navigator.clipboard.writeText(anonymizedText);
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadText = () => {
|
const downloadText = () => {
|
||||||
const blob = new Blob([outputText], { type: "text/plain" });
|
const anonymizedText = generateAnonymizedText(outputText, entityMappings);
|
||||||
|
const blob = new Blob([anonymizedText], { type: "text/plain" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
|
|||||||
@@ -8,14 +8,7 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { EntityMapping } from "../config/entityLabels";
|
||||||
interface EntityMapping {
|
|
||||||
originalValue: string;
|
|
||||||
anonymizedValue: string;
|
|
||||||
entityType: string;
|
|
||||||
startIndex: number;
|
|
||||||
endIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EntityMappingTableProps {
|
interface EntityMappingTableProps {
|
||||||
mappings: EntityMapping[];
|
mappings: EntityMapping[];
|
||||||
@@ -24,65 +17,72 @@ interface EntityMappingTableProps {
|
|||||||
export const EntityMappingTable = ({ mappings }: EntityMappingTableProps) => {
|
export const EntityMappingTable = ({ mappings }: EntityMappingTableProps) => {
|
||||||
if (!mappings || mappings.length === 0) {
|
if (!mappings || mappings.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card className="mt-6">
|
<Card className="mt-8">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader>
|
||||||
<CardTitle className="text-base sm:text-lg font-medium text-[#092727]">
|
<CardTitle className="text-lg font-medium text-[#092727]">
|
||||||
Tableau de mapping des entités
|
Entités détectées
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-gray-500 text-center py-4 text-sm">
|
<p className="text-gray-500 text-center py-8">
|
||||||
Aucune entité sensible détectée dans le texte.
|
Aucune entité détectée dans le document.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Créer un compteur pour chaque type d'entité
|
||||||
|
const entityCounts: { [key: string]: number } = {};
|
||||||
|
const mappingsWithNumbers = mappings.map((mapping) => {
|
||||||
|
const entityType = mapping.entity_type;
|
||||||
|
entityCounts[entityType] = (entityCounts[entityType] || 0) + 1;
|
||||||
|
return {
|
||||||
|
...mapping,
|
||||||
|
entityNumber: entityCounts[entityType],
|
||||||
|
displayName: mapping.displayName || mapping.replacementValue || `[${entityType}]`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mt-6">
|
<Card className="mt-8">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader>
|
||||||
<CardTitle className="text-base sm:text-lg font-medium text-[#092727]">
|
<CardTitle className="text-lg font-medium text-[#092727]">
|
||||||
Tableau de mapping des entités ({mappings.length} entité
|
Entités détectées ({mappings.length})
|
||||||
{mappings.length > 1 ? "s" : ""} anonymisée
|
|
||||||
{mappings.length > 1 ? "s" : ""})
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-2 sm:px-6">
|
<CardContent>
|
||||||
{/* Version mobile : Cards empilées */}
|
{/* Version mobile : Cards empilées */}
|
||||||
<div className="block sm:hidden space-y-4">
|
<div className="sm:hidden space-y-4">
|
||||||
{mappings.map((mapping, index) => (
|
{mappingsWithNumbers.map((mapping, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
|
className="border rounded-lg p-4 bg-white shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div>
|
||||||
<span className="text-xs font-medium text-gray-600">
|
|
||||||
Type d'entité
|
|
||||||
</span>
|
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-[#f7ab6e] bg-opacity-20 text-[#092727] border-[#f7ab6e] text-xs"
|
className="bg-[#f7ab6e] bg-opacity-20 text-[#092727] border-[#f7ab6e]"
|
||||||
>
|
>
|
||||||
{mapping.entityType}
|
{mapping.displayName}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-gray-600 block mb-1">
|
<span className="text-xs font-medium text-gray-600 block mb-1">
|
||||||
Valeur originale
|
Texte détecté
|
||||||
</span>
|
</span>
|
||||||
<div className="font-mono text-xs bg-red-50 text-red-700 p-2 rounded border break-all">
|
<div className="font-mono text-xs bg-red-50 text-red-700 p-2 rounded border break-all">
|
||||||
{mapping.originalValue}
|
{mapping.text}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-gray-600 block mb-1">
|
<span className="text-xs font-medium text-gray-600 block mb-1">
|
||||||
Valeur anonymisée
|
Identifiant
|
||||||
</span>
|
</span>
|
||||||
<div className="font-mono text-xs bg-green-50 text-green-700 p-2 rounded border break-all">
|
<div className="font-mono text-xs bg-green-50 text-green-700 p-2 rounded border break-all">
|
||||||
{mapping.anonymizedValue}
|
{mapping.displayName} #{mapping.entityNumber}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,29 +100,29 @@ export const EntityMappingTable = ({ mappings }: EntityMappingTableProps) => {
|
|||||||
Type d'entité
|
Type d'entité
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="font-semibold text-[#092727] min-w-[150px]">
|
<TableHead className="font-semibold text-[#092727] min-w-[150px]">
|
||||||
Valeur originale
|
Texte détecté
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="font-semibold text-[#092727] min-w-[150px]">
|
<TableHead className="font-semibold text-[#092727] min-w-[100px]">
|
||||||
Valeur anonymisée
|
Identifiant
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{mappings.map((mapping, index) => (
|
{mappingsWithNumbers.map((mapping, index) => (
|
||||||
<TableRow key={index} className="hover:bg-gray-50">
|
<TableRow key={index} className="hover:bg-gray-50">
|
||||||
<TableCell className="py-4">
|
<TableCell className="py-4">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-[#f7ab6e] bg-opacity-20 text-[#092727] border-[#f7ab6e]"
|
className="bg-[#f7ab6e] bg-opacity-20 text-[#092727] border-[#f7ab6e]"
|
||||||
>
|
>
|
||||||
{mapping.entityType}
|
{mapping.displayName}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-sm bg-red-50 text-red-700 py-4 max-w-[200px] break-all">
|
<TableCell className="font-mono text-sm bg-red-50 text-red-700 py-4 max-w-[200px] break-all">
|
||||||
{mapping.originalValue}
|
{mapping.text}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-sm bg-green-50 text-green-700 py-4 max-w-[200px] break-all">
|
<TableCell className="font-mono text-sm bg-green-50 text-green-700 py-4">
|
||||||
{mapping.anonymizedValue}
|
{mapping.displayName} #{mapping.entityNumber}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
interface FileHandlerProps {
|
interface FileHandlerProps {
|
||||||
setUploadedFile: (file: File | null) => void;
|
setUploadedFile: (file: File | null) => void;
|
||||||
setSourceText: (text: string) => void;
|
setSourceText: (text: string) => void;
|
||||||
setFileContent: (content: string) => void;
|
|
||||||
setError: (error: string | null) => void;
|
setError: (error: string | null) => void;
|
||||||
setIsLoadingFile?: (loading: boolean) => void; // Ajouter cette propriété
|
setIsLoadingFile: (loading: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useFileHandler = ({
|
export const useFileHandler = ({
|
||||||
setUploadedFile,
|
setUploadedFile,
|
||||||
setSourceText,
|
setSourceText,
|
||||||
setFileContent,
|
|
||||||
setError,
|
setError,
|
||||||
setIsLoadingFile,
|
setIsLoadingFile,
|
||||||
}: FileHandlerProps) => {
|
}: FileHandlerProps) => {
|
||||||
@@ -22,12 +20,10 @@ export const useFileHandler = ({
|
|||||||
setUploadedFile(file);
|
setUploadedFile(file);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSourceText("");
|
setSourceText("");
|
||||||
setFileContent("");
|
|
||||||
|
|
||||||
if (file.type === "text/plain") {
|
if (file.type === "text/plain") {
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
setFileContent(text);
|
|
||||||
setSourceText(text);
|
setSourceText(text);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Erreur lors de la lecture du fichier texte");
|
setError("Erreur lors de la lecture du fichier texte");
|
||||||
@@ -63,7 +59,9 @@ export const useFileHandler = ({
|
|||||||
} catch (jsonError) {
|
} catch (jsonError) {
|
||||||
console.error("❌ Erreur parsing JSON:", jsonError);
|
console.error("❌ Erreur parsing JSON:", jsonError);
|
||||||
console.error("❌ Réponse non-JSON:", responseText);
|
console.error("❌ Réponse non-JSON:", responseText);
|
||||||
errorMessage = `Erreur ${response.status}: ${responseText || 'Réponse invalide du serveur'}`;
|
errorMessage = `Erreur ${response.status}: ${
|
||||||
|
responseText || "Réponse invalide du serveur"
|
||||||
|
}`;
|
||||||
}
|
}
|
||||||
} catch (readError) {
|
} catch (readError) {
|
||||||
console.error("❌ Impossible de lire la réponse:", readError);
|
console.error("❌ Impossible de lire la réponse:", readError);
|
||||||
@@ -86,7 +84,6 @@ export const useFileHandler = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setFileContent(extractedText);
|
|
||||||
setSourceText(extractedText);
|
setSourceText(extractedText);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur PDF:", error);
|
console.error("Erreur PDF:", error);
|
||||||
@@ -96,7 +93,6 @@ export const useFileHandler = ({
|
|||||||
: "Erreur lors de la lecture du fichier PDF"
|
: "Erreur lors de la lecture du fichier PDF"
|
||||||
);
|
);
|
||||||
setUploadedFile(null);
|
setUploadedFile(null);
|
||||||
setFileContent("");
|
|
||||||
setSourceText("");
|
setSourceText("");
|
||||||
} finally {
|
} finally {
|
||||||
// Désactiver le loader une fois terminé
|
// Désactiver le loader une fois terminé
|
||||||
|
|||||||
@@ -11,14 +11,9 @@ import { SupportedDataTypes } from "./SupportedDataTypes";
|
|||||||
import { AnonymizationInterface } from "./AnonymizationInterface";
|
import { AnonymizationInterface } from "./AnonymizationInterface";
|
||||||
import { highlightEntities } from "../utils/highlightEntities";
|
import { highlightEntities } from "../utils/highlightEntities";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { EntityMapping } from "../config/entityLabels"; // Importer l'interface unifiée
|
||||||
|
|
||||||
interface EntityMapping {
|
// Supprimer l'interface locale EntityMapping (lignes 15-21)
|
||||||
originalValue: string;
|
|
||||||
anonymizedValue: string;
|
|
||||||
entityType: string;
|
|
||||||
startIndex: number;
|
|
||||||
endIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileUploadComponentProps {
|
interface FileUploadComponentProps {
|
||||||
uploadedFile: File | null;
|
uploadedFile: File | null;
|
||||||
@@ -26,7 +21,6 @@ interface FileUploadComponentProps {
|
|||||||
sourceText: string;
|
sourceText: string;
|
||||||
setSourceText: (text: string) => void;
|
setSourceText: (text: string) => void;
|
||||||
setUploadedFile: (file: File | null) => void;
|
setUploadedFile: (file: File | null) => void;
|
||||||
setFileContent: (content: string) => void;
|
|
||||||
onAnonymize?: () => void;
|
onAnonymize?: () => void;
|
||||||
isProcessing?: boolean;
|
isProcessing?: boolean;
|
||||||
canAnonymize?: boolean;
|
canAnonymize?: boolean;
|
||||||
@@ -37,7 +31,7 @@ interface FileUploadComponentProps {
|
|||||||
downloadText?: () => void;
|
downloadText?: () => void;
|
||||||
isExampleLoaded?: boolean;
|
isExampleLoaded?: boolean;
|
||||||
setIsExampleLoaded?: (loaded: boolean) => void;
|
setIsExampleLoaded?: (loaded: boolean) => void;
|
||||||
entityMappings?: EntityMapping[]; // Ajouter cette prop
|
entityMappings?: EntityMapping[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileUploadComponent = ({
|
export const FileUploadComponent = ({
|
||||||
@@ -46,7 +40,6 @@ export const FileUploadComponent = ({
|
|||||||
sourceText,
|
sourceText,
|
||||||
setSourceText,
|
setSourceText,
|
||||||
setUploadedFile,
|
setUploadedFile,
|
||||||
setFileContent,
|
|
||||||
onAnonymize,
|
onAnonymize,
|
||||||
isProcessing = false,
|
isProcessing = false,
|
||||||
canAnonymize = false,
|
canAnonymize = false,
|
||||||
@@ -56,7 +49,7 @@ export const FileUploadComponent = ({
|
|||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
downloadText,
|
downloadText,
|
||||||
setIsExampleLoaded,
|
setIsExampleLoaded,
|
||||||
entityMappings, // Ajouter cette prop ici
|
entityMappings,
|
||||||
}: FileUploadComponentProps) => {
|
}: FileUploadComponentProps) => {
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
@@ -224,8 +217,8 @@ export const FileUploadComponent = ({
|
|||||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 sm:p-4 max-h-72 overflow-y-auto overflow-x-hidden">
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 sm:p-4 max-h-72 overflow-y-auto overflow-x-hidden">
|
||||||
<div className="text-xs sm:text-sm text-gray-700 whitespace-pre-wrap break-words overflow-wrap-anywhere leading-relaxed">
|
<div className="text-xs sm:text-sm text-gray-700 whitespace-pre-wrap break-words overflow-wrap-anywhere leading-relaxed">
|
||||||
{highlightEntities(
|
{highlightEntities(
|
||||||
outputText || "Aucun contenu à afficher",
|
sourceText || "Aucun contenu à afficher", // Utiliser sourceText au lieu de outputText
|
||||||
entityMappings
|
entityMappings || [] // Fournir un tableau vide par défaut
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,8 +286,8 @@ export const FileUploadComponent = ({
|
|||||||
{/* Boutons d'action - Responsive mobile */}
|
{/* Boutons d'action - Responsive mobile */}
|
||||||
{canAnonymize && !isLoadingFile && (
|
{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">
|
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||||
{/* Bouton Anonymiser en premier */}
|
{/* Bouton Anonymiser - seulement si pas encore anonymisé */}
|
||||||
{onAnonymize && (
|
{onAnonymize && !outputText && (
|
||||||
<button
|
<button
|
||||||
onClick={onAnonymize}
|
onClick={onAnonymize}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
@@ -326,7 +319,7 @@ export const FileUploadComponent = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bouton Recommencer */}
|
{/* Bouton Recommencer - toujours visible */}
|
||||||
{onRestart && (
|
{onRestart && (
|
||||||
<button
|
<button
|
||||||
onClick={onRestart}
|
onClick={onRestart}
|
||||||
@@ -399,7 +392,6 @@ export const FileUploadComponent = ({
|
|||||||
<span>Commencez à taper du texte, ou </span>
|
<span>Commencez à taper du texte, ou </span>
|
||||||
<SampleTextComponent
|
<SampleTextComponent
|
||||||
setSourceText={setSourceText}
|
setSourceText={setSourceText}
|
||||||
setFileContent={setFileContent}
|
|
||||||
setUploadedFile={setUploadedFile}
|
setUploadedFile={setUploadedFile}
|
||||||
setIsExampleLoaded={setIsExampleLoaded}
|
setIsExampleLoaded={setIsExampleLoaded}
|
||||||
variant="link"
|
variant="link"
|
||||||
|
|||||||
28
app/components/InstructionsPanel.tsx
Normal file
28
app/components/InstructionsPanel.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Info } from "lucide-react";
|
||||||
|
|
||||||
|
export const InstructionsPanel: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Info className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-2">Instructions d'utilisation :</p>
|
||||||
|
<ul className="space-y-1 text-blue-700">
|
||||||
|
<li>• Survolez les mots pour les mettre en évidence</li>
|
||||||
|
<li>
|
||||||
|
• Cliquez pour sélectionner un mot, Crtl + clic pour plusieurs
|
||||||
|
mots
|
||||||
|
</li>
|
||||||
|
<li>• Faites clic droit pour ouvrir le menu contextuel</li>
|
||||||
|
<li>• Modifiez les labels et couleurs selon vos besoins</li>
|
||||||
|
<li>
|
||||||
|
• Utilisez "Toutes les occurrences" pour appliquer à
|
||||||
|
tous les mots similaires
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
123
app/components/InteractiveTextEditor.tsx
Normal file
123
app/components/InteractiveTextEditor.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useState, useRef, useCallback } from "react";
|
||||||
|
import { EntityMapping } from "@/app/config/entityLabels";
|
||||||
|
import { useTextParsing } from "./hooks/useTextParsing";
|
||||||
|
import { useContextMenu } from "./hooks/useContextMenu";
|
||||||
|
import { useColorMapping } from "./hooks/useColorMapping";
|
||||||
|
import { TextDisplay } from "./TextDisplay";
|
||||||
|
import { ContextMenu } from "./ContextMenu";
|
||||||
|
import { InstructionsPanel } from "./InstructionsPanel";
|
||||||
|
|
||||||
|
interface InteractiveTextEditorProps {
|
||||||
|
text: string;
|
||||||
|
entityMappings: EntityMapping[];
|
||||||
|
onUpdateMapping: (
|
||||||
|
originalValue: string,
|
||||||
|
newLabel: string,
|
||||||
|
entityType: string,
|
||||||
|
applyToAllOccurrences?: boolean,
|
||||||
|
customColor?: string // Ajouter ce paramètre
|
||||||
|
) => void;
|
||||||
|
onRemoveMapping?: (originalValue: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InteractiveTextEditor: React.FC<InteractiveTextEditorProps> = ({
|
||||||
|
text,
|
||||||
|
entityMappings,
|
||||||
|
onUpdateMapping,
|
||||||
|
onRemoveMapping,
|
||||||
|
}) => {
|
||||||
|
const [selectedWords, setSelectedWords] = useState<Set<number>>(new Set());
|
||||||
|
const [hoveredWord, setHoveredWord] = useState<number | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { words } = useTextParsing(text, entityMappings);
|
||||||
|
const { getCurrentColor } = useColorMapping(entityMappings); // CORRECTION: Passer entityMappings
|
||||||
|
const {
|
||||||
|
contextMenu,
|
||||||
|
showContextMenu,
|
||||||
|
applyLabel,
|
||||||
|
applyColorDirectly,
|
||||||
|
removeLabel,
|
||||||
|
getExistingLabels,
|
||||||
|
} = useContextMenu({
|
||||||
|
entityMappings,
|
||||||
|
words, // NOUVEAU: passer les mots
|
||||||
|
onUpdateMapping,
|
||||||
|
onRemoveMapping,
|
||||||
|
getCurrentColor,
|
||||||
|
setSelectedWords,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleWordClick = useCallback(
|
||||||
|
(index: number, event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
setSelectedWords((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(index)) {
|
||||||
|
newSet.delete(index);
|
||||||
|
} else {
|
||||||
|
newSet.add(index);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSelectedWords(new Set([index]));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleContextMenu = useCallback(
|
||||||
|
(event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (selectedWords.size === 0) return;
|
||||||
|
|
||||||
|
const selectedText = Array.from(selectedWords)
|
||||||
|
.map((index) => {
|
||||||
|
const word = words[index];
|
||||||
|
return word?.isEntity ? word.text : word?.text;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
showContextMenu({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
selectedText,
|
||||||
|
wordIndices: Array.from(selectedWords),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[selectedWords, words, showContextMenu]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<InstructionsPanel />
|
||||||
|
|
||||||
|
<TextDisplay
|
||||||
|
words={words}
|
||||||
|
text={text}
|
||||||
|
selectedWords={selectedWords}
|
||||||
|
hoveredWord={hoveredWord}
|
||||||
|
onWordClick={handleWordClick}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
onWordHover={setHoveredWord}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{contextMenu.visible && (
|
||||||
|
<ContextMenu
|
||||||
|
contextMenu={contextMenu}
|
||||||
|
existingLabels={getExistingLabels()}
|
||||||
|
// entityMappings={entityMappings} // SUPPRIMER cette ligne
|
||||||
|
onApplyLabel={applyLabel}
|
||||||
|
onApplyColor={applyColorDirectly}
|
||||||
|
onRemoveLabel={removeLabel}
|
||||||
|
getCurrentColor={getCurrentColor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,36 +1,203 @@
|
|||||||
import { Copy, Download, AlertTriangle } from "lucide-react";
|
import { Copy, Download } from "lucide-react";
|
||||||
import { ReactNode } from "react";
|
import { InteractiveTextEditor } from "./InteractiveTextEditor";
|
||||||
|
import { isValidEntityBoundary } from "@/app/utils/entityBoundary";
|
||||||
|
import { EntityMapping } from "@/app/config/entityLabels"; // Importer l'interface unifiée
|
||||||
|
|
||||||
interface EntityMapping {
|
// Supprimer l'interface locale et utiliser celle de entityLabels.ts
|
||||||
originalValue: string;
|
|
||||||
anonymizedValue: string;
|
|
||||||
entityType: string;
|
|
||||||
startIndex: number;
|
|
||||||
endIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResultPreviewComponentProps {
|
interface ResultPreviewComponentProps {
|
||||||
outputText: string;
|
outputText: string;
|
||||||
|
sourceText: string;
|
||||||
copyToClipboard: () => void;
|
copyToClipboard: () => void;
|
||||||
downloadText: () => void;
|
downloadText: () => void;
|
||||||
highlightEntities: (text: string, mappings?: EntityMapping[]) => ReactNode;
|
|
||||||
entityMappings?: EntityMapping[];
|
entityMappings?: EntityMapping[];
|
||||||
|
onMappingsUpdate?: (mappings: EntityMapping[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ResultPreviewComponent = ({
|
export const ResultPreviewComponent = ({
|
||||||
outputText,
|
outputText,
|
||||||
|
sourceText,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
downloadText,
|
downloadText,
|
||||||
highlightEntities,
|
entityMappings = [],
|
||||||
entityMappings,
|
onMappingsUpdate,
|
||||||
}: ResultPreviewComponentProps) => {
|
}: 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;
|
if (!outputText) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-8 space-y-4">
|
<div className="mt-8 space-y-4">
|
||||||
<div className="flex items-center justify-between border-b border-[#f7ab6e] border-opacity-30 pb-2">
|
<div className="flex items-center justify-between border-b border-[#f7ab6e] border-opacity-30 pb-2">
|
||||||
<h3 className="text-lg font-medium text-[#092727]">
|
<h3 className="text-lg font-medium text-[#092727]">
|
||||||
Document anonymisé
|
Document anonymisé (Mode interactif)
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -56,21 +223,12 @@ export const ResultPreviewComponent = ({
|
|||||||
|
|
||||||
<div className="border border-[#f7ab6e] border-opacity-30 rounded-lg bg-white min-h-[400px] flex flex-col">
|
<div className="border border-[#f7ab6e] border-opacity-30 rounded-lg bg-white min-h-[400px] flex flex-col">
|
||||||
<div className="flex-1 p-4 overflow-hidden">
|
<div className="flex-1 p-4 overflow-hidden">
|
||||||
<div className="h-full min-h-[300px] text-[#092727] whitespace-pre-wrap overflow-y-auto">
|
<InteractiveTextEditor
|
||||||
<div className="leading-relaxed">
|
text={sourceText}
|
||||||
{highlightEntities(outputText, entityMappings)}
|
entityMappings={entityMappings} // Utiliser entityMappings du parent au lieu de mappings
|
||||||
</div>
|
onUpdateMapping={handleUpdateMapping}
|
||||||
</div>
|
onRemoveMapping={handleRemoveMapping}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="p-4 border-t border-[#f7ab6e] border-opacity-30">
|
|
||||||
<div className="flex items-start gap-2 p-2 bg-[#f7ab6e] bg-opacity-10 rounded-md">
|
|
||||||
<AlertTriangle className="h-4 w-4 text-[#f7ab6e] mt-0.5 flex-shrink-0" />
|
|
||||||
<p className="text-sm text-[#092727]">
|
|
||||||
Vérifiez le résultat pour vous assurer que toutes les informations
|
|
||||||
privées sont supprimées et éviter une divulgation accidentelle.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
interface SampleTextComponentProps {
|
interface SampleTextComponentProps {
|
||||||
setSourceText: (text: string) => void;
|
setSourceText: (text: string) => void;
|
||||||
setFileContent: (content: string) => void;
|
|
||||||
setUploadedFile: (file: File | null) => void;
|
setUploadedFile: (file: File | null) => void;
|
||||||
setIsExampleLoaded?: (loaded: boolean) => void;
|
setIsExampleLoaded?: (loaded: boolean) => void;
|
||||||
variant?: "button" | "link"; // Nouvelle prop
|
variant?: "button" | "link";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SampleTextComponent = ({
|
export const SampleTextComponent = ({
|
||||||
setSourceText,
|
setSourceText,
|
||||||
setFileContent,
|
|
||||||
setUploadedFile,
|
setUploadedFile,
|
||||||
setIsExampleLoaded,
|
setIsExampleLoaded,
|
||||||
variant = "button",
|
variant = "button",
|
||||||
@@ -26,16 +24,15 @@ Le contrat de prestation signé le 3 janvier 2024 prévoyait un montant de 75 00
|
|||||||
- M. Pieter Van Der Berg (consultant IT, email: p.vanderberg@itconsult.be)
|
- M. Pieter Van Der Berg (consultant IT, email: p.vanderberg@itconsult.be)
|
||||||
|
|
||||||
**Données sensibles :**
|
**Données sensibles :**
|
||||||
Le serveur compromis contenait 12 000 dossiers clients avec numéros de registre national. L’incident du 28 février 2024 a exposé les données personnelles stockées sur l’adresse IP 10.0.0.45 dans les bureaux situés Rue de la Loi 200, 1040 Etterbeek.
|
Le serveur compromis contenait 12 000 dossiers clients avec numéros de registre national. L'incident du 28 février 2024 a exposé les données personnelles stockées sur l'adresse IP 10.0.0.45 dans les bureaux situés Rue de la Loi 200, 1040 Etterbeek.
|
||||||
|
|
||||||
|
|
||||||
Coordonnées bancaires : BE43 0017 5555 5557 (CBC Banque)
|
Coordonnées bancaires : BE43 0017 5555 5557 (CBC Banque)
|
||||||
TVA intracommunautaire : BE0987.654.321`;
|
TVA intracommunautaire : BE0987.654.321`;
|
||||||
setSourceText(sampleText);
|
setSourceText(sampleText);
|
||||||
setFileContent(sampleText);
|
|
||||||
setUploadedFile(null);
|
setUploadedFile(null);
|
||||||
if (setIsExampleLoaded) {
|
if (setIsExampleLoaded) {
|
||||||
setIsExampleLoaded(true); // NOUVEAU - Marquer qu'un exemple est chargé
|
setIsExampleLoaded(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
108
app/components/TextDisplay.tsx
Normal file
108
app/components/TextDisplay.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { generateColorFromName } from "@/app/config/colorPalette";
|
||||||
|
import { Word } from "./hooks/useTextParsing";
|
||||||
|
|
||||||
|
interface TextDisplayProps {
|
||||||
|
words: Word[];
|
||||||
|
text: string;
|
||||||
|
selectedWords: Set<number>;
|
||||||
|
hoveredWord: number | null;
|
||||||
|
onWordClick: (index: number, event: React.MouseEvent) => void;
|
||||||
|
onContextMenu: (event: React.MouseEvent) => void;
|
||||||
|
onWordHover: (index: number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextDisplay: React.FC<TextDisplayProps> = ({
|
||||||
|
words,
|
||||||
|
text,
|
||||||
|
selectedWords,
|
||||||
|
hoveredWord,
|
||||||
|
onWordClick,
|
||||||
|
onContextMenu,
|
||||||
|
onWordHover,
|
||||||
|
}) => {
|
||||||
|
const renderWord = (word: Word, index: number) => {
|
||||||
|
const isSelected = selectedWords.has(index);
|
||||||
|
const isHovered = hoveredWord === index;
|
||||||
|
|
||||||
|
let className =
|
||||||
|
"inline-block cursor-pointer transition-all duration-200 px-1 py-0.5 rounded-sm ";
|
||||||
|
let backgroundColor = "transparent";
|
||||||
|
|
||||||
|
if (word.isEntity) {
|
||||||
|
// Couleur personnalisée ou générée - Niveau 200
|
||||||
|
if (word.mapping?.customColor) {
|
||||||
|
backgroundColor = word.mapping.customColor;
|
||||||
|
} else if (word.mapping?.displayName) {
|
||||||
|
// Utiliser generateColorFromName pour la cohérence
|
||||||
|
backgroundColor = generateColorFromName(word.mapping.displayName).value;
|
||||||
|
} else if (word.entityType) {
|
||||||
|
backgroundColor = generateColorFromName(word.entityType).value;
|
||||||
|
} else {
|
||||||
|
// Couleur par défaut si aucune information disponible
|
||||||
|
backgroundColor = generateColorFromName("default").value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utiliser la classe CSS appropriée
|
||||||
|
if (word.mapping?.displayName) {
|
||||||
|
const colorClass = generateColorFromName(word.mapping.displayName);
|
||||||
|
className += `${colorClass.bgClass} ${colorClass.textClass} border `;
|
||||||
|
} else if (word.entityType) {
|
||||||
|
const colorClass = generateColorFromName(word.entityType);
|
||||||
|
className += `${colorClass.bgClass} ${colorClass.textClass} border `;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion du survol et sélection - Couleurs claires
|
||||||
|
if (isSelected) {
|
||||||
|
className += "ring-2 ring-blue-400 ";
|
||||||
|
} else if (isHovered) {
|
||||||
|
if (!word.isEntity) {
|
||||||
|
className += "bg-gray-200 ";
|
||||||
|
backgroundColor = "#E5E7EB"; // gray-200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
className += "brightness-95 ";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => onWordHover(index)}
|
||||||
|
onMouseLeave={() => onWordHover(null)}
|
||||||
|
onClick={(e) => onWordClick(index, e)}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
title={
|
||||||
|
word.isEntity
|
||||||
|
? `Entité: ${word.entityType} (Original: ${word.text})`
|
||||||
|
: "Cliquez pour sélectionner"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{word.displayText}{" "}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-white border border-gray-200 rounded-lg min-h-[300px] leading-relaxed text-sm">
|
||||||
|
<div className="whitespace-pre-wrap">
|
||||||
|
{words.map((word, index) => {
|
||||||
|
const nextWord = words[index + 1];
|
||||||
|
const spaceBetween = nextWord
|
||||||
|
? text.slice(word.end, nextWord.start)
|
||||||
|
: text.slice(word.end);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
{renderWord(word, index)}
|
||||||
|
<span>{spaceBetween}</span>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
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 COLOR_PALETTE[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chercher le mapping correspondant au texte sélectionné
|
||||||
|
const mapping = entityMappings.find((m) => m.text === selectedText);
|
||||||
|
|
||||||
|
if (mapping?.customColor) {
|
||||||
|
return mapping.customColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapping?.displayName) {
|
||||||
|
return generateColorFromName(mapping.displayName).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapping?.entity_type) {
|
||||||
|
return generateColorFromName(mapping.entity_type).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer une couleur basée sur le texte
|
||||||
|
return generateColorFromName(selectedText).value;
|
||||||
|
},
|
||||||
|
[entityMappings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getColorByText = useCallback(
|
||||||
|
(selectedText: string) => {
|
||||||
|
return getCurrentColor(selectedText);
|
||||||
|
},
|
||||||
|
[getCurrentColor]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
colorOptions,
|
||||||
|
tailwindToHex,
|
||||||
|
getCurrentColor,
|
||||||
|
getColorByText,
|
||||||
|
};
|
||||||
|
};
|
||||||
207
app/components/hooks/useContextMenu.ts
Normal file
207
app/components/hooks/useContextMenu.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { EntityMapping } from "@/app/config/entityLabels";
|
||||||
|
import { Word } from "./useTextParsing"; // AJOUTER cet import
|
||||||
|
|
||||||
|
interface ContextMenuState {
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
selectedText: string;
|
||||||
|
wordIndices: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseContextMenuProps {
|
||||||
|
entityMappings: EntityMapping[];
|
||||||
|
words: Word[]; // Maintenant le type Word est reconnu
|
||||||
|
onUpdateMapping: (
|
||||||
|
originalValue: string,
|
||||||
|
newLabel: string,
|
||||||
|
entityType: string,
|
||||||
|
applyToAll?: boolean,
|
||||||
|
customColor?: string,
|
||||||
|
wordStart?: number,
|
||||||
|
wordEnd?: number
|
||||||
|
) => void;
|
||||||
|
onRemoveMapping?: (originalValue: string, applyToAll?: boolean) => void;
|
||||||
|
getCurrentColor: (selectedText: string) => string;
|
||||||
|
setSelectedWords: (words: Set<number>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useContextMenu = ({
|
||||||
|
entityMappings,
|
||||||
|
words, // Paramètre ajouté
|
||||||
|
onUpdateMapping,
|
||||||
|
onRemoveMapping,
|
||||||
|
getCurrentColor,
|
||||||
|
setSelectedWords,
|
||||||
|
}: UseContextMenuProps) => {
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
selectedText: "",
|
||||||
|
wordIndices: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeContextMenu = useCallback(() => {
|
||||||
|
setContextMenu((prev) => ({ ...prev, visible: false }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showContextMenu = useCallback(
|
||||||
|
(menuData: Omit<ContextMenuState, "visible">) => {
|
||||||
|
setContextMenu({ ...menuData, visible: true });
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getExistingLabels = useCallback(() => {
|
||||||
|
const uniqueLabels = new Set<string>();
|
||||||
|
entityMappings.forEach((mapping) => {
|
||||||
|
uniqueLabels.add(mapping.displayName || mapping.entity_type); // Utiliser displayName
|
||||||
|
});
|
||||||
|
return Array.from(uniqueLabels).sort();
|
||||||
|
}, [entityMappings]);
|
||||||
|
|
||||||
|
// CORRECTION: Accepter displayName comme premier paramètre
|
||||||
|
const applyLabel = useCallback(
|
||||||
|
(displayName: string, applyToAll?: boolean) => {
|
||||||
|
if (!contextMenu.selectedText) return;
|
||||||
|
|
||||||
|
const originalText = contextMenu.selectedText;
|
||||||
|
const firstWordIndex = contextMenu.wordIndices[0];
|
||||||
|
|
||||||
|
// Calculer les vraies coordonnées start/end du mot cliqué
|
||||||
|
const clickedWord = words[firstWordIndex];
|
||||||
|
const wordStart = clickedWord?.start;
|
||||||
|
const wordEnd = clickedWord?.end;
|
||||||
|
|
||||||
|
const existingMapping = entityMappings.find(
|
||||||
|
(m) => m.text === originalText
|
||||||
|
);
|
||||||
|
const entityType =
|
||||||
|
existingMapping?.entity_type ||
|
||||||
|
displayName.replace(/[\[\]]/g, "").toUpperCase();
|
||||||
|
|
||||||
|
onUpdateMapping(
|
||||||
|
originalText,
|
||||||
|
displayName,
|
||||||
|
entityType,
|
||||||
|
applyToAll,
|
||||||
|
undefined, // customColor
|
||||||
|
wordStart, // vraies coordonnées start
|
||||||
|
wordEnd // vraies coordonnées end
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedWords(new Set());
|
||||||
|
closeContextMenu();
|
||||||
|
},
|
||||||
|
[
|
||||||
|
contextMenu,
|
||||||
|
words, // NOUVEAU
|
||||||
|
entityMappings,
|
||||||
|
onUpdateMapping,
|
||||||
|
closeContextMenu,
|
||||||
|
setSelectedWords,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// CORRECTION: Accepter applyToAll comme paramètre
|
||||||
|
const applyColorDirectly = useCallback(
|
||||||
|
(color: string, colorName: string, applyToAll?: boolean) => {
|
||||||
|
if (!contextMenu.selectedText) return;
|
||||||
|
|
||||||
|
const existingMapping = entityMappings.find(
|
||||||
|
(mapping) => mapping.text === contextMenu.selectedText
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("useContextMenu - applyColorDirectly:", {
|
||||||
|
color,
|
||||||
|
colorName,
|
||||||
|
applyToAll,
|
||||||
|
existingMapping,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingMapping) {
|
||||||
|
onUpdateMapping(
|
||||||
|
contextMenu.selectedText,
|
||||||
|
existingMapping.displayName || existingMapping.entity_type, // Utiliser displayName
|
||||||
|
existingMapping.entity_type,
|
||||||
|
applyToAll,
|
||||||
|
color
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onUpdateMapping(
|
||||||
|
contextMenu.selectedText,
|
||||||
|
"CUSTOM_LABEL",
|
||||||
|
"CUSTOM_LABEL",
|
||||||
|
applyToAll,
|
||||||
|
color
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedWords(new Set());
|
||||||
|
closeContextMenu();
|
||||||
|
},
|
||||||
|
[
|
||||||
|
contextMenu.selectedText,
|
||||||
|
entityMappings, // Ajouter cette dépendance
|
||||||
|
onUpdateMapping,
|
||||||
|
closeContextMenu,
|
||||||
|
setSelectedWords,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// CORRECTION: Accepter applyToAll comme paramètre
|
||||||
|
const removeLabel = useCallback(
|
||||||
|
(applyToAll?: boolean) => {
|
||||||
|
if (!contextMenu.selectedText || !onRemoveMapping) return;
|
||||||
|
|
||||||
|
console.log("useContextMenu - removeLabel:", {
|
||||||
|
selectedText: contextMenu.selectedText,
|
||||||
|
applyToAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
onRemoveMapping(contextMenu.selectedText, applyToAll);
|
||||||
|
setSelectedWords(new Set());
|
||||||
|
closeContextMenu();
|
||||||
|
},
|
||||||
|
[
|
||||||
|
contextMenu.selectedText,
|
||||||
|
onRemoveMapping,
|
||||||
|
closeContextMenu,
|
||||||
|
setSelectedWords,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Gestion des clics en dehors du menu
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (contextMenu.visible) {
|
||||||
|
const target = event.target as Element;
|
||||||
|
const contextMenuElement = document.querySelector(
|
||||||
|
"[data-context-menu]"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (contextMenuElement && !contextMenuElement.contains(target)) {
|
||||||
|
setTimeout(() => {
|
||||||
|
closeContextMenu();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [contextMenu.visible, closeContextMenu]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contextMenu,
|
||||||
|
showContextMenu,
|
||||||
|
closeContextMenu,
|
||||||
|
applyLabel,
|
||||||
|
applyColorDirectly,
|
||||||
|
removeLabel,
|
||||||
|
getExistingLabels,
|
||||||
|
getCurrentColor,
|
||||||
|
};
|
||||||
|
};
|
||||||
98
app/components/hooks/useTextParsing.ts
Normal file
98
app/components/hooks/useTextParsing.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { EntityMapping } from "@/app/config/entityLabels";
|
||||||
|
|
||||||
|
export interface Word {
|
||||||
|
text: string;
|
||||||
|
displayText: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
isEntity: boolean;
|
||||||
|
entityType?: string;
|
||||||
|
entityIndex?: number;
|
||||||
|
mapping?: EntityMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTextParsing = (
|
||||||
|
text: string,
|
||||||
|
entityMappings: EntityMapping[]
|
||||||
|
) => {
|
||||||
|
const words = useMemo((): Word[] => {
|
||||||
|
const segments: Word[] = [];
|
||||||
|
let currentIndex = 0;
|
||||||
|
|
||||||
|
const sortedMappings = [...entityMappings].sort(
|
||||||
|
(a, b) => a.start - b.start // CORRECTION: utiliser 'start' au lieu de 'startIndex'
|
||||||
|
);
|
||||||
|
|
||||||
|
sortedMappings.forEach((mapping, mappingIndex) => {
|
||||||
|
if (currentIndex < mapping.start) {
|
||||||
|
// CORRECTION: utiliser 'start'
|
||||||
|
const beforeText = text.slice(currentIndex, mapping.start);
|
||||||
|
const beforeWords = beforeText.split(/\s+/).filter(Boolean);
|
||||||
|
|
||||||
|
beforeWords.forEach((word) => {
|
||||||
|
const wordStart = text.indexOf(word, currentIndex);
|
||||||
|
const wordEnd = wordStart + word.length;
|
||||||
|
|
||||||
|
segments.push({
|
||||||
|
text: word,
|
||||||
|
displayText: word,
|
||||||
|
start: wordStart,
|
||||||
|
end: wordEnd,
|
||||||
|
isEntity: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentIndex = wordEnd;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utiliser displayName au lieu de entity_type
|
||||||
|
// Ligne 45 - Ajouter du debug
|
||||||
|
console.log("useTextParsing - mapping:", {
|
||||||
|
text: mapping.text,
|
||||||
|
displayName: mapping.displayName,
|
||||||
|
entity_type: mapping.entity_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
const anonymizedText =
|
||||||
|
mapping.displayName || `[${mapping.entity_type.toUpperCase()}]`;
|
||||||
|
|
||||||
|
segments.push({
|
||||||
|
text: mapping.text,
|
||||||
|
displayText: anonymizedText,
|
||||||
|
start: mapping.start,
|
||||||
|
end: mapping.end,
|
||||||
|
isEntity: true,
|
||||||
|
entityType: mapping.entity_type,
|
||||||
|
entityIndex: mappingIndex,
|
||||||
|
mapping: mapping,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentIndex = mapping.end; // CORRECTION: utiliser 'end'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentIndex < text.length) {
|
||||||
|
const remainingText = text.slice(currentIndex);
|
||||||
|
const remainingWords = remainingText.split(/\s+/).filter(Boolean);
|
||||||
|
|
||||||
|
remainingWords.forEach((word) => {
|
||||||
|
const wordStart = text.indexOf(word, currentIndex);
|
||||||
|
const wordEnd = wordStart + word.length;
|
||||||
|
|
||||||
|
segments.push({
|
||||||
|
text: word,
|
||||||
|
displayText: word,
|
||||||
|
start: wordStart,
|
||||||
|
end: wordEnd,
|
||||||
|
isEntity: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentIndex = wordEnd;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}, [text, entityMappings]);
|
||||||
|
|
||||||
|
return { words };
|
||||||
|
};
|
||||||
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
|
||||||
|
};
|
||||||
|
};
|
||||||
64
app/page.tsx
64
app/page.tsx
@@ -1,31 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { FileUploadComponent } from "./components/FileUploadComponent";
|
import { FileUploadComponent } from "./components/FileUploadComponent";
|
||||||
|
|
||||||
import { EntityMappingTable } from "./components/EntityMappingTable";
|
import { EntityMappingTable } from "./components/EntityMappingTable";
|
||||||
import { ProgressBar } from "./components/ProgressBar";
|
import { ProgressBar } from "./components/ProgressBar";
|
||||||
import { useFileHandler } from "./components/FileHandler";
|
import { useFileHandler } from "./components/FileHandler";
|
||||||
import { useAnonymization } from "./components/AnonymizationLogic";
|
import { useAnonymization } from "./components/AnonymizationLogic";
|
||||||
import { useDownloadActions } from "./components/DownloadActions";
|
import { useDownloadActions } from "./components/DownloadActions";
|
||||||
|
import { ResultPreviewComponent } from "./components/ResultPreviewComponent";
|
||||||
|
import { EntityMapping } from "./config/entityLabels"; // Importer l'interface unifiée
|
||||||
|
|
||||||
interface EntityMapping {
|
// Supprimer l'interface locale EntityMapping (lignes 12-18)
|
||||||
originalValue: string;
|
|
||||||
anonymizedValue: string;
|
|
||||||
entityType: string;
|
|
||||||
startIndex: number;
|
|
||||||
endIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [sourceText, setSourceText] = useState("");
|
const [sourceText, setSourceText] = useState("");
|
||||||
const [outputText, setOutputText] = useState("");
|
const [outputText, setOutputText] = useState("");
|
||||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||||
const [fileContent, setFileContent] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoadingFile, setIsLoadingFile] = useState(false);
|
const [isLoadingFile, setIsLoadingFile] = useState(false);
|
||||||
const [entityMappings, setEntityMappings] = useState<EntityMapping[]>([]);
|
const [entityMappings, setEntityMappings] = useState<EntityMapping[]>([]);
|
||||||
const [isExampleLoaded, setIsExampleLoaded] = useState(false); // NOUVEAU
|
const [isExampleLoaded, setIsExampleLoaded] = useState(false);
|
||||||
|
|
||||||
const progressSteps = ["Téléversement", "Prévisualisation", "Anonymisation"];
|
const progressSteps = ["Téléversement", "Prévisualisation", "Anonymisation"];
|
||||||
|
|
||||||
@@ -40,32 +34,43 @@ export default function Home() {
|
|||||||
setSourceText("");
|
setSourceText("");
|
||||||
setOutputText("");
|
setOutputText("");
|
||||||
setUploadedFile(null);
|
setUploadedFile(null);
|
||||||
setFileContent("");
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsLoadingFile(false);
|
setIsLoadingFile(false);
|
||||||
setEntityMappings([]);
|
setEntityMappings([]);
|
||||||
setIsExampleLoaded(false); // NOUVEAU
|
setIsExampleLoaded(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fonction pour mettre à jour les mappings depuis l'éditeur interactif
|
||||||
|
const handleMappingsUpdate = useCallback(
|
||||||
|
(updatedMappings: EntityMapping[]) => {
|
||||||
|
setEntityMappings(updatedMappings);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// Hooks personnalisés pour la logique métier
|
// Hooks personnalisés pour la logique métier
|
||||||
const { handleFileChange } = useFileHandler({
|
const { handleFileChange } = useFileHandler({
|
||||||
setUploadedFile,
|
setUploadedFile,
|
||||||
setSourceText,
|
setSourceText,
|
||||||
setFileContent,
|
|
||||||
setError,
|
setError,
|
||||||
setIsLoadingFile, // Passer le setter
|
setIsLoadingFile,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { anonymizeData, isProcessing } = useAnonymization({
|
const { anonymizeData, isProcessing } = useAnonymization({
|
||||||
sourceText,
|
|
||||||
fileContent,
|
|
||||||
uploadedFile,
|
|
||||||
setOutputText,
|
setOutputText,
|
||||||
setError,
|
setError,
|
||||||
setEntityMappings,
|
setEntityMappings,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { copyToClipboard, downloadText } = useDownloadActions({ outputText });
|
const { copyToClipboard, downloadText } = useDownloadActions({
|
||||||
|
outputText,
|
||||||
|
entityMappings,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fonction wrapper pour appeler anonymizeData avec les bonnes données
|
||||||
|
const handleAnonymize = () => {
|
||||||
|
anonymizeData({ file: uploadedFile, text: sourceText });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full overflow-hidden">
|
<div className="min-h-screen w-full overflow-hidden">
|
||||||
@@ -83,8 +88,7 @@ export default function Home() {
|
|||||||
sourceText={sourceText}
|
sourceText={sourceText}
|
||||||
setSourceText={setSourceText}
|
setSourceText={setSourceText}
|
||||||
setUploadedFile={setUploadedFile}
|
setUploadedFile={setUploadedFile}
|
||||||
setFileContent={setFileContent}
|
onAnonymize={handleAnonymize}
|
||||||
onAnonymize={anonymizeData}
|
|
||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
canAnonymize={
|
canAnonymize={
|
||||||
uploadedFile !== null ||
|
uploadedFile !== null ||
|
||||||
@@ -97,11 +101,27 @@ export default function Home() {
|
|||||||
downloadText={downloadText}
|
downloadText={downloadText}
|
||||||
isExampleLoaded={isExampleLoaded}
|
isExampleLoaded={isExampleLoaded}
|
||||||
setIsExampleLoaded={setIsExampleLoaded}
|
setIsExampleLoaded={setIsExampleLoaded}
|
||||||
entityMappings={entityMappings} // Ajouter cette ligne
|
entityMappings={entityMappings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive Text Editor - Nouveau composant pour l'édition interactive */}
|
||||||
|
{outputText && (
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
|
||||||
|
<div className="p-1 sm:p-3">
|
||||||
|
<ResultPreviewComponent
|
||||||
|
outputText={outputText}
|
||||||
|
sourceText={sourceText}
|
||||||
|
copyToClipboard={copyToClipboard}
|
||||||
|
downloadText={downloadText}
|
||||||
|
entityMappings={entityMappings}
|
||||||
|
onMappingsUpdate={handleMappingsUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Entity Mapping Table - Seulement si outputText existe */}
|
{/* Entity Mapping Table - Seulement si outputText existe */}
|
||||||
{outputText && (
|
{outputText && (
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
|
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
32
app/utils/generateAnonymizedText.ts
Normal file
32
app/utils/generateAnonymizedText.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { EntityMapping } from "@/app/config/entityLabels";
|
||||||
|
|
||||||
|
export const generateAnonymizedText = (
|
||||||
|
originalText: string,
|
||||||
|
mappings: EntityMapping[]
|
||||||
|
): string => {
|
||||||
|
if (!originalText || !mappings || mappings.length === 0) {
|
||||||
|
return originalText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trier les mappings par position de début
|
||||||
|
const sortedMappings = [...mappings].sort((a, b) => a.start - b.start);
|
||||||
|
|
||||||
|
let result = "";
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
for (const mapping of sortedMappings) {
|
||||||
|
// Ajouter le texte avant l'entité
|
||||||
|
result += originalText.slice(lastIndex, mapping.start);
|
||||||
|
|
||||||
|
// Utiliser displayName comme dans le tableau de mapping
|
||||||
|
result += mapping.displayName;
|
||||||
|
|
||||||
|
// Mettre à jour la position
|
||||||
|
lastIndex = mapping.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter le reste du texte
|
||||||
|
result += originalText.slice(lastIndex);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -1,199 +1,50 @@
|
|||||||
import { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
|
import {
|
||||||
export const patterns = [
|
generateColorFromName,
|
||||||
{
|
EntityMapping,
|
||||||
regex: /<PERSON>/g,
|
} from "@/app/config/entityLabels";
|
||||||
className: "bg-blue-200 text-blue-800",
|
|
||||||
label: "Personne",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<EMAIL_ADDRESS>/g,
|
|
||||||
className: "bg-green-200 text-green-800",
|
|
||||||
label: "Adresse Email",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<PHONE_NUMBER>/g,
|
|
||||||
className: "bg-purple-200 text-purple-800",
|
|
||||||
label: "N° de Téléphone",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<LOCATION>/g,
|
|
||||||
className: "bg-red-200 text-red-800",
|
|
||||||
label: "Lieu",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<IBAN>/g,
|
|
||||||
className: "bg-yellow-200 text-yellow-800",
|
|
||||||
label: "IBAN",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<ORGANIZATION>/g,
|
|
||||||
className: "bg-indigo-200 text-indigo-800",
|
|
||||||
label: "Organisation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<FLEXIBLE_DATE>/g,
|
|
||||||
className: "bg-pink-200 text-pink-800",
|
|
||||||
label: "Date",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<BE_ADDRESS>/g,
|
|
||||||
className: "bg-cyan-200 text-cyan-800",
|
|
||||||
label: "Adresse (BE)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<BE_PHONE_NUMBER>/g,
|
|
||||||
className: "bg-violet-200 text-violet-800",
|
|
||||||
label: "N° de Tél. (BE)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<CREDIT_CARD>/g,
|
|
||||||
className: "bg-orange-200 text-orange-800",
|
|
||||||
label: "Carte de Crédit",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<URL>/g,
|
|
||||||
className: "bg-teal-200 text-teal-800",
|
|
||||||
label: "URL",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<IP_ADDRESS>/g,
|
|
||||||
className: "bg-gray-300 text-gray-900",
|
|
||||||
label: "Adresse IP",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<DATE_TIME>/g,
|
|
||||||
className: "bg-pink-300 text-pink-900",
|
|
||||||
label: "Date & Heure",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<NRP>/g,
|
|
||||||
className: "bg-red-300 text-red-900",
|
|
||||||
label: "N° Registre National",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<BE_VAT>/g,
|
|
||||||
className: "bg-yellow-300 text-yellow-900",
|
|
||||||
label: "TVA (BE)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<BE_ENTERPRISE_NUMBER>/g,
|
|
||||||
className: "bg-lime-200 text-lime-800",
|
|
||||||
label: "N° d'entreprise (BE)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /<BE_PRO_ID>/g,
|
|
||||||
className: "bg-emerald-200 text-emerald-800",
|
|
||||||
label: "ID Pro (BE)",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface EntityMapping {
|
|
||||||
originalValue: string;
|
|
||||||
anonymizedValue: string;
|
|
||||||
entityType: string;
|
|
||||||
startIndex: number;
|
|
||||||
endIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const highlightEntities = (
|
export const highlightEntities = (
|
||||||
text: string,
|
originalText: string,
|
||||||
entityMappings?: EntityMapping[]
|
mappings?: EntityMapping[]
|
||||||
): ReactNode => {
|
): ReactNode[] => {
|
||||||
if (!text) return text;
|
if (!originalText || !mappings || mappings.length === 0) {
|
||||||
|
return [originalText];
|
||||||
const replacements: Array<{
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
element: ReactNode;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
// Trouver toutes les correspondances
|
|
||||||
patterns.forEach((pattern, patternIndex) => {
|
|
||||||
const regex = new RegExp(pattern.regex.source, pattern.regex.flags);
|
|
||||||
let match;
|
|
||||||
let matchCount = 0; // Compteur pour ce type d'entité
|
|
||||||
|
|
||||||
while ((match = regex.exec(text)) !== null) {
|
|
||||||
const start = match.index;
|
|
||||||
const end = match.index + match[0].length;
|
|
||||||
|
|
||||||
// Vérifier qu'il n'y a pas de chevauchement avec des remplacements existants
|
|
||||||
const hasOverlap = replacements.some(
|
|
||||||
(r) =>
|
|
||||||
(start >= r.start && start < r.end) || (end > r.start && end <= r.end)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasOverlap) {
|
|
||||||
matchCount++; // Incrémenter le compteur pour ce type
|
|
||||||
let displayLabel = pattern.label;
|
|
||||||
const displayClass = pattern.className;
|
|
||||||
|
|
||||||
if (entityMappings) {
|
|
||||||
// Chercher le mapping correspondant à cette position et ce type
|
|
||||||
const matchingMapping = entityMappings.find(
|
|
||||||
(mapping) => mapping.entityType === pattern.label
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchingMapping) {
|
|
||||||
// Utiliser directement la valeur anonymisée du mapping
|
|
||||||
// qui correspond à cette occurrence (basée sur l'ordre d'apparition)
|
|
||||||
const entityType = pattern.label;
|
|
||||||
const mappingsOfThisType = entityMappings.filter(
|
|
||||||
(m) => m.entityType === entityType
|
|
||||||
);
|
|
||||||
|
|
||||||
// Prendre le mapping correspondant à cette occurrence
|
|
||||||
if (mappingsOfThisType[matchCount - 1]) {
|
|
||||||
displayLabel = mappingsOfThisType[matchCount - 1].anonymizedValue;
|
|
||||||
} else {
|
|
||||||
// Fallback si pas de mapping trouvé
|
|
||||||
displayLabel = `${entityType} [${matchCount}]`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const element = (
|
|
||||||
<span
|
|
||||||
key={`${patternIndex}-${start}`}
|
|
||||||
className={`${displayClass} px-2 py-1 rounded-md font-medium text-xs inline-block mx-0.5 shadow-sm border`}
|
|
||||||
title={`${displayLabel} anonymisé`}
|
|
||||||
>
|
|
||||||
{displayLabel}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
replacements.push({ start, end, element });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trier les remplacements par position
|
|
||||||
replacements.sort((a, b) => a.start - b.start);
|
|
||||||
|
|
||||||
// Construire le résultat final
|
|
||||||
if (replacements.length === 0) {
|
|
||||||
return text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
|
|
||||||
replacements.forEach((replacement) => {
|
// Les mappings sont triés par `start`
|
||||||
// Ajouter le texte avant le remplacement
|
mappings.forEach((mapping, index) => {
|
||||||
if (replacement.start > lastIndex) {
|
const { start, end, entity_type, text } = mapping;
|
||||||
parts.push(text.slice(lastIndex, replacement.start));
|
|
||||||
|
// Ajouter le segment de texte AVANT l'entité actuelle
|
||||||
|
if (start > lastIndex) {
|
||||||
|
parts.push(originalText.slice(lastIndex, start));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajouter l'élément de remplacement
|
// Créer et ajouter le badge stylisé pour l'entité
|
||||||
parts.push(replacement.element);
|
const colorOption = generateColorFromName(entity_type);
|
||||||
|
const displayText = mapping.displayName || `[${entity_type.toUpperCase()}]`;
|
||||||
|
|
||||||
lastIndex = replacement.end;
|
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 texte restant
|
// Ajouter le reste du texte après la dernière entité
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < originalText.length) {
|
||||||
parts.push(text.slice(lastIndex));
|
parts.push(originalText.slice(lastIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts;
|
return parts;
|
||||||
|
|||||||
@@ -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