diff --git a/src-tauri/src/importer/csv_importer.rs b/src-tauri/src/importer/csv_importer.rs index 9a26911..55c96de 100644 --- a/src-tauri/src/importer/csv_importer.rs +++ b/src-tauri/src/importer/csv_importer.rs @@ -1,9 +1,11 @@ -use crate::core::AppState; +use crate::core::{query_engine::QueryEngine, AppState}; use anyhow::{anyhow, Result}; use csv::ReaderBuilder; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs::File; +use std::io::Read; +use std::time::UNIX_EPOCH; use tauri::{AppHandle, Emitter, State}; use uuid::Uuid; @@ -19,38 +21,165 @@ pub struct CsvImportOptions { pub delimiter: char, pub skip_rows: u32, pub batch_size: usize, + pub preview_file_size: u64, + pub preview_modified_ns: String, } -#[tauri::command] -pub async fn preview_csv( - file_path: String, - delimiter: char, +#[derive(Serialize, Debug)] +pub struct CsvPreview { + pub columns: Vec, + pub source_keys: Vec, + pub rows: Vec>, + pub total_rows: u64, + pub file_size: u64, + pub modified_ns: String, +} + +struct CsvInspection { + columns: Vec, + source_keys: Vec, + rows: Vec>, + total_rows: u64, +} + +fn delimiter_byte(delimiter: char) -> Result { + if delimiter.is_ascii() && delimiter != '\n' && delimiter != '\r' && delimiter != '"' { + Ok(delimiter as u8) + } else { + Err(anyhow!( + "Choose a single ASCII separator other than a quote or newline" + )) + } +} + +fn file_stamp(path: &str) -> Result<(u64, String)> { + let metadata = std::fs::metadata(path)?; + let modified = metadata.modified()?.duration_since(UNIX_EPOCH)?.as_nanos(); + Ok((metadata.len(), modified.to_string())) +} + +fn inspect_reader( + input: R, + delimiter: u8, has_header: bool, skip_rows: u32, -) -> Result>, String> { - let file = File::open(&file_path).map_err(|e| e.to_string())?; +) -> Result { let mut reader = ReaderBuilder::new() - .delimiter(delimiter as u8) - .has_headers(has_header) - .from_reader(file); - - let mut preview = Vec::new(); - - // Skip rows + .delimiter(delimiter) + .has_headers(false) + .from_reader(input); let mut records = reader.records(); - for _ in 0..skip_rows { - if records.next().is_none() { - break; + let first = records + .next() + .ok_or_else(|| anyhow!("CSV file is empty"))??; + if first.is_empty() { + return Err(anyhow!("CSV file has no columns")); + } + let (columns, source_keys, first_data) = if has_header { + let columns: Vec = first.iter().map(|value| value.trim().to_string()).collect(); + if columns.iter().any(String::is_empty) { + return Err(anyhow!("CSV headers cannot be blank")); } + let mut seen = HashSet::new(); + if columns.iter().any(|name| !seen.insert(name.to_lowercase())) { + return Err(anyhow!("CSV headers must be unique")); + } + (columns.clone(), columns, None) + } else { + let columns = (1..=first.len()) + .map(|index| format!("Column {index}")) + .collect(); + let keys = (0..first.len()).map(|index| index.to_string()).collect(); + (columns, keys, Some(first)) + }; + let mut rows = Vec::new(); + let mut total_rows = 0; + for (index, record) in first_data.into_iter().map(Ok).chain(records).enumerate() { + let record = record?; + if record.len() != source_keys.len() { + return Err(anyhow!( + "CSV row {} has a different number of columns", + index + 1 + )); + } + if index < skip_rows as usize { + continue; + } + total_rows += 1; + if rows.len() < 10 { + rows.push(record.iter().map(str::to_string).collect()); + } + } + if total_rows == 0 { + return Err(anyhow!("CSV has no rows to import after skipped rows")); } + Ok(CsvInspection { + columns, + source_keys, + rows, + total_rows, + }) +} - for result in records.take(10) { - let record = result.map_err(|e| e.to_string())?; - let row: Vec = record.iter().map(|s| s.to_string()).collect(); - preview.push(row); +fn resolve_mapping( + source_keys: &[String], + mapping: &HashMap, +) -> Result> { + if mapping.keys().any(|key| !source_keys.contains(key)) { + return Err(anyhow!("Column mapping contains an unknown CSV column")); } + let mut result = Vec::new(); + let mut targets = HashSet::new(); + for (index, source) in source_keys.iter().enumerate() { + let target = if mapping.is_empty() { + Some(source) + } else { + mapping.get(source) + }; + if let Some(target) = target { + let target = target.trim(); + if target.is_empty() || !targets.insert(target.to_lowercase()) { + return Err(anyhow!( + "Mapped destination columns must be nonempty and unique" + )); + } + result.push((index, target.to_string())); + } + } + if result.is_empty() { + return Err(anyhow!("Map at least one CSV column")); + } + Ok(result) +} - Ok(preview) +#[tauri::command] +pub async fn preview_csv( + file_path: String, + delimiter: char, + has_header: bool, + skip_rows: u32, +) -> Result { + tauri::async_runtime::spawn_blocking(move || -> Result { + let delimiter = delimiter_byte(delimiter)?; + let (file_size, modified_ns) = file_stamp(&file_path)?; + let inspection = inspect_reader(File::open(&file_path)?, delimiter, has_header, skip_rows)?; + if file_stamp(&file_path)? != (file_size, modified_ns.clone()) { + return Err(anyhow!( + "The CSV changed while it was being previewed. Try again." + )); + } + Ok(CsvPreview { + columns: inspection.columns, + source_keys: inspection.source_keys, + rows: inspection.rows, + total_rows: inspection.total_rows, + file_size, + modified_ns, + }) + }) + .await + .map_err(|error| error.to_string())? + .map_err(|error| error.to_string()) } #[tauri::command] @@ -112,14 +241,41 @@ async fn do_import_csv( import_id: &str, options: &CsvImportOptions, ) -> Result<()> { - // 1. Open CSV file - let file = File::open(&options.file_path)?; - let mut reader = ReaderBuilder::new() - .delimiter(options.delimiter as u8) - .has_headers(options.has_header) - .from_reader(file); + if options.table_name.trim().is_empty() + || options.batch_size == 0 + || options.batch_size > 10_000 + { + return Err(anyhow!( + "Choose a target table and a batch size between 1 and 10000" + )); + } + let delimiter = delimiter_byte(options.delimiter)?; + let expected_stamp = ( + options.preview_file_size, + options.preview_modified_ns.clone(), + ); + if file_stamp(&options.file_path)? != expected_stamp { + return Err(anyhow!( + "The CSV changed since preview. Preview it again before importing." + )); + } + let inspection = inspect_reader( + File::open(&options.file_path)?, + delimiter, + options.has_header, + options.skip_rows, + )?; + if file_stamp(&options.file_path)? != expected_stamp { + return Err(anyhow!( + "The CSV changed since preview. Preview it again before importing." + )); + } + if !options.has_header && options.column_mapping.is_empty() { + return Err(anyhow!("Map columns when the CSV has no header")); + } + let mapping = resolve_mapping(&inspection.source_keys, &options.column_mapping)?; + let columns: Vec = mapping.iter().map(|(_, column)| column.clone()).collect(); - // 2. Detect DB type for proper quoting let db_type = { if manager .get_postgres_pools() @@ -137,24 +293,31 @@ async fn do_import_csv( } .ok_or_else(|| anyhow!("Connection not found"))?; - // 3. Create table if missing if options.create_table_if_missing { - create_table_if_not_exists( - manager, - connection_id, - &options.table_name, - &mut reader, - options.has_header, - &options.column_mapping, - ) - .await?; + create_table_if_not_exists(manager, connection_id, &options.table_name, &columns).await?; + } + let structure = + QueryEngine::get_table_structure(manager, connection_id, &options.table_name).await?; + if structure.columns.is_empty() { + return Err(anyhow!( + "Target table was not found. Check the table name and database." + )); + } + let target_columns: HashSet<&str> = structure + .columns + .iter() + .map(|column| column.name.as_str()) + .collect(); + if let Some(column) = columns + .iter() + .find(|column| !target_columns.contains(column.as_str())) + { + return Err(anyhow!( + "Destination column '{}' does not exist in the target table", + column + )); } - // 4. Prepare batch insert logic - let mut batch = Vec::new(); - let mut rows_processed = 0u64; - - // Get connection pool let pool_guard = match db_type { "postgres" => { let pools = manager.get_postgres_pools().await; @@ -179,59 +342,47 @@ async fn do_import_csv( } _ => return Err(anyhow!("Unsupported database type")), }; - - let headers = if options.has_header { - reader.headers()?.clone() - } else { - csv::StringRecord::new() + let sql = insert_sql(&options.table_name, &columns, db_type); + let mut tx = match &pool_guard { + InsertTarget::Postgres(pool) => CsvTransaction::Postgres(pool.begin().await?), + InsertTarget::MySql(pool) => CsvTransaction::MySql(pool.begin().await?), + InsertTarget::Sqlite(pool) => CsvTransaction::Sqlite(pool.begin().await?), }; - - for result in reader.records() { + let mut reader = ReaderBuilder::new() + .delimiter(delimiter) + .has_headers(options.has_header) + .from_reader(File::open(&options.file_path)?); + let mut rows_processed = 0u64; + for result in reader.records().skip(options.skip_rows as usize) { let record = result?; - batch.push(record); - - if batch.len() >= options.batch_size { - insert_batch( - &pool_guard, - &options.table_name, - &batch, - &options.column_mapping, - &headers, - db_type, - ) - .await?; - rows_processed += batch.len() as u64; - + if record.len() != inspection.source_keys.len() { + return Err(anyhow!("CSV column count changed during import")); + } + tx.insert(&sql, &record, &mapping).await?; + rows_processed += 1; + if rows_processed % options.batch_size as u64 == 0 { app_handle.emit( "import-progress", ImportProgress { import_id: import_id.to_string(), rows_processed, - total_rows: None, - percentage: None, + total_rows: Some(inspection.total_rows), + percentage: Some(rows_processed as f32 * 100.0 / inspection.total_rows as f32), status: "processing".to_string(), error: None, }, )?; - - batch.clear(); } } - - if !batch.is_empty() { - insert_batch( - &pool_guard, - &options.table_name, - &batch, - &options.column_mapping, - &headers, - db_type, - ) - .await?; - rows_processed += batch.len() as u64; + if rows_processed != inspection.total_rows || file_stamp(&options.file_path)? != expected_stamp + { + return Err(anyhow!( + "The CSV changed during import. No rows were committed." + )); } + tx.commit().await?; - app_handle.emit( + let _ = app_handle.emit( "import-progress", ImportProgress { import_id: import_id.to_string(), @@ -241,54 +392,12 @@ async fn do_import_csv( status: "complete".to_string(), error: None, }, - )?; + ); Ok(()) } -// InsertTarget moved to importer/mod.rs - -async fn insert_batch( - target: &InsertTarget, - table_name: &str, - batch: &[csv::StringRecord], - mapping: &HashMap, - headers: &csv::StringRecord, - db_type: &str, -) -> Result<()> { - if batch.is_empty() { - return Ok(()); - } - - // Identify columns to insert - let mut columns = Vec::new(); - let mut csv_indices = Vec::new(); - - if mapping.is_empty() && !headers.is_empty() { - // Auto-mapping if no specific mapping provided? - // Or should we require mapping? - // Let's assume headers match DB columns if no mapping. - for (i, h) in headers.iter().enumerate() { - columns.push(h.to_string()); - csv_indices.push(i); - } - } else { - for (csv_col, db_col) in mapping { - if let Some(pos) = headers.iter().position(|h| h == csv_col) { - columns.push(db_col.clone()); - csv_indices.push(pos); - } else if let Ok(idx) = csv_col.parse::() { - // If mapping is from index (0, 1, 2...) - columns.push(db_col.clone()); - csv_indices.push(idx); - } - } - } - - if columns.is_empty() { - return Err(anyhow!("No valid columns found for mapping")); - } - +fn insert_sql(table_name: &str, columns: &[String], db_type: &str) -> String { let quoted_table = match db_type { "mysql" => format!("`{}`", table_name.replace("`", "``")), _ => format!("\"{}\"", table_name.replace("\"", "\"\"")), @@ -309,87 +418,70 @@ async fn insert_batch( }) .collect(); - let sql = format!( + format!( "INSERT INTO {} ({}) VALUES ({})", quoted_table, quoted_columns.join(", "), placeholders.join(", ") - ); + ) +} - match target { - InsertTarget::Postgres(pool) => { - let mut tx = pool.begin().await?; - for record in batch { - let mut query = sqlx::query(&sql); - for &idx in &csv_indices { - let val = record.get(idx).unwrap_or(""); - query = query.bind(val); +enum CsvTransaction { + Postgres(sqlx::Transaction<'static, sqlx::Postgres>), + MySql(sqlx::Transaction<'static, sqlx::MySql>), + Sqlite(sqlx::Transaction<'static, sqlx::Sqlite>), +} + +impl CsvTransaction { + async fn insert( + &mut self, + sql: &str, + record: &csv::StringRecord, + mapping: &[(usize, String)], + ) -> Result<()> { + match self { + Self::Postgres(tx) => { + let mut query = sqlx::query(sql); + for (index, _) in mapping { + query = query.bind(record.get(*index).unwrap_or("")); } - query.execute(&mut *tx).await?; + query.execute(&mut **tx).await?; } - tx.commit().await?; - } - InsertTarget::MySql(pool) => { - let mut tx = pool.begin().await?; - for record in batch { - let mut query = sqlx::query(&sql); - for &idx in &csv_indices { - let val = record.get(idx).unwrap_or(""); - query = query.bind(val); + Self::MySql(tx) => { + let mut query = sqlx::query(sql); + for (index, _) in mapping { + query = query.bind(record.get(*index).unwrap_or("")); } - query.execute(&mut *tx).await?; + query.execute(&mut **tx).await?; } - tx.commit().await?; - } - InsertTarget::Sqlite(pool) => { - let mut tx = pool.begin().await?; - for record in batch { - let mut query = sqlx::query(&sql); - for &idx in &csv_indices { - let val = record.get(idx).unwrap_or(""); - query = query.bind(val); + Self::Sqlite(tx) => { + let mut query = sqlx::query(sql); + for (index, _) in mapping { + query = query.bind(record.get(*index).unwrap_or("")); } - query.execute(&mut *tx).await?; + query.execute(&mut **tx).await?; } - tx.commit().await?; } + Ok(()) } - Ok(()) + async fn commit(self) -> Result<()> { + match self { + Self::Postgres(tx) => tx.commit().await?, + Self::MySql(tx) => tx.commit().await?, + Self::Sqlite(tx) => tx.commit().await?, + } + Ok(()) + } } async fn create_table_if_not_exists( manager: &crate::core::connection_manager::ConnectionManager, connection_id: &Uuid, table_name: &str, - reader: &mut csv::Reader, - has_header: bool, - mapping: &HashMap, + columns: &[String], ) -> Result<()> { - // 1. Determine columns - let mut columns = Vec::new(); - if !mapping.is_empty() { - for db_col in mapping.values() { - columns.push(db_col.clone()); - } - } else if has_header { - let headers = reader.headers()?; - for h in headers.iter() { - columns.push(h.to_string()); - } - } else { - // We can't easily peek the reader here without consuming it if it's not clonable - // But we can assume some default or skip if no header and no mapping - return Err(anyhow!( - "Cannot create table without headers or column mapping" - )); - } - - if columns.is_empty() { - return Err(anyhow!("Could not determine columns for table creation")); - } - - // 2. Identify DB type + // Table creation is separate from the row transaction and may leave an empty table on failure. let db_type = { if manager .get_postgres_pools() @@ -458,3 +550,74 @@ async fn create_table_if_not_exists( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn preview_counts_rows_after_skipping_and_keeps_only_a_sample() { + let input = Cursor::new("name,age\nignored,0\nAda,37\nLin,42\n"); + let preview = inspect_reader(input, b',', true, 1).unwrap(); + assert_eq!(preview.columns, vec!["name", "age"]); + assert_eq!(preview.source_keys, vec!["name", "age"]); + assert_eq!(preview.total_rows, 2); + assert_eq!(preview.rows, vec![vec!["Ada", "37"], vec!["Lin", "42"]]); + } + + #[test] + fn preview_rejects_duplicate_headers_and_inconsistent_rows() { + assert!(inspect_reader(Cursor::new("id,id\n1,2\n"), b',', true, 0).is_err()); + assert!(inspect_reader(Cursor::new("id,name\n1\n"), b',', true, 0).is_err()); + } + + #[test] + fn mapping_rejects_duplicate_targets_and_missing_source_columns() { + let sources = vec!["id".to_string(), "name".to_string()]; + let duplicate = HashMap::from([ + ("id".to_string(), "same".to_string()), + ("name".to_string(), "same".to_string()), + ]); + assert!(resolve_mapping(&sources, &duplicate).is_err()); + let unknown = HashMap::from([("missing".to_string(), "id".to_string())]); + assert!(resolve_mapping(&sources, &unknown).is_err()); + assert_eq!( + resolve_mapping(&sources, &HashMap::new()).unwrap(), + vec![(0, "id".to_string()), (1, "name".to_string())] + ); + } + + #[test] + fn headerless_csv_uses_stable_index_keys() { + let preview = inspect_reader(Cursor::new("Ada,37\nLin,42\n"), b',', false, 0).unwrap(); + assert_eq!(preview.columns, vec!["Column 1", "Column 2"]); + assert_eq!(preview.source_keys, vec!["0", "1"]); + assert_eq!(preview.total_rows, 2); + } + + #[tokio::test] + async fn a_failed_insert_rolls_back_earlier_rows() { + let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE items (name TEXT UNIQUE)") + .execute(&pool) + .await + .unwrap(); + let mut tx = CsvTransaction::Sqlite(pool.begin().await.unwrap()); + let mapping = vec![(0, "name".to_string())]; + let record = csv::StringRecord::from(vec!["Ada"]); + tx.insert("INSERT INTO items (name) VALUES (?)", &record, &mapping) + .await + .unwrap(); + assert!(tx + .insert("INSERT INTO items (name) VALUES (?)", &record, &mapping) + .await + .is_err()); + drop(tx); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM items") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0); + } +} diff --git a/src/components/ImportDialog.tsx b/src/components/ImportDialog.tsx index 1f0b735..03c0d01 100644 --- a/src/components/ImportDialog.tsx +++ b/src/components/ImportDialog.tsx @@ -1,33 +1,50 @@ import { useState, useEffect } from 'react'; import { X, Upload, FileText, Database, Check, AlertCircle, Loader2, ChevronRight } from 'lucide-react'; -import { useDatabaseStore } from '../store/databaseStore'; +import { useDatabaseStore, type TableStructure } from '../store/databaseStore'; import { open } from '@tauri-apps/plugin-dialog'; +import { invoke } from '@tauri-apps/api/core'; import { invokeWithExecutionPolicy } from '../utils/executionPolicy'; +import { isSensitiveColumn } from '../utils/sensitiveData'; import { listen } from '@tauri-apps/api/event'; +interface CsvPreview { + columns: string[]; + source_keys: string[]; + rows: string[][]; + total_rows: number; + file_size: number; + modified_ns: string; +} + export const ImportDialog = () => { const { showImportDialog, setShowImportDialog, activeConnectionId, - activeDatabase + activeDatabase, + sidebarItems, + fetchSidebarItems, } = useDatabaseStore(); - const [step, setStep] = useState<'selection' | 'config' | 'mapping' | 'progress'>('selection'); + const [step, setStep] = useState<'selection' | 'config' | 'review' | 'progress'>('selection'); const [filePath, setFilePath] = useState(null); const [format, setFormat] = useState<'csv' | 'sql'>('csv'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [importId] = useState(() => Math.random().toString(36).substring(7)); + const [csvPreview, setCsvPreview] = useState(null); + const [targetColumns, setTargetColumns] = useState([]); + const [columnMapping, setColumnMapping] = useState>({}); + const [reviewTarget, setReviewTarget] = useState<{ connectionId: string; database: string } | null>(null); // CSV Options const [csvOptions, setCsvOptions] = useState({ tableName: '', - createTable: true, + createTable: false, hasHeader: true, delimiter: ',', skipRows: 0, - batchSize: 1000, + batchSize: 500, }); // SQL Options @@ -60,6 +77,9 @@ export const ImportDialog = () => { if (payload.status === 'complete' || payload.status === 'error') { setLoading(false); + if (payload.status === 'complete' && activeConnectionId) { + void fetchSidebarItems(activeConnectionId); + } } } }); @@ -67,7 +87,15 @@ export const ImportDialog = () => { return () => { unlisten.then(f => f()); }; - }, [showImportDialog, importId]); + }, [showImportDialog, importId, activeConnectionId, fetchSidebarItems]); + + const resetReview = () => { + setCsvPreview(null); + setTargetColumns([]); + setColumnMapping({}); + setReviewTarget(null); + setError(null); + }; const handleSelectFile = async () => { try { @@ -79,6 +107,7 @@ export const ImportDialog = () => { }); if (selected && !Array.isArray(selected)) { + resetReview(); setFilePath(selected); const ext = selected.split('.').pop()?.toLowerCase(); if (ext === 'sql') { @@ -93,24 +122,73 @@ export const ImportDialog = () => { } }; + const prepareCsvReview = async () => { + if (!filePath || !activeConnectionId || !activeDatabase || !csvOptions.tableName.trim()) return; + setLoading(true); + setError(null); + try { + const preview = await invoke('preview_csv', { + filePath, + delimiter: csvOptions.delimiter, + hasHeader: csvOptions.hasHeader, + skipRows: csvOptions.skipRows, + }); + const structure = await invoke('get_table_structure', { + connectionId: activeConnectionId, + tableName: csvOptions.tableName.trim(), + }); + const columns = structure.columns.map(column => column.name); + if (columns.length === 0 && !csvOptions.createTable) { + throw new Error('Target table not found. Choose an existing table or enable Create table.'); + } + const mapping = Object.fromEntries(preview.source_keys.flatMap((key, index) => { + const suggested = columns.length > 0 + ? columns.find(column => column.toLowerCase() === preview.columns[index].toLowerCase()) + : csvOptions.hasHeader ? preview.columns[index] : `column_${index + 1}`; + return suggested ? [[key, suggested]] : []; + })); + setCsvPreview(preview); + setTargetColumns(columns); + setColumnMapping(mapping); + setReviewTarget({ connectionId: activeConnectionId, database: activeDatabase }); + setStep('review'); + } catch (err) { + setError(String(err)); + } finally { + setLoading(false); + } + }; + const startImport = async () => { if (!filePath || !activeConnectionId || !activeDatabase) return; + if (format === 'csv' && (!csvPreview || reviewTarget?.connectionId !== activeConnectionId || reviewTarget?.database !== activeDatabase)) { + setError('The connection or database changed. Preview this CSV again.'); + return; + } + const mappedColumns = Object.values(columnMapping); + if (format === 'csv' && (mappedColumns.length === 0 || mappedColumns.some(value => !value.trim()) || new Set(mappedColumns.map(value => value.trim().toLowerCase())).size !== mappedColumns.length)) { + setError('Map at least one CSV column, with each destination used only once.'); + return; + } setError(null); setLoading(true); setStep('progress'); + setProgress(null); try { if (format === 'csv') { const options = { file_path: filePath, - table_name: csvOptions.tableName, + table_name: csvOptions.tableName.trim(), create_table_if_missing: csvOptions.createTable, has_header: csvOptions.hasHeader, delimiter: csvOptions.delimiter, skip_rows: csvOptions.skipRows, batch_size: csvOptions.batchSize, - column_mapping: {} // TODO: Implement mapping UI if needed + column_mapping: columnMapping, + preview_file_size: csvPreview!.file_size, + preview_modified_ns: csvPreview!.modified_ns, }; await invokeWithExecutionPolicy('import_csv', activeConnectionId, activeDatabase, { kind: 'csv_import', payload: options }, { importId, options }); @@ -126,11 +204,22 @@ export const ImportDialog = () => { } catch (err: any) { setError(err.toString()); setLoading(false); + setStep(format === 'csv' ? 'review' : 'config'); } }; if (!showImportDialog) return null; + const knownTables = (activeConnectionId ? sidebarItems[activeConnectionId] || [] : []) + .filter(item => item.item_type === 'Table') + .map(item => item.name); + const mappedTargets = Object.values(columnMapping); + const mappingValid = mappedTargets.length > 0 + && mappedTargets.every(value => value.trim().length > 0) + && new Set(mappedTargets.map(value => value.trim().toLowerCase())).size === mappedTargets.length; + const maskedPreviewColumns = csvPreview?.columns.map((column, index) => + isSensitiveColumn(column) || isSensitiveColumn(columnMapping[csvPreview.source_keys[index]] || '')) || []; + return (
@@ -146,8 +235,10 @@ export const ImportDialog = () => {

Import CSV or SQL dump into {activeDatabase}

-
@@ -191,7 +282,7 @@ export const ImportDialog = () => { {filePath?.split('/').pop()}
- +
@@ -203,20 +294,22 @@ export const ImportDialog = () => { setCsvOptions({...csvOptions, tableName: e.target.value})} - placeholder="new_table_name" + onChange={e => { resetReview(); setCsvOptions({...csvOptions, tableName: e.target.value}); }} + list="csv-target-tables" + placeholder="Select an existing table" className="w-full bg-[#252525] border border-[#333] rounded px-3 py-2 text-sm text-white focus:border-accent focus:outline-none placeholder:text-[#555]" /> + {knownTables.map(name =>
setCsvOptions({...csvOptions, createTable: e.target.checked})} + onChange={e => { resetReview(); setCsvOptions({...csvOptions, createTable: e.target.checked}); }} className="rounded border-[#333] bg-[#252525] text-accent" /> - +
@@ -225,7 +318,7 @@ export const ImportDialog = () => { { resetReview(); setCsvOptions({...csvOptions, skipRows: Math.max(0, Math.floor(Number(e.target.value) || 0))}); }} + className="w-full bg-[#252525] border border-[#333] rounded px-3 py-2 text-sm text-white focus:border-accent focus:outline-none" /> + ) : ( @@ -263,6 +362,68 @@ export const ImportDialog = () => {

)} + {error &&

{error}

} + + )} + + {step === 'review' && csvPreview && ( +
+
+ Review before import +

{csvPreview.total_rows.toLocaleString()} rows from {filePath?.split('/').pop()} into {activeDatabase}.{csvOptions.tableName.trim()}.

+

Rows are inserted in one transaction. A row error rolls them back. Creating a new table may leave an empty table if import fails.

+
+
+

Column mapping

+
+ {csvPreview.columns.map((column, index) => { + const key = csvPreview.source_keys[index]; + return
+ {column} + {targetColumns.length > 0 ? ( + + ) : ( + { + const value = event.target.value; + setColumnMapping(current => { + const next = { ...current }; + if (value.trim()) next[key] = value; + else delete next[key]; + return next; + }); + }} + placeholder="Skip column" + className="w-full bg-[#252525] border border-[#444] rounded px-2 py-1.5 text-white" /> + )} +
; + })} +
+ {!mappingValid &&

Map at least one column and use each destination only once.

} +
+
+

First {csvPreview.rows.length} rows

+
+ + {csvPreview.columns.map((column, index) => )} + {csvPreview.rows.map((row, rowIndex) => {row.map((value, columnIndex) => )})} +
{column}
{maskedPreviewColumns[columnIndex] ? 'Hidden' : value}
+
+
+ {error &&

{error}

}
)} @@ -288,8 +449,8 @@ export const ImportDialog = () => {

{progress?.status === 'complete' ? `Processed ${progress.rowsProcessed.toLocaleString()} rows` : - progress?.status === 'error' ? 'Something went wrong during import' : - `Total rows processed: ${progress?.rowsProcessed.toLocaleString() || 0}`} + progress?.status === 'error' ? (format === 'csv' ? 'No CSV rows were committed; check the error below.' : 'Import failed; check the error below.') : + `Rows staged: ${progress?.rowsProcessed.toLocaleString() || 0}`}

@@ -336,12 +497,23 @@ export const ImportDialog = () => { Back + + )} + + {step === 'review' && ( + <> + + )} @@ -353,6 +525,7 @@ export const ImportDialog = () => { setStep('selection'); setProgress(null); setFilePath(null); + resetReview(); }} className="px-6 py-2 bg-[#333] hover:bg-[#444] text-white text-xs font-bold rounded-lg transition-all" >