/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Data Connector UI - Import data from CSV files and map to IFC properties * * Full integration with CsvConnector from @ifc-lite/mutations */ import { useState, useCallback, useMemo, useRef, useEffect, type DragEvent } from 'react'; import { Upload, FileSpreadsheet, Link2, ArrowRight, Check, AlertCircle, Loader2, Trash2, Plus, Eye, Play, Wand2, ChevronRight, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Alert, AlertDescription, AlertTitle, } from '@/components/ui/alert'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Progress } from '@/components/ui/progress'; import { Separator } from '@/components/ui/separator'; import { useViewerStore } from '@/store'; import { useIfc } from '@/hooks/useIfc'; import { configureMutationView } from '@/utils/configureMutationView'; import { PropertyValueType } from '@ifc-lite/data'; import { CsvConnector, MutablePropertyView, type CsvRow, type MatchStrategy, type PropertyMapping, type DataMapping, type MatchResult, type ImportStats, type ImportProgress, } from '@ifc-lite/mutations'; import type { IfcDataStore } from '@ifc-lite/parser'; type MatchType = 'globalId' | 'expressId' | 'name' | 'property'; interface DataConnectorProps { trigger?: React.ReactNode; } interface CsvColumn { name: string; sampleValues: string[]; } interface MappingRow { id: string; sourceColumn: string; targetPset: string; targetProperty: string; valueType: PropertyValueType; } export function DataConnector({ trigger }: DataConnectorProps) { const { models } = useIfc(); const getMutationView = useViewerStore((s) => s.getMutationView); const registerMutationView = useViewerStore((s) => s.registerMutationView); // Also get legacy single-model state for backward compatibility const legacyIfcDataStore = useViewerStore((s) => s.ifcDataStore); const legacyGeometryResult = useViewerStore((s) => s.geometryResult); const fileInputRef = useRef(null); const scrollAreaRef = useRef(null); const [open, setOpen] = useState(false); const [selectedModelId, setSelectedModelId] = useState(''); // Raw CSV content const [csvContent, setCsvContent] = useState(''); const [fileName, setFileName] = useState(''); // Parsed CSV data for preview const [parsedRows, setParsedRows] = useState([]); const [csvColumns, setCsvColumns] = useState([]); // Matching configuration const [matchType, setMatchType] = useState('globalId'); const [matchColumn, setMatchColumn] = useState(''); const [matchPset, setMatchPset] = useState(''); const [matchProp, setMatchProp] = useState(''); // Property mappings const [mappings, setMappings] = useState([]); // Results const [matchResults, setMatchResults] = useState(null); const [importStats, setImportStats] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [importProgress, setImportProgress] = useState(null); const [error, setError] = useState(null); // Track whether config changed since last import (disables button after success) const [importDirty, setImportDirty] = useState(true); // Get list of models - includes both federated models and legacy single-model const modelList = useMemo(() => { const list = Array.from(models.values()).map((m) => ({ id: m.id, name: m.name, })); // If no models in Map but legacy data exists, add a synthetic entry if (list.length === 0 && legacyIfcDataStore) { list.push({ id: '__legacy__', name: 'Current Model', }); } return list; }, [models, legacyIfcDataStore]); // Get selected model's data - supports both federated and legacy mode const selectedModel = useMemo(() => { if (selectedModelId === '__legacy__' && legacyIfcDataStore && legacyGeometryResult) { // Return a synthetic FederatedModel-like object for legacy mode return { id: '__legacy__', name: 'Current Model', ifcDataStore: legacyIfcDataStore, geometryResult: legacyGeometryResult, visible: true, collapsed: false, }; } return models.get(selectedModelId); }, [models, selectedModelId, legacyIfcDataStore, legacyGeometryResult]); // Auto-select first model useMemo(() => { if (modelList.length > 0 && !selectedModelId) { setSelectedModelId(modelList[0].id); } }, [modelList, selectedModelId]); // Ensure mutation view exists for selected model useEffect(() => { if (!selectedModel?.ifcDataStore || !selectedModelId) return; // Check if mutation view already exists let mutationView = getMutationView(selectedModelId); if (mutationView) return; // Create new mutation view with on-demand property extractor const dataStore = selectedModel.ifcDataStore; mutationView = new MutablePropertyView(dataStore.properties || null, selectedModelId); configureMutationView(mutationView, dataStore as IfcDataStore); // Register the mutation view registerMutationView(selectedModelId, mutationView); }, [selectedModel, selectedModelId, getMutationView, registerMutationView]); // Create CsvConnector instance const csvConnector = useMemo(() => { if (!selectedModel?.ifcDataStore) return null; const mutationView = getMutationView(selectedModelId); if (!mutationView) return null; const dataStore = selectedModel.ifcDataStore; return new CsvConnector( dataStore.entities, mutationView, dataStore.strings || null ); }, [selectedModel, selectedModelId, getMutationView]); // Parse CSV file const handleFileSelect = useCallback((e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setFileName(file.name); setMatchResults(null); setImportStats(null); setError(null); setImportDirty(true); const reader = new FileReader(); reader.onload = (event) => { const text = event.target?.result as string; if (!text) return; setCsvContent(text); // Use CsvConnector to parse if available, otherwise do basic parsing for preview if (csvConnector) { try { const rows = csvConnector.parse(text); setParsedRows(rows); // Extract column names and sample values if (rows.length > 0) { const headers = Object.keys(rows[0]); const columns: CsvColumn[] = headers.map((name) => ({ name, sampleValues: rows.slice(0, 3).map((row) => row[name] || ''), })); setCsvColumns(columns); // Auto-detect match column const globalIdCol = columns.find( (c) => c.name.toLowerCase().includes('globalid') || c.name.toLowerCase().includes('guid') ); if (globalIdCol) { setMatchColumn(globalIdCol.name); setMatchType('globalId'); } // Auto-detect property mappings const autoMappings = csvConnector.autoDetectMappings(headers); const mappingRows: MappingRow[] = autoMappings.map((m, idx) => ({ id: `auto_${idx}_${Date.now()}`, sourceColumn: m.sourceColumn, targetPset: m.targetPset, targetProperty: m.targetProperty, valueType: m.valueType, })); // Filter out ID columns from auto mappings const filteredMappings = mappingRows.filter( (m) => !m.sourceColumn.toLowerCase().includes('globalid') && !m.sourceColumn.toLowerCase().includes('expressid') && !m.sourceColumn.toLowerCase().includes('guid') && m.sourceColumn.toLowerCase() !== 'id' ); if (filteredMappings.length > 0) { setMappings(filteredMappings); } } } catch (err) { setError(`Failed to parse CSV: ${err instanceof Error ? err.message : 'Unknown error'}`); } } else { // Basic parsing for preview before model selected const lines = text.split('\n').filter((line) => line.trim()); if (lines.length < 2) { setError('CSV must have at least a header row and one data row'); return; } const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, '')); const columns: CsvColumn[] = headers.map((name, idx) => ({ name, sampleValues: lines .slice(1, 4) .map((line) => { const values = line.split(','); return values[idx]?.trim().replace(/^"|"$/g, '') || ''; }), })); setCsvColumns(columns); // Auto-detect match column const globalIdCol = columns.find( (c) => c.name.toLowerCase().includes('globalid') || c.name.toLowerCase().includes('guid') ); if (globalIdCol) { setMatchColumn(globalIdCol.name); setMatchType('globalId'); } } }; reader.readAsText(file); e.target.value = ''; // Reset input }, [csvConnector]); // Re-parse when model changes and we have content const handleModelChange = useCallback((modelId: string) => { setSelectedModelId(modelId); setMatchResults(null); setImportStats(null); setError(null); setImportDirty(true); }, []); // Add a mapping row const addMapping = useCallback(() => { setMappings((prev) => [ ...prev, { id: `mapping_${Date.now()}`, sourceColumn: '', targetPset: 'Pset_Custom', targetProperty: '', valueType: PropertyValueType.String, }, ]); }, []); // Remove a mapping const removeMapping = useCallback((id: string) => { setMappings((prev) => prev.filter((m) => m.id !== id)); }, []); // Update a mapping const updateMapping = useCallback( (id: string, field: keyof MappingRow, value: string | number) => { setMappings((prev) => prev.map((m) => (m.id === id ? { ...m, [field]: value } : m)) ); }, [] ); // Auto-detect mappings const handleAutoDetect = useCallback(() => { if (!csvConnector || csvColumns.length === 0) return; const headers = csvColumns.map((c) => c.name); const autoMappings = csvConnector.autoDetectMappings(headers); const mappingRows: MappingRow[] = autoMappings .filter( (m) => !m.sourceColumn.toLowerCase().includes('globalid') && !m.sourceColumn.toLowerCase().includes('expressid') && !m.sourceColumn.toLowerCase().includes('guid') && m.sourceColumn.toLowerCase() !== 'id' ) .map((m, idx) => ({ id: `auto_${idx}_${Date.now()}`, sourceColumn: m.sourceColumn, targetPset: m.targetPset, targetProperty: m.targetProperty, valueType: m.valueType, })); setMappings(mappingRows); }, [csvConnector, csvColumns]); // Build DataMapping from UI state const buildDataMapping = useCallback((): DataMapping | null => { if (!matchColumn) return null; const matchStrategy: MatchStrategy = matchType === 'property' ? { type: 'property', psetName: matchPset, propName: matchProp, column: matchColumn } : { type: matchType, column: matchColumn }; const propertyMappings: PropertyMapping[] = mappings .filter((m) => m.sourceColumn && m.targetProperty) .map((m) => ({ sourceColumn: m.sourceColumn, targetPset: m.targetPset, targetProperty: m.targetProperty, valueType: m.valueType, })); return { matchStrategy, propertyMappings }; }, [matchColumn, matchType, matchPset, matchProp, mappings]); // Preview matches using CsvConnector.preview const handlePreview = useCallback(() => { if (!csvConnector || !csvContent || !matchColumn) return; setIsProcessing(true); setMatchResults(null); setImportStats(null); setError(null); try { const dataMapping = buildDataMapping(); if (!dataMapping) { setError('Invalid mapping configuration'); setIsProcessing(false); return; } // Use CsvConnector preview method const preview = csvConnector.preview(csvContent, dataMapping); setParsedRows(preview.rows); setMatchResults(preview.matches); } catch (err) { console.error('Preview failed:', err); setError(`Preview failed: ${err instanceof Error ? err.message : 'Unknown error'}`); } finally { setIsProcessing(false); } }, [csvConnector, csvContent, matchColumn, buildDataMapping]); // Import using CsvConnector.importAsync for non-blocking progress const handleImport = useCallback(async () => { if (!csvConnector || !csvContent) return; setIsProcessing(true); setImportStats(null); setImportProgress(null); setError(null); try { const dataMapping = buildDataMapping(); if (!dataMapping) { setError('Invalid mapping configuration'); setIsProcessing(false); return; } const stats = await csvConnector.importAsync( csvContent, dataMapping, (progress) => setImportProgress(progress) ); setImportStats(stats); setImportProgress(null); setImportDirty(false); if (stats.errors.length > 0) { setError(stats.errors.join('\n')); } } catch (err) { console.error('Import failed:', err); setError(`Import failed: ${err instanceof Error ? err.message : 'Unknown error'}`); } finally { setIsProcessing(false); } }, [csvConnector, csvContent, buildDataMapping]); // Scroll to bottom of the body area — double rAF ensures DOM is painted const scrollToBottom = useCallback(() => { requestAnimationFrame(() => { requestAnimationFrame(() => { scrollAreaRef.current?.scrollTo({ top: scrollAreaRef.current.scrollHeight, behavior: 'smooth', }); }); }); }, []); // Auto-scroll when import completes or errors appear useEffect(() => { if (importStats || error) scrollToBottom(); }, [importStats, error, scrollToBottom]); // Auto-scroll when progress first appears (so user sees the bar) const prevProgressRef = useRef(null); useEffect(() => { if (importProgress && !prevProgressRef.current) scrollToBottom(); prevProgressRef.current = importProgress; }, [importProgress, scrollToBottom]); // Mark config dirty when match/mapping settings change after a completed import useEffect(() => { if (importStats) setImportDirty(true); // eslint-disable-next-line react-hooks/exhaustive-deps -- only fire on config changes, not importStats itself }, [matchType, matchColumn, matchPset, matchProp, mappings]); // Stats from match results const matchStats = useMemo(() => { if (!matchResults) return null; const matched = matchResults.filter((r) => r.matchedEntityIds.length > 0).length; const unmatched = matchResults.filter((r) => r.matchedEntityIds.length === 0).length; const multiMatch = matchResults.filter((r) => r.matchedEntityIds.length > 1).length; const highConfidence = matchResults.filter((r) => r.confidence === 1).length; return { matched, unmatched, multiMatch, highConfidence, total: matchResults.length }; }, [matchResults]); // Convert parsed rows to array for table display const previewData = useMemo(() => { if (parsedRows.length === 0) return []; return parsedRows.slice(0, 5).map((row) => { return csvColumns.map((col) => row[col.name] || ''); }); }, [parsedRows, csvColumns]); // Drag-and-drop handlers const [isDragging, setIsDragging] = useState(false); const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }, []); const handleDragLeave = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }, []); const handleDrop = useCallback( (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const file = e.dataTransfer.files[0]; if (!file || !file.name.endsWith('.csv')) return; // Reuse the same file-reading logic via a synthetic event const dataTransfer = new DataTransfer(); dataTransfer.items.add(file); if (fileInputRef.current) { fileInputRef.current.files = dataTransfer.files; fileInputRef.current.dispatchEvent(new Event('change', { bubbles: true })); } }, [] ); // Derive the current step for the step indicator const currentStep = useMemo(() => { if (importStats) return 3; if (csvColumns.length > 0 && matchColumn && mappings.length > 0) return 2; if (csvColumns.length > 0) return 1; return 0; }, [csvColumns.length, matchColumn, mappings.length, importStats]); const steps = ['Upload CSV', 'Configure Mapping', 'Import']; return ( {trigger || ( )} {/* Fixed Header */} Import External Data Map CSV data to IFC entity properties {/* Step Indicator */}
{steps.map((step, idx) => (
{idx < currentStep ? ( ) : ( {idx + 1} )} {step}
{idx < steps.length - 1 && ( )}
))}
{/* Scrollable Body */}
{/* Model selector */}
{selectedModelId && !csvConnector && (

Note: MutationView not available for this model. Some features may be limited.

)}
{/* File Upload - Drag and Drop Zone */}
{!fileName ? (
fileInputRef.current?.click()} className={`flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-8 cursor-pointer transition-colors ${ isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-muted-foreground/50 hover:bg-muted/50' }`} >

{isDragging ? 'Drop CSV file here' : 'Drag & drop a CSV file'}

or click to browse

) : (
{fileName}
)}
{csvColumns.length > 0 && ( <> {/* CSV Preview */}
{csvColumns.map((col) => ( {col.name} ))} {previewData.map((row, rowIdx) => ( {row.map((cell, cellIdx) => ( {cell || '\u2014'} ))} ))}

{parsedRows.length > 0 ? `${parsedRows.length} rows parsed` : `${csvColumns[0]?.sampleValues.length || 0} sample rows`}

{/* Matching Configuration */}
{matchType === 'property' && (
setMatchPset(e.target.value)} placeholder="e.g., Pset_WallCommon" />
setMatchProp(e.target.value)} placeholder="e.g., Reference" />
)}
{/* Property Mappings */}
{csvConnector && ( )}
{mappings.length === 0 ? (

No property mappings configured

Click "Auto-detect" or "Add" to map CSV columns to IFC properties

) : (
{/* Column headers for mapping rows */}
Source Column Target Pset Target Property Type
{mappings.map((mapping) => (
updateMapping(mapping.id, 'targetPset', e.target.value) } className="h-8 text-xs" /> updateMapping(mapping.id, 'targetProperty', e.target.value) } className="h-8 text-xs" />
))}
)}
{/* Match Results */} {matchStats && ( Match Results {matchStats.matched} matched {matchStats.unmatched} unmatched {matchStats.highConfidence} high confidence {matchStats.multiMatch > 0 && ( {matchStats.multiMatch} multi-match )} )} {/* Live Import Progress */} {importProgress && (
{importProgress.phase === 'parsing' && 'Parsing CSV...'} {importProgress.phase === 'matching' && 'Matching entities...'} {importProgress.phase === 'applying' && 'Applying properties...'} {importProgress.matchedRows.toLocaleString()} matched {importProgress.mutationsCreated > 0 && ` \u00b7 ${importProgress.mutationsCreated.toLocaleString()} written`}
)} {/* Import Stats */} {importStats && ( Import Complete
{importStats.mutationsCreated} properties updated {importStats.matchedRows} rows matched {importStats.unmatchedRows} rows unmatched
{importStats.warnings.length > 0 && (
{importStats.warnings.length} warning(s)
)}
)} {/* Error Display */} {error && ( Error {error} )} )}
{/* Fixed Footer */}
); }