Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
parseCSVToPVs,
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 = parseCSVToPVs(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) => {
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]) => {
values.forEach((value) => {
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