clean presidio

This commit is contained in:
Biqoz
2026-01-29 00:19:50 +01:00
parent 050474e95b
commit 90b73080e0
11 changed files with 667 additions and 516 deletions

View File

@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(npm install:*)",
"Bash(npx next:*)",
"Bash(node:*)",
"Bash(npm view:*)",
"Bash(npm info:*)",
"Bash(npm ls:*)",
"Bash(npm outdated:*)"
]
}
}

View File

@@ -1,24 +1,40 @@
import { EntityMapping } from "@/app/config/entityLabels";
import { generateAnonymizedText } from "@/app/utils/generateAnonymizedText";
interface DownloadActionsProps {
outputText: string;
entityMappings?: EntityMapping[];
anonymizedText?: string; // Nouveau paramètre pour le texte déjà anonymisé par Presidio
anonymizedText?: string;
sourceText?: string; // Ajouter le texte source
}
export const useDownloadActions = ({
outputText,
anonymizedText, // Texte déjà anonymisé par Presidio
entityMappings,
anonymizedText,
sourceText, // Nouveau paramètre
}: DownloadActionsProps) => {
const copyToClipboard = () => {
// Toujours utiliser le texte anonymisé de Presidio
const textToCopy = anonymizedText || outputText;
// Utiliser les mappings mis à jour pour générer le texte final
let textToCopy = anonymizedText || outputText;
if (sourceText && entityMappings && entityMappings.length > 0) {
// Générer le texte avec les labels modifiés manuellement
textToCopy = generateAnonymizedText(sourceText, entityMappings);
}
navigator.clipboard.writeText(textToCopy);
};
const downloadText = () => {
// Utiliser le texte anonymisé de Presidio si disponible, sinon fallback sur outputText
const textToDownload = anonymizedText || outputText;
// Utiliser les mappings mis à jour pour générer le texte final
let textToDownload = anonymizedText || outputText;
if (sourceText && entityMappings && entityMappings.length > 0) {
// Générer le texte avec les labels modifiés manuellement
textToDownload = generateAnonymizedText(sourceText, entityMappings);
}
const blob = new Blob([textToDownload], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");

View File

@@ -12,9 +12,135 @@ import { EntityMapping } from "../config/entityLabels";
interface EntityMappingTableProps {
mappings: EntityMapping[];
selectedCategory?: string;
}
export const EntityMappingTable = ({ mappings }: EntityMappingTableProps) => {
// Fonction pour filtrer les entités selon la catégorie
const filterMappingsByCategory = (
mappings: EntityMapping[],
category: string = "pii_business"
): EntityMapping[] => {
if (category === "pii_business") {
return mappings; // Tout afficher
}
// Définir les entités PII (Données personnelles)
const piiEntities = new Set([
// Données personnelles de base
"PERSONNE",
"PERSON",
"DATE",
"DATE_TIME",
"EMAIL_ADDRESS",
"ADRESSE_EMAIL",
"PHONE_NUMBER",
"TELEPHONE",
"CREDIT_CARD",
"IBAN",
"ADRESSE_IP",
// Adresses personnelles
"ADRESSE",
"ADRESSE_FRANCAISE",
"ADRESSE_BELGE",
"LOCATION",
// Téléphones personnels
"TELEPHONE_FRANCAIS",
"TELEPHONE_BELGE",
// Documents d'identité personnels
"NUMERO_SECURITE_SOCIALE_FRANCAIS",
"REGISTRE_NATIONAL_BELGE",
"CARTE_IDENTITE_FRANCAISE",
"CARTE_IDENTITE_BELGE",
"PASSEPORT_FRANCAIS",
"PASSEPORT_BELGE",
"PERMIS_CONDUIRE_FRANCAIS",
// Données financières personnelles
"COMPTE_BANCAIRE_FRANCAIS",
// Données sensibles RGPD
"HEALTH_DATA",
"DONNEES_SANTE",
"SEXUAL_ORIENTATION",
"ORIENTATION_SEXUELLE",
"POLITICAL_OPINIONS",
"OPINIONS_POLITIQUES",
"BIOMETRIC_DATA",
"DONNEES_BIOMETRIQUES",
"RGPD_FINANCIAL_DATA",
"DONNEES_FINANCIERES_RGPD",
// Identifiants personnels
"IDENTIFIANT_PERSONNEL",
]);
// Définir les entités Business (Données d'entreprise)
const businessEntities = new Set([
// Organisations et sociétés
"ORGANISATION",
"ORGANIZATION",
"SOCIETE_FRANCAISE",
"SOCIETE_BELGE",
// Identifiants fiscaux et d'entreprise
"TVA_FRANCAISE",
"TVA_BELGE",
"NUMERO_FISCAL_FRANCAIS",
"SIRET_SIREN_FRANCAIS",
"NUMERO_ENTREPRISE_BELGE",
// Identifiants professionnels
"ID_PROFESSIONNEL_BELGE",
// Données commerciales
"MARKET_SHARE",
"SECRET_COMMERCIAL",
"REFERENCE_CONTRAT",
"MONTANT_FINANCIER",
// Données techniques d'entreprise
"CLE_API_SECRETE",
]);
// Définir les entités mixtes (PII + Business)
const mixedEntities = new Set([
// Données pouvant être personnelles ou professionnelles
"TITRE_CIVILITE",
"DONNEES_PROFESSIONNELLES",
"LOCALISATION_GPS",
"URL_IDENTIFIANT",
]);
if (category === "pii") {
// Inclure PII + mixtes
const allowedEntities = new Set([...piiEntities, ...mixedEntities]);
return mappings.filter((mapping) =>
allowedEntities.has(mapping.entity_type)
);
}
if (category === "business") {
// Inclure Business + mixtes
const allowedEntities = new Set([...businessEntities, ...mixedEntities]);
return mappings.filter((mapping) =>
allowedEntities.has(mapping.entity_type)
);
}
// Par défaut, retourner tous les mappings
return mappings;
};
export const EntityMappingTable = ({
mappings,
selectedCategory = "pii_business",
}: EntityMappingTableProps) => {
// Filtrer les mappings selon la catégorie sélectionnée
const filteredMappings = filterMappingsByCategory(mappings, selectedCategory);
if (!mappings || mappings.length === 0) {
return (
<Card className="mt-8">
@@ -32,9 +158,37 @@ export const EntityMappingTable = ({ mappings }: EntityMappingTableProps) => {
);
}
// Créer un compteur pour chaque type d'entité
if (filteredMappings.length === 0) {
const categoryNames = {
pii: "PII (Données Personnelles)",
business: "Business (Données Métier)",
pii_business: "PII + Business",
};
return (
<Card className="mt-8">
<CardHeader>
<CardTitle className="text-lg font-medium text-[#092727]">
Entités détectées -{" "}
{categoryNames[selectedCategory as keyof typeof categoryNames] ||
"Toutes"}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500 text-center py-8">
Aucune entité de type &quot;
{categoryNames[selectedCategory as keyof typeof categoryNames] ||
"sélectionné"}
&quot; détectée dans le document.
</p>
</CardContent>
</Card>
);
}
// Créer un compteur pour chaque type d'entité (sur les mappings filtrés)
const entityCounts: { [key: string]: number } = {};
const mappingsWithNumbers = mappings.map((mapping) => {
const mappingsWithNumbers = filteredMappings.map((mapping) => {
const entityType = mapping.entity_type;
entityCounts[entityType] = (entityCounts[entityType] || 0) + 1;
return {
@@ -44,11 +198,20 @@ export const EntityMappingTable = ({ mappings }: EntityMappingTableProps) => {
};
});
const categoryNames = {
pii: "PII (Données Personnelles)",
business: "Business (Données Métier)",
pii_business: "PII + Business",
};
return (
<Card className="mt-8">
<CardHeader>
<CardTitle className="text-lg font-medium text-[#092727]">
Entités détectées ({mappings.length})
Entités détectées -{" "}
{categoryNames[selectedCategory as keyof typeof categoryNames] ||
"Toutes"}{" "}
({filteredMappings.length}/{mappings.length})
</CardTitle>
</CardHeader>
<CardContent>

View File

@@ -18,11 +18,11 @@ import { EntityMapping } from "../config/entityLabels"; // Importer l'interface
interface FileUploadComponentProps {
uploadedFile: File | null;
handleFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
sourceText: string;
setSourceText: (text: string) => void;
setUploadedFile: (file: File | null) => void;
onAnonymize?: (category?: string) => void;
onAnonymize?: (category: string) => void;
isProcessing?: boolean;
canAnonymize?: boolean;
isLoadingFile?: boolean;
@@ -30,10 +30,11 @@ interface FileUploadComponentProps {
outputText?: string;
copyToClipboard?: () => void;
downloadText?: () => void;
isExampleLoaded?: boolean;
setIsExampleLoaded?: (loaded: boolean) => void;
entityMappings?: EntityMapping[];
onMappingsUpdate?: (mappings: EntityMapping[]) => void;
selectedCategory?: string;
setSelectedCategory?: (category: string) => void;
}
export const FileUploadComponent = ({
@@ -53,9 +54,12 @@ export const FileUploadComponent = ({
setIsExampleLoaded,
entityMappings,
onMappingsUpdate,
selectedCategory = "pii",
setSelectedCategory,
}: FileUploadComponentProps) => {
const [isDragOver, setIsDragOver] = useState(false);
const [selectedCategory, setSelectedCategory] = useState("pii");
// Remove the duplicate local state declarations:
// const [selectedCategory, setSelectedCategory] = useState("pii");
// Fonction pour valider le type de fichier
const isValidFileType = (file: File) => {
@@ -494,8 +498,8 @@ export const FileUploadComponent = ({
</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[#f7ab6e] focus:border-[#f7ab6e] bg-white"
onChange={(e) => setSelectedCategory?.(e.target.value)}
className="w-full appearance-none bg-white border border-gray-300 text-gray-700 text-xs rounded-md pl-3 pr-8 py-2 focus:outline-none focus:ring-1 focus:ring-[#f7ab6e] focus:border-[#f7ab6e] transition-colors duration-200"
>
<option value="pii">🔒 PII (Données Personnelles)</option>
<option value="business">🏢 Business (Données Métier)</option>
@@ -649,13 +653,11 @@ export const FileUploadComponent = ({
<div className="relative">
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
onChange={(e) => setSelectedCategory?.(e.target.value)}
className="w-full appearance-none bg-white border border-gray-300 text-gray-700 text-xs rounded-md pl-3 pr-8 py-2 focus:outline-none focus:ring-1 focus:ring-[#f7ab6e] focus:border-[#f7ab6e] transition-colors duration-200"
>
<option value="pii">🔒 PII (Données Personnelles)</option>
<option value="business">
🏢 Business (Données Métier)
</option>
<option value="business">🏢 Business (Données Métier)</option>
<option value="pii_business">🔒🏢 PII + Business </option>
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700">

View File

@@ -28,11 +28,10 @@ export const InteractiveTextEditor: React.FC<InteractiveTextEditorProps> = ({
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 { getCurrentColor } = useColorMapping(entityMappings);
const {
contextMenu,
showContextMenu,
@@ -42,7 +41,7 @@ export const InteractiveTextEditor: React.FC<InteractiveTextEditorProps> = ({
getExistingLabels,
} = useContextMenu({
entityMappings,
words, // NOUVEAU: passer les mots
words,
onUpdateMapping,
onRemoveMapping,
getCurrentColor,
@@ -50,15 +49,10 @@ export const InteractiveTextEditor: React.FC<InteractiveTextEditorProps> = ({
});
const handleWordClick = useCallback(
(index: number, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
// Support multi-sélection avec Ctrl, Cmd et Shift
const isMultiSelect = event.ctrlKey || event.metaKey || event.shiftKey;
if (isMultiSelect) {
setSelectedWords((prev) => {
(index: number | null, event: React.MouseEvent) => {
if (index !== null) {
event.preventDefault();
setSelectedWords(prev => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
@@ -67,53 +61,87 @@ export const InteractiveTextEditor: React.FC<InteractiveTextEditorProps> = ({
}
return newSet;
});
} else {
setSelectedWords(new Set([index]));
}
},
[]
);
const handleContextMenu = useCallback(
const handleContainerContextMenu = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
if (selectedWords.size === 0) return;
// Priorité à la sélection de texte libre
const selection = window.getSelection();
if (selection && selection.toString().trim()) {
const selectedText = selection.toString().trim();
showContextMenu({
x: event.clientX,
y: event.clientY,
selectedText,
wordIndices: [], // Sélection libre, pas d'indices de mots
});
return;
}
// Fallback sur la sélection par mots si pas de sélection libre
if (selectedWords.size > 0) {
const selectedText = Array.from(selectedWords)
.sort((a, b) => a - b)
.map((index) => {
const word = words[index];
return word?.isEntity ? word.text : word?.text;
})
.filter(Boolean)
.join(" ");
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),
});
showContextMenu({
x: event.clientX,
y: event.clientY,
selectedText,
wordIndices: Array.from(selectedWords),
});
}
},
[selectedWords, words, showContextMenu]
);
const handleClearSelection = useCallback(() => {
setSelectedWords(new Set());
// Effacer la sélection de texte native
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
}
}, []);
return (
<div ref={containerRef} className="relative">
<div className="mb-2">
<button
onClick={handleClearSelection}
className="px-3 py-1 bg-gray-200 hover:bg-gray-300 rounded text-sm"
>
Effacer la sélection
</button>
{selectedWords.size > 0 && (
<span className="ml-2 text-sm text-gray-600">
{selectedWords.size} mot(s) sélectionné(s)
</span>
)}
</div>
<TextDisplay
words={words}
text={text}
selectedWords={selectedWords}
hoveredWord={hoveredWord}
onContextMenu={handleContainerContextMenu}
onWordClick={handleWordClick}
onContextMenu={handleContextMenu}
onWordHover={setHoveredWord}
/>
{contextMenu.visible && (
<ContextMenu
contextMenu={contextMenu}
existingLabels={getExistingLabels()}
// entityMappings={entityMappings} // SUPPRIMER cette ligne
onApplyLabel={applyLabel}
onApplyColor={applyColorDirectly}
onRemoveLabel={removeLabel}

View File

@@ -6,44 +6,34 @@ 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;
onWordClick: (index: number | null, event: React.MouseEvent) => void;
}
export const TextDisplay: React.FC<TextDisplayProps> = ({
words,
text,
selectedWords,
hoveredWord,
onWordClick,
onContextMenu,
onWordHover,
onWordClick,
}) => {
const renderWord = (word: Word, index: number) => {
const isSelected = selectedWords.has(index);
const isHovered = hoveredWord === index;
let className =
"inline-block cursor-pointer transition-all duration-200 rounded-sm ";
let className = "inline-block transition-all duration-200 rounded-sm cursor-pointer select-text ";
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 `;
@@ -53,55 +43,39 @@ export const TextDisplay: React.FC<TextDisplayProps> = ({
}
}
// 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 += "ring-2 ring-gray-400 bg-gray-100 ";
} else {
className += "hover:bg-yellow-100 ";
}
className += "brightness-95 ";
return (
<span
key={index}
data-word-index={index}
className={className}
style={{
backgroundColor: backgroundColor,
userSelect: "none",
WebkitUserSelect: "none",
}}
onMouseEnter={() => onWordHover(index)}
onMouseLeave={() => onWordHover(null)}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey) {
e.preventDefault();
e.stopPropagation();
}
onWordClick(index, e);
}}
onContextMenu={onContextMenu}
onMouseDown={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey) {
e.preventDefault();
}
}}
onClick={(event) => onWordClick(index, event)}
title={
word.isEntity
? `Entité: ${word.entityType} (Original: ${word.text})`
: "Cliquez pour sélectionner"
: "Sélectionnez librement le texte ou cliquez sur les mots"
}
>
{word.displayText}{" "}
{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">
<div
className="p-4 bg-white border border-gray-200 rounded-lg min-h-[300px] leading-relaxed text-sm select-text"
onContextMenu={onContextMenu}
>
<div className="whitespace-pre-wrap select-text">
{words.map((word, index) => {
const nextWord = words[index + 1];
const spaceBetween = nextWord
@@ -111,7 +85,7 @@ export const TextDisplay: React.FC<TextDisplayProps> = ({
return (
<React.Fragment key={index}>
{renderWord(word, index)}
<span>{spaceBetween}</span>
<span className="select-text">{spaceBetween}</span>
</React.Fragment>
);
})}

View File

@@ -15,12 +15,14 @@ import { EntityMapping } from "./config/entityLabels"; // Importer l'interface u
export default function Home() {
const [sourceText, setSourceText] = useState("");
const [outputText, setOutputText] = useState("");
const [anonymizedText, setAnonymizedText] = useState(""); // Nouveau state pour le texte anonymisé de Presidio
const [anonymizedText, setAnonymizedText] = useState("");
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoadingFile, setIsLoadingFile] = useState(false);
const [entityMappings, setEntityMappings] = useState<EntityMapping[]>([]);
const [isExampleLoaded, setIsExampleLoaded] = useState(false);
// Remove this unused state variable:
// const [isExampleLoaded, setIsExampleLoaded] = useState(false);
const [selectedCategory, setSelectedCategory] = useState("pii");
const progressSteps = ["Téléversement", "Prévisualisation", "Anonymisation"];
@@ -38,7 +40,8 @@ export default function Home() {
setError(null);
setIsLoadingFile(false);
setEntityMappings([]);
setIsExampleLoaded(false);
// Remove this line: setIsExampleLoaded(false);
setSelectedCategory("pii");
};
// Fonction pour mettre à jour les mappings depuis l'éditeur interactif
@@ -67,13 +70,15 @@ export default function Home() {
const { copyToClipboard, downloadText } = useDownloadActions({
outputText,
entityMappings,
anonymizedText, // Passer le texte anonymisé de Presidio
anonymizedText,
sourceText, // Ajouter le texte source
});
// Fonction wrapper pour appeler anonymizeData avec les bonnes données
const handleAnonymize = (category?: string) => {
anonymizeData({ file: uploadedFile, text: sourceText, category });
};
// Remove unused function or update the onAnonymize prop
// const handleAnonymize = (category?: string) => {
// anonymizeData({ file: uploadedFile, text: sourceText, category });
// };
return (
<div className="min-h-screen w-full overflow-hidden">
@@ -91,21 +96,19 @@ export default function Home() {
sourceText={sourceText}
setSourceText={setSourceText}
setUploadedFile={setUploadedFile}
onAnonymize={handleAnonymize}
onAnonymize={(category: string) => anonymizeData({ file: uploadedFile, text: sourceText, category })}
isProcessing={isProcessing}
canAnonymize={
uploadedFile !== null ||
Boolean(sourceText && sourceText.trim())
}
canAnonymize={!!sourceText.trim()}
isLoadingFile={isLoadingFile}
onRestart={handleRestart}
outputText={outputText}
copyToClipboard={copyToClipboard}
downloadText={downloadText}
isExampleLoaded={isExampleLoaded}
setIsExampleLoaded={setIsExampleLoaded}
// Remove this line: setIsExampleLoaded={setIsExampleLoaded}
entityMappings={entityMappings}
onMappingsUpdate={handleMappingsUpdate}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
/>
</div>
</div>
@@ -114,7 +117,10 @@ export default function Home() {
{outputText && (
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
<div className="p-1 sm:p-3">
<EntityMappingTable mappings={entityMappings} />
<EntityMappingTable
mappings={entityMappings}
selectedCategory={selectedCategory}
/>
</div>
</div>
)}

View File

@@ -82,7 +82,13 @@ export const generateAnonymizedText = (
let replacement = mapping.replacementValue;
if (!replacement) {
// Priorité : displayName modifié > displayName original > entity_type
replacement = mapping.displayName || `[${mapping.entity_type}]`;
// Si displayName ne contient pas de crochets, les ajouter
if (mapping.displayName && !mapping.displayName.startsWith('[')) {
replacement = `[${mapping.displayName}]`;
}
}
result += replacement;

725
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,10 +17,10 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.1",
"jspdf": "^4.0.0",
"lucide-react": "^0.514.0",
"mammoth": "^1.9.1",
"next": "15.3.3",
"next": "^16.1.6",
"patch-package": "^8.0.0",
"pdf-parse": "^1.1.1",
"react": "^19.0.0",

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,9 +23,19 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}