import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Box, Paper, Typography, TextField, Button, MenuItem, Select, FormControl, InputLabel, Alert, Table, TableHead, TableRow, TableCell, TableBody, TableContainer, Checkbox, Tooltip, CircularProgress, Chip, } from '@mui/material'; import { FileSpreadsheet as CsvIcon, FolderOpen as FolderOpenIcon, Upload as UploadIcon, Save as SaveIcon, FileText as FileTextIcon, } from 'lucide-react'; import api from '../api'; const CONFLICT_POLICIES = [ { value: 'skip', label: 'Skip', help: 'Keep the existing entry; ignore the CSV row.' }, { value: 'overwrite', label: 'Overwrite', help: "Replace the existing entry's prompt and audio." }, { value: 'rename', label: 'Rename', help: 'Add as a new entry with a numeric suffix (e.g. track_2.wav). Always copies into data/.' }, ]; export default function CsvImportPanel({ onCommitted }) { const [folderPath, setFolderPath] = useState(''); const [csvFile, setCsvFile] = useState(null); const [preview, setPreview] = useState(null); const [previewError, setPreviewError] = useState(''); const [previewLoading, setPreviewLoading] = useState(false); const [selected, setSelected] = useState({}); const [conflictPolicy, setConflictPolicy] = useState('skip'); const [copyFiles, setCopyFiles] = useState(true); const [committing, setCommitting] = useState(false); const [message, setMessage] = useState(''); const [error, setError] = useState(''); const [isDocker, setIsDocker] = useState(false); const [uploadingFolder, setUploadingFolder] = useState(false); const folderInputRef = useRef(null); const csvInputRef = useRef(null); useEffect(() => { api.get('/api/environment') .then(({ data }) => setIsDocker(!!data?.docker)) .catch(() => {}); }, []); const pickFolder = async () => { setError(''); try { const { data } = await api.post('/api/pick-folder', { start_dir: folderPath || undefined }); if (data?.path) setFolderPath(data.path); } catch (exc) { setError(exc.response?.data?.error || exc.message); } }; const openFolderUpload = () => { setError(''); if (folderInputRef.current) { folderInputRef.current.value = ''; folderInputRef.current.click(); } }; const handleFolderSelected = async (event) => { const fileList = Array.from(event.target.files || []); if (fileList.length === 0) return; setError(''); setUploadingFolder(true); try { const form = new FormData(); fileList.forEach((file) => { form.append('files', file); form.append('rel_paths', file.webkitRelativePath || file.name); }); const { data } = await api.post('/api/upload-folder', form); if (data?.path) setFolderPath(data.path); } catch (exc) { setError(exc.response?.data?.error || exc.message); } finally { setUploadingFolder(false); } }; const openCsvPicker = () => { if (csvInputRef.current) { csvInputRef.current.value = ''; csvInputRef.current.click(); } }; const handleCsvSelected = (event) => { const file = event.target.files?.[0]; if (file) { setCsvFile(file); setPreview(null); setSelected({}); setMessage(''); setError(''); } }; const runPreview = useCallback(async () => { if (!csvFile || !folderPath) return; setPreviewLoading(true); setPreviewError(''); setPreview(null); setMessage(''); setError(''); try { const form = new FormData(); form.append('csv', csvFile); form.append('audio_folder', folderPath); const { data } = await api.post('/api/import-csv/preview', form); setPreview(data); const sel = {}; (data.rows || []).forEach((r, i) => { sel[i] = r.audio_found && r.errors.length === 0; }); setSelected(sel); } catch (exc) { setPreviewError(exc.response?.data?.error || exc.message); } finally { setPreviewLoading(false); } }, [csvFile, folderPath]); const toggleSelected = (idx) => { setSelected((prev) => ({ ...prev, [idx]: !prev[idx] })); }; const toggleAll = () => { if (!preview) return; const eligible = preview.rows .map((r, i) => ({ r, i })) .filter(({ r }) => r.audio_found && r.errors.length === 0); const allSelected = eligible.every(({ i }) => selected[i]); const next = { ...selected }; eligible.forEach(({ i }) => { next[i] = !allSelected; }); setSelected(next); }; const commit = async () => { if (!preview) return; setError(''); setMessage(''); setCommitting(true); try { const entries = preview.rows .filter((_, i) => selected[i]) .map((r) => ({ file_name: r.file_name, prompt: r.prompt, src_path: r.src_path, })); if (entries.length === 0) { setError('No rows selected.'); setCommitting(false); return; } const { data } = await api.post('/api/import-csv/commit', { entries, conflict_policy: conflictPolicy, copy_files: copyFiles, }); setMessage(data.message || 'Imported.'); if (onCommitted) onCommitted(); setPreview(null); setSelected({}); setCsvFile(null); } catch (exc) { setError(exc.response?.data?.error || exc.message); } finally { setCommitting(false); } }; const selectedCount = Object.values(selected).filter(Boolean).length; const selectedConflictCount = preview ? preview.rows.filter((r, i) => selected[i] && r.conflict).length : 0; const policyHelp = CONFLICT_POLICIES.find((p) => p.value === conflictPolicy)?.help || ''; return ( Import CSV + Audio Folder Upload a CSV with file_name and prompt columns plus the folder containing those audio files. Rows merge into the same dataset as the other modes — duplicate filenames follow the conflict policy you choose. setFolderPath(e.target.value)} placeholder="Click Browse to choose a folder…" sx={{ flexGrow: 1, minWidth: 260 }} InputProps={{ readOnly: true }} /> {isDocker ? ( <> ) : ( )} {csvFile ? csvFile.name : 'No CSV chosen.'} {previewError && {previewError}} {error && {error}} {message && {message}} {preview && ( <> 0 ? 'warning' : 'default'} label={`${preview.conflicts} conflict${preview.conflicts === 1 ? '' : 's'}`} /> 0 ? 'error' : 'default'} label={`${preview.missing_audio} missing audio`} /> 0 && preview.rows.every((r, i) => !r.audio_found || r.errors.length > 0 || selected[i] ) } indeterminate={ preview.rows.some((_, i) => selected[i]) && !preview.rows.every((r, i) => !r.audio_found || r.errors.length > 0 || selected[i] ) } onChange={toggleAll} /> File Prompt Status {preview.rows.map((row, idx) => { const blocked = !row.audio_found || row.errors.length > 0; return ( toggleSelected(idx)} disabled={blocked} /> {row.file_name || '(empty)'} {row.prompt} {row.conflict && ( )} {!row.audio_found && ( )} {row.errors.map((err, i) => ( ))} {!row.conflict && row.audio_found && row.errors.length === 0 && ( )} ); })}
On conflict {policyHelp} {selectedConflictCount > 0 && ( <> {selectedConflictCount} of the selected rows conflict with existing entries. )} )}
); }