Skip to content
317 changes: 317 additions & 0 deletions src/components/TagsCSVImportDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
import { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Alert,
CircularProgress,
Chip,
Stack,
} from '@mui/material';
import { Upload } from '@mui/icons-material';
import {
parseCSVToPVsAsync,
createTagMappingForTags,
createValidationSummaryForTags,
ParsedCSVRow,
} from '../utils/csvParser';

interface TagsCSVImportDialogProps {
open: boolean;
onClose: () => void;
onImport: (data: ParsedCSVRow[]) => Promise<void>;
availableTagGroups: Array<{
id: string;
name: string;
tags: Array<{ id: string; name: string }>;
}>;
}

export function TagsCSVImportDialog({
open,
onClose,
onImport,
availableTagGroups,
}: TagsCSVImportDialogProps) {
const [csvData, setCSVData] = useState<ParsedCSVRow[]>([]);
const [parseErrors, setParseErrors] = useState<string[]>([]);
const [validationSummary, setValidationSummary] = useState<string>('');
const [importing, setImporting] = useState(false);
const [fileSelected, setFileSelected] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [importableTagCount, setImportableTagCount] = useState<number>(0);

const handleClose = () => {
setCSVData([]);
setParseErrors([]);
setValidationSummary('');
setFileSelected(false);
setImporting(false);
setImportError(null);
onClose();
};

const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
const inputElement = event.target;
if (!file) return;

try {
const content = await file.text();
const result = await parseCSVToPVsAsync(content);

if (result.errors.length > 0) {
setParseErrors(result.errors);
setCSVData([]);
setValidationSummary('');
setFileSelected(false);
return;
}

setCSVData(result.data);
setParseErrors([]);
setFileSelected(true);

// Validate tags and calculate importable count
if (result.data.length > 0) {
// Collect all new groups, duplicate values, and track unique tags across all rows
const allNewGroups = new Set<string>();
const allDuplicateValues: Record<string, Set<string>> = {};
const allUniqueTags = new Set<string>();

result.data.forEach((row: ParsedCSVRow) => {
const mapping = createTagMappingForTags(row.groups, availableTagGroups);

mapping.newGroups.forEach((group) => allNewGroups.add(group));

// Track all unique tags from CSV
Object.entries(row.groups).forEach(([group, values]: [string, string[]]) => {
values.forEach((value: string) => {
allUniqueTags.add(`${group}:${value}`);
});
});

Object.entries(mapping.duplicateValues).forEach(([group, values]) => {
if (!allDuplicateValues[group]) {
allDuplicateValues[group] = new Set();
}
values.forEach((value) => allDuplicateValues[group].add(value));
});
});

// Calculate total duplicate tag count
let totalDuplicateTags = 0;
Object.values(allDuplicateValues).forEach((values) => {
totalDuplicateTags += values.size;
});

// Calculate importable tag count (total - duplicates)
const importableCount = allUniqueTags.size - totalDuplicateTags;
setImportableTagCount(Math.max(0, importableCount));

// Convert sets to arrays
const newGroups = Array.from(allNewGroups);
const duplicateValues: Record<string, string[]> = {};
Object.entries(allDuplicateValues).forEach(([group, valueSet]) => {
duplicateValues[group] = Array.from(valueSet);
});

const summary = createValidationSummaryForTags(newGroups, duplicateValues);
setValidationSummary(summary);
}
} catch (error) {
setParseErrors([
`Failed to read CSV file: ${error instanceof Error ? error.message : 'Unknown error'}`,
]);
setCSVData([]);
setValidationSummary('');
setFileSelected(false);
}

// Reset file input
inputElement.value = '';
};

const handleImport = async () => {
if (csvData.length === 0) return;

setImporting(true);
setImportError(null);
try {
await onImport(csvData);
handleClose();
} catch (error) {
setImportError(`Import failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setImporting(false);
}
};

return (
<Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
<DialogTitle>Import Tags from CSV</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
{/* File Upload Section */}
<Box>
<label htmlFor="tags-csv-file-input">
<input
accept=".csv"
style={{ display: 'none' }}
id="tags-csv-file-input"
type="file"
onChange={handleFileSelect}
/>
<Button
variant="contained"
component="span"
startIcon={<Upload />}
disabled={importing}
>
Select CSV File
</Button>
</label>
</Box>

{/* CSV Format Instructions */}
<Alert severity="info">
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>CSV Format Requirements:</strong>
</Typography>
<Typography variant="body2" component="div">
• Any column names will be treated as tag groups
<br />
• Tag values can be comma-separated (e.g., &ldquo;tag1, tag2&rdquo;)
<br />
• Empty cells or values &ldquo;nan&rdquo;/&ldquo;none&rdquo; will be ignored
<br />• Existing tag groups and tags will be preserved
</Typography>
</Alert>

{/* Import Error */}
{importError && (
<Alert severity="error">
<Typography variant="body2">{importError}</Typography>
</Alert>
)}

{/* Parse Errors */}
{parseErrors.length > 0 && (
<Alert severity="error">
{parseErrors.map((error) => (
<Typography key={error} variant="body2">
{error}
</Typography>
))}
</Alert>
)}

{/* Validation Summary */}
{fileSelected && validationSummary && (
<Alert severity={validationSummary.includes('Duplicate') ? 'warning' : 'success'}>
<Typography variant="body2">{validationSummary}</Typography>
{validationSummary.includes('Duplicate') && (
<Typography variant="caption" sx={{ mt: 1, display: 'block' }}>
Note: Duplicate values will be skipped. New groups and values will be created.
</Typography>
)}
</Alert>
)}

{/* Preview Table */}
{csvData.length > 0 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Preview ({csvData.length} row{csvData.length !== 1 ? 's' : ''})
</Typography>
<TableContainer component={Paper} sx={{ maxHeight: 400 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ width: 200 }}>Tag Group</TableCell>
<TableCell>Values</TableCell>
<TableCell align="right">Count</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(
csvData.reduce(
(acc, row) => {
Object.entries(row.groups).forEach(([groupName, values]) => {
if (!acc[groupName]) {
acc[groupName] = new Map();
}
values.forEach((value) => {
acc[groupName].set(value, (acc[groupName].get(value) || 0) + 1);
});
});
return acc;
},
{} as Record<string, Map<string, number>>
)
).map(([groupName, valueCounts]) => (
<TableRow key={groupName}>
<TableCell>
<Typography variant="subtitle2" fontWeight="bold">
{groupName}
</Typography>
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{Array.from(valueCounts.entries()).map(([value, count]) => (
<Chip
key={`${groupName}-${value}`}
label={count > 1 ? `${value} (${count})` : value}
size="small"
variant="outlined"
/>
))}
</Box>
</TableCell>
<TableCell align="right">
<Typography variant="body2" color="text.secondary">
{Array.from(valueCounts.values()).reduce(
(sum, count) => sum + count,
0
)}{' '}
total
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={importing}>
Cancel
</Button>
<Button
onClick={handleImport}
variant="contained"
disabled={csvData.length === 0 || importing}
startIcon={importing ? <CircularProgress size={16} /> : undefined}
>
{importing
? 'Importing...'
: `Import ${importableTagCount} tag${importableTagCount !== 1 ? 's' : ''}`}
</Button>
</DialogActions>
</Dialog>
);
}
Loading
Loading