//! Native CSV/TSV import commands. //! //! GoingsOn imports CSV as its interchange format: platform-specific exports //! (Todoist, Things, etc.) are converted to CSV by separate tools, then brought //! in here. This replaces the former Rhai plugin runtime, the parsing and field //! mapping that lived in a sandboxed `.rhai` script are now native Rust, so there //! is no plugin sandbox, capability model, or provenance surface to secure. //! //! Flow: `preview_import` parses the file into typed items (dry run); //! `execute_import` creates the entities. Entity type (task/project/event) is //! auto-detected from the header columns. use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde::Deserialize; use tauri::State; use tracing::instrument; use goingson_core::{ ImportEntityType, ImportEventData, ImportExecuteResult, ImportFailure, ImportItem, ImportItemData, ImportOptions, ImportParseResult, ImportProjectData, ImportTaskData, NewEventBuilder, NewProject, NewTaskBuilder, ParseableEnum, Priority, ProjectId, }; use super::error::ApiError; use super::import_external::read_import_file; use crate::state::{AppState, DESKTOP_USER_ID}; /// Input for previewing a CSV import (dry run, no DB writes). #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PreviewImportInput { pub file_path: String, #[serde(default)] pub options: ImportOptions, } /// Input for executing a CSV import. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExecuteImportInput { pub file_path: String, #[serde(default)] pub options: ImportOptions, /// Indices of items to import (all if empty). #[serde(default)] pub selected_indices: Vec, } /// Previews a CSV/TSV import by parsing the file without creating entities. #[tauri::command] #[instrument(skip_all)] pub async fn preview_import(input: PreviewImportInput) -> Result { let content = read_import_file(&input.file_path)?; parse_csv_import(&content, &input.options) } /// Executes a CSV/TSV import, creating entities in the database. #[tauri::command] #[instrument(skip_all)] pub async fn execute_import( state: State<'_, Arc>, input: ExecuteImportInput, ) -> Result { let content = read_import_file(&input.file_path)?; let parsed = parse_csv_import(&content, &input.options)?; let projects = state .projects .list_all(DESKTOP_USER_ID) .await .map_err(ApiError::from)?; let project_list: Vec<(String, String)> = projects .iter() .map(|p| (p.id.to_string(), p.name.clone())) .collect(); let items: Vec<&ImportItem> = if input.selected_indices.is_empty() { parsed.items.iter().collect() } else { parsed .items .iter() .enumerate() .filter(|(idx, _)| input.selected_indices.contains(idx)) .map(|(_, item)| item) .collect() }; match parsed.entity_type { ImportEntityType::Task => import_tasks(&state, &items, &project_list).await, ImportEntityType::Project => import_projects(&state, &items).await, ImportEntityType::Event => import_events(&state, &items, &project_list).await, } } // CSV Parsing /// Parses CSV/TSV content into typed import items, auto-detecting the entity /// type from the header row. fn parse_csv_import(content: &str, options: &ImportOptions) -> Result { let rows = parse_csv_rows(content, options)?; if rows.is_empty() { return Ok(ImportParseResult { entity_type: ImportEntityType::Task, items: Vec::new(), warnings: vec!["The file contained no data rows.".to_string()], }); } let columns: HashSet<&str> = rows[0].keys().map(std::string::String::as_str).collect(); let entity_type = detect_entity_type(&columns); let (items, warnings) = match entity_type { ImportEntityType::Task => parse_tasks(&rows), ImportEntityType::Project => parse_projects(&rows), ImportEntityType::Event => parse_events(&rows), }; Ok(ImportParseResult { entity_type, items, warnings, }) } /// Reads CSV/TSV rows into maps keyed by lowercased header (case-insensitive /// field matching). Strips a leading BOM (Excel exports). With no header row, /// columns are keyed `col_0`, `col_1`, ... fn parse_csv_rows( content: &str, options: &ImportOptions, ) -> Result>, ApiError> { let content = content.strip_prefix('\u{FEFF}').unwrap_or(content); let delimiter = options.delimiter.unwrap_or(',') as u8; let mut reader = csv::ReaderBuilder::new() .has_headers(options.has_header) .delimiter(delimiter) .flexible(true) .from_reader(content.as_bytes()); let headers: Vec = if options.has_header { reader .headers() .map_err(|e| ApiError::validation_msg(format!("Failed to read CSV headers: {e}")))? .iter() .map(|s| s.trim().to_lowercase()) .collect() } else { Vec::new() }; let mut rows = Vec::new(); for result in reader.records() { let record = result.map_err(|e| ApiError::validation_msg(format!("CSV parse error: {e}")))?; let mut row = HashMap::new(); for (i, field) in record.iter().enumerate() { let key = headers .get(i) .cloned() .unwrap_or_else(|| format!("col_{i}")); row.insert(key, field.to_string()); } rows.push(row); } Ok(rows) } /// Detects the entity type from the (lowercased) header columns. fn detect_entity_type(columns: &HashSet<&str>) -> ImportEntityType { if columns.contains("start") || columns.contains("start_time") || columns.contains("start_date") { return ImportEntityType::Event; } if columns.contains("project_type") || (columns.contains("name") && !columns.contains("description")) { return ImportEntityType::Project; } ImportEntityType::Task } /// First non-empty value among the given (lowercased) candidate column names. fn get_field(row: &HashMap, names: &[&str]) -> Option { for name in names { if let Some(value) = row.get(*name) { let trimmed = value.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } } None } fn parse_tasks(rows: &[HashMap]) -> (Vec, Vec) { let mut items = Vec::new(); let mut warnings = Vec::new(); for (idx, row) in rows.iter().enumerate() { let Some(description) = get_field(row, &["description", "task", "title", "name", "subject"]) else { warnings.push(format!("Row {}: missing description, skipped.", idx + 1)); continue; }; let data = ImportTaskData { description, due: get_field(row, &["due", "due_date", "deadline", "date"]) .and_then(|d| normalize_date(&d)), priority: normalize_priority(get_field(row, &["priority", "pri", "importance"])), status: normalize_task_status(get_field(row, &["status", "state"])), project_name: get_field(row, &["project", "project_name", "category"]), tags: Some(parse_tags(get_field( row, &["tags", "labels", "categories"], ))), notes: get_field(row, &["notes", "note", "comments", "body"]), }; items.push(ImportItem { source_index: idx + 1, data: ImportItemData::Task(data), has_errors: false, errors: Vec::new(), }); } (items, warnings) } fn parse_projects(rows: &[HashMap]) -> (Vec, Vec) { let mut items = Vec::new(); let mut warnings = Vec::new(); for (idx, row) in rows.iter().enumerate() { let Some(name) = get_field(row, &["name", "project", "title"]) else { warnings.push(format!("Row {}: missing name, skipped.", idx + 1)); continue; }; let data = ImportProjectData { name, description: get_field(row, &["description", "desc", "notes"]), project_type: normalize_project_type(get_field( row, &["type", "project_type", "category"], )), status: normalize_project_status(get_field(row, &["status", "state"])), }; items.push(ImportItem { source_index: idx + 1, data: ImportItemData::Project(data), has_errors: false, errors: Vec::new(), }); } (items, warnings) } fn parse_events(rows: &[HashMap]) -> (Vec, Vec) { let mut items = Vec::new(); let mut warnings = Vec::new(); for (idx, row) in rows.iter().enumerate() { let Some(title) = get_field(row, &["title", "name", "event", "subject", "summary"]) else { warnings.push(format!("Row {}: missing title, skipped.", idx + 1)); continue; }; let Some(start_raw) = get_field(row, &["start", "start_time", "start_date", "date", "when"]) else { warnings.push(format!("Row {}: missing start time, skipped.", idx + 1)); continue; }; // Normalize if recognizable, otherwise keep the raw value so the // executor's parse_datetime gets a chance and reports a precise failure. let start = normalize_date(&start_raw).unwrap_or(start_raw); let data = ImportEventData { title, start, end: get_field(row, &["end", "end_time", "end_date"]).and_then(|e| normalize_date(&e)), location: get_field(row, &["location", "place", "venue", "where"]), description: get_field(row, &["description", "notes", "body", "details"]), project_name: get_field(row, &["project", "project_name", "category"]), }; items.push(ImportItem { source_index: idx + 1, data: ImportItemData::Event(data), has_errors: false, errors: Vec::new(), }); } (items, warnings) } /// Maps loose priority spellings to High/Medium/Low. `None` input → `None`. fn normalize_priority(value: Option) -> Option { let value = value?; Some( match value.to_lowercase().as_str() { "high" | "1" | "h" | "urgent" | "critical" => "High", "low" | "3" | "l" | "minor" => "Low", _ => "Medium", } .to_string(), ) } fn normalize_task_status(value: Option) -> Option { let value = value?; Some( match value.to_lowercase().as_str() { "done" | "complete" | "completed" | "finished" | "closed" => "Done", "in progress" | "inprogress" | "started" | "working" | "active" => "InProgress", _ => "Pending", } .to_string(), ) } fn normalize_project_type(value: Option) -> Option { let value = value?; Some( match value.to_lowercase().as_str() { "job" | "work" | "employment" => "Job", "side project" | "sideproject" | "personal" | "hobby" => "SideProject", "company" | "business" | "startup" => "Company", "essay" | "writing" => "Essay", "article" | "blog" | "post" => "Article", "painting" | "art" | "visual" => "Painting", _ => "Other", } .to_string(), ) } fn normalize_project_status(value: Option) -> Option { let value = value?; Some( match value.to_lowercase().as_str() { "on hold" | "onhold" | "paused" | "waiting" => "OnHold", "completed" | "done" | "finished" => "Completed", "archived" | "inactive" | "closed" => "Archived", _ => "Active", } .to_string(), ) } /// Splits a delimited tag string (`,`, `;`, or `|`) into trimmed, non-empty tags. fn parse_tags(value: Option) -> Vec { match value { None => Vec::new(), Some(v) => v .split([',', ';', '|']) .map(str::trim) .filter(|t| !t.is_empty()) .map(std::string::ToString::to_string) .collect(), } } /// Normalizes a date/datetime string to ISO form, trying common layouts. /// Returns `None` if no layout matches (caller decides how to handle). fn normalize_date(input: &str) -> Option { let input = input.trim(); if input.is_empty() { return None; } const FORMATS: [&str; 10] = [ "%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S%.fZ", "%Y/%m/%d", "%m/%d/%Y", "%d/%m/%Y", "%d-%m-%Y", "%B %d, %Y", "%b %d, %Y", ]; for format in &FORMATS { if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(input, format) { return Some(dt.format("%Y-%m-%dT%H:%M:%S").to_string()); } if let Ok(d) = chrono::NaiveDate::parse_from_str(input, format) { return Some(d.format("%Y-%m-%d").to_string()); } } if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(input) { return Some(dt.format("%Y-%m-%dT%H:%M:%S").to_string()); } None } // Import Executors /// Imports parsed task items. Resolves project by name (case-insensitive), /// parses priority and due date; per-item errors are accumulated. async fn import_tasks( state: &AppState, items: &[&ImportItem], projects: &[(String, String)], ) -> Result { let mut imported = 0; let mut failed = 0; let mut failures = Vec::new(); for item in items { if let ImportItemData::Task(data) = &item.data { let project_id = resolve_project(data.project_name.as_deref(), projects); let priority = data.priority.as_ref().map_or(Priority::Medium, |p| { match p.to_lowercase().as_str() { "high" | "1" => Priority::High, "low" | "3" => Priority::Low, _ => Priority::Medium, } }); let due = data.due.as_ref().and_then(|d| parse_datetime(d)); let mut builder = NewTaskBuilder::new(&data.description) .priority(priority) .tags(data.tags.clone().unwrap_or_default()); if let Some(d) = due { builder = builder.due(d); } if let Some(pid) = project_id { builder = builder.project_id(pid); } match state.tasks.create(DESKTOP_USER_ID, builder.build()).await { Ok(_) => imported += 1, Err(e) => { failed += 1; failures.push(ImportFailure { source_index: item.source_index, message: e.to_string(), }); } } } } Ok(ImportExecuteResult { imported_count: imported, failed_count: failed, skipped_count: items.len() - imported - failed, failures, }) } /// Imports parsed project items. Type/status fall back to defaults on /// unrecognized values rather than failing the import. async fn import_projects( state: &AppState, items: &[&ImportItem], ) -> Result { let mut imported = 0; let mut failed = 0; let mut failures = Vec::new(); for item in items { if let ImportItemData::Project(data) = &item.data { let new_project = NewProject { name: data.name.clone(), description: data.description.clone().unwrap_or_default(), project_type: data .project_type .as_ref() .map(|t| goingson_core::ProjectType::from_str_or_default(t)) .unwrap_or_default(), status: data .status .as_ref() .map(|s| goingson_core::ProjectStatus::from_str_or_default(s)) .unwrap_or_default(), }; match state.projects.create(DESKTOP_USER_ID, new_project).await { Ok(_) => imported += 1, Err(e) => { failed += 1; failures.push(ImportFailure { source_index: item.source_index, message: e.to_string(), }); } } } } Ok(ImportExecuteResult { imported_count: imported, failed_count: failed, skipped_count: items.len() - imported - failed, failures, }) } /// Imports parsed event items. `start` is required, events without a parseable /// start time are counted as failures. async fn import_events( state: &AppState, items: &[&ImportItem], projects: &[(String, String)], ) -> Result { let mut imported = 0; let mut failed = 0; let mut failures = Vec::new(); for item in items { if let ImportItemData::Event(data) = &item.data { let project_id = resolve_project(data.project_name.as_deref(), projects); let Some(start) = parse_datetime(&data.start) else { failed += 1; failures.push(ImportFailure { source_index: item.source_index, message: format!("Invalid start time: {}", data.start), }); continue; }; let end = data.end.as_ref().and_then(|e| parse_datetime(e)); let mut builder = NewEventBuilder::new(&data.title, start); if let Some(e) = end { builder = builder.end_time(e); } if let Some(ref loc) = data.location { builder = builder.location(loc); } if let Some(ref desc) = data.description { builder = builder.description(desc); } if let Some(pid) = project_id { builder = builder.project_id(pid); } match state.events.create(DESKTOP_USER_ID, builder.build()).await { Ok(_) => imported += 1, Err(e) => { failed += 1; failures.push(ImportFailure { source_index: item.source_index, message: e.to_string(), }); } } } } Ok(ImportExecuteResult { imported_count: imported, failed_count: failed, skipped_count: items.len() - imported - failed, failures, }) } /// Resolves a project name (case-insensitive) to its id. fn resolve_project(name: Option<&str>, projects: &[(String, String)]) -> Option { let name = name?; let name_lower = name.to_lowercase(); projects .iter() .find(|(_, n)| n.to_lowercase() == name_lower) .and_then(|(id, _)| uuid::Uuid::parse_str(id).ok().map(ProjectId::from)) } /// Parses a datetime with a three-format fallback (RFC 3339, ISO without tz /// assumed UTC, date-only at midnight UTC). Returns `None` if none match. fn parse_datetime(s: &str) -> Option> { chrono::DateTime::parse_from_rfc3339(s) .ok() .map(|dt| dt.with_timezone(&chrono::Utc)) .or_else(|| { chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") .ok() .map(|dt| dt.and_utc()) }) .or_else(|| { chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") .ok() .map(|d| d.and_hms_opt(0, 0, 0).expect("midnight is valid").and_utc()) }) } #[cfg(test)] mod tests { use super::*; fn opts() -> ImportOptions { ImportOptions { has_header: true, delimiter: None, date_format: None, extra: std::collections::HashMap::default(), } } #[test] fn detects_event_from_start_column() { let csv = "title,start\nStandup,2024-01-15\n"; let r = parse_csv_import(csv, &opts()).unwrap(); assert_eq!(r.entity_type, ImportEntityType::Event); assert_eq!(r.items.len(), 1); } #[test] fn detects_project_when_name_without_description() { let csv = "name,status\nWebsite,active\n"; let r = parse_csv_import(csv, &opts()).unwrap(); assert_eq!(r.entity_type, ImportEntityType::Project); } #[test] fn defaults_to_task_and_maps_fields() { let csv = "description,priority,tags,project\nBuy milk,high,\"a, b\",Home\n"; let r = parse_csv_import(csv, &opts()).unwrap(); assert_eq!(r.entity_type, ImportEntityType::Task); let ImportItemData::Task(t) = &r.items[0].data else { panic!("expected task"); }; assert_eq!(t.description, "Buy milk"); assert_eq!(t.priority.as_deref(), Some("High")); assert_eq!(t.project_name.as_deref(), Some("Home")); assert_eq!( t.tags.as_ref().unwrap(), &vec!["a".to_string(), "b".to_string()] ); } #[test] fn case_insensitive_headers_and_bom_stripped() { let csv = "\u{FEFF}Description,Due\nShip it,2024-03-01\n"; let r = parse_csv_import(csv, &opts()).unwrap(); let ImportItemData::Task(t) = &r.items[0].data else { panic!("expected task"); }; assert_eq!(t.description, "Ship it"); assert_eq!(t.due.as_deref(), Some("2024-03-01")); } #[test] fn rows_missing_required_field_become_warnings() { // Second row has a priority but an empty description: a non-blank // record missing its required field, which is warned and skipped. // (Truly blank lines are dropped by the CSV reader, not warned.) let csv = "description,priority\nReal task,high\n,low\n"; let r = parse_csv_import(csv, &opts()).unwrap(); assert_eq!(r.items.len(), 1); assert_eq!(r.warnings.len(), 1); } #[test] fn empty_file_yields_no_items() { let r = parse_csv_import("", &opts()).unwrap(); assert!(r.items.is_empty()); } #[test] fn tsv_via_delimiter_option() { let mut o = opts(); o.delimiter = Some('\t'); let csv = "description\tpriority\nTabbed\tlow\n"; let r = parse_csv_import(csv, &o).unwrap(); let ImportItemData::Task(t) = &r.items[0].data else { panic!("expected task"); }; assert_eq!(t.description, "Tabbed"); assert_eq!(t.priority.as_deref(), Some("Low")); } #[test] fn normalize_date_handles_us_format() { assert_eq!(normalize_date("01/15/2024").as_deref(), Some("2024-01-15")); assert_eq!(normalize_date("not a date"), None); } }