//! External import commands for vCard and iCalendar files. //! //! Provides preview (dry-run) and import (create records) for .vcf and .ics files. use chrono::NaiveDate; use serde::Serialize; use std::sync::Arc; use tauri::State; use tracing::instrument; use goingson_core::{ NewContact, NewContactCustomField, NewContactEmail, NewContactPhone, NewEvent, NewSocialHandle, Recurrence, }; /// Maximum import file size (50 MB). const MAX_IMPORT_FILE_SIZE: u64 = 50 * 1024 * 1024; fn read_import_file(path: &str) -> Result { let metadata = std::fs::metadata(path) .map_err(|e| ApiError::internal(format!("Failed to stat file: {}", e)))?; if metadata.len() > MAX_IMPORT_FILE_SIZE { return Err(ApiError::validation_msg(format!( "File is too large ({} bytes, max {} bytes)", metadata.len(), MAX_IMPORT_FILE_SIZE ))); } std::fs::read_to_string(path) .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e))) } use crate::external_sync::{ical, vcard}; use crate::state::{AppState, DESKTOP_USER_ID}; use super::ApiError; // ============ Result Types ============ /// Result of an import operation. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ImportResult { pub imported: u64, pub skipped: u64, pub errors: Vec, } /// Preview of a single vCard contact. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct VCardPreview { pub display_name: String, pub email_count: usize, pub phone_count: usize, pub company: Option, } /// Preview of a single ICS event. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct IcsPreview { pub title: String, pub start_time: String, pub end_time: Option, pub location: Option, pub recurrence: String, } // ============ Preview Commands ============ /// Preview a vCard import without creating records. #[tauri::command] #[instrument(skip_all)] pub async fn preview_vcf(file_path: String) -> Result, ApiError> { let content = read_import_file(&file_path)?; let cards = vcard::parse_vcf(&content) .map_err(|e| ApiError::internal(format!("Failed to parse vCard: {}", e)))?; Ok(cards .into_iter() .map(|c| VCardPreview { display_name: c.display_name, email_count: c.emails.len(), phone_count: c.phones.len(), company: c.company, }) .collect()) } /// Preview an ICS import without creating records. #[tauri::command] #[instrument(skip_all)] pub async fn preview_ics(file_path: String) -> Result, ApiError> { let content = read_import_file(&file_path)?; let events = ical::parse_ics(&content) .map_err(|e| ApiError::internal(format!("Failed to parse ICS: {}", e)))?; Ok(events .into_iter() .map(|e| IcsPreview { title: e.title, start_time: e.start_time.to_rfc3339(), end_time: e.end_time.map(|t| t.to_rfc3339()), location: e.location, recurrence: match e.recurrence { Recurrence::Daily => "Daily".to_string(), Recurrence::Weekly => "Weekly".to_string(), Recurrence::Monthly => "Monthly".to_string(), Recurrence::None => "None".to_string(), }, }) .collect()) } // ============ Import Commands ============ /// Import contacts from a vCard (.vcf) file. #[tauri::command] #[instrument(skip_all)] pub async fn import_vcf( state: State<'_, Arc>, file_path: String, ) -> Result { let content = read_import_file(&file_path)?; let cards = vcard::parse_vcf(&content) .map_err(|e| ApiError::internal(format!("Failed to parse vCard: {}", e)))?; let mut imported = 0u64; let mut skipped = 0u64; let mut errors = Vec::new(); for card in cards { // Generate a dedup key from the display name + first email let ext_id = card .emails .first() .map(|e| e.address.clone()) .unwrap_or_else(|| card.display_name.clone()); // Check for existing contact with same external source + id if let Ok(Some(_)) = state .contacts .find_by_external_id("vcf", &ext_id, DESKTOP_USER_ID) .await { skipped += 1; continue; } // Parse birthday let birthday = card.birthday.as_deref().and_then(|s| { NaiveDate::parse_from_str(s, "%Y-%m-%d").ok() }); let new_contact = NewContact { display_name: card.display_name.clone(), nickname: card.nickname, company: card.company, title: card.title, notes: card.notes.unwrap_or_default(), tags: card.tags, birthday, timezone: card.timezone, is_implicit: false, }; match state.contacts.create(DESKTOP_USER_ID, new_contact).await { Ok(contact) => { // Set external source/id for dedup on re-import (must succeed to prevent duplicates) if let Err(e) = sqlx::query( "UPDATE contacts SET external_source = ?, external_id = ? WHERE id = ?", ) .bind("vcf") .bind(&ext_id) .bind(contact.id.to_string()) .execute(&state.pool) .await { tracing::error!(contact = %card.display_name, "Failed to set external source (dedup key lost): {}", e); errors.push(format!("{}: failed to set dedup key: {}", card.display_name, e)); } // Add sub-collections, collecting any errors for email in card.emails { if let Err(e) = state .contacts .add_email( contact.id, DESKTOP_USER_ID, NewContactEmail { address: email.address, label: email.label, is_primary: email.is_primary, }, ) .await { tracing::warn!(contact = %card.display_name, "Failed to add email: {}", e); } } for phone in card.phones { if let Err(e) = state .contacts .add_phone( contact.id, DESKTOP_USER_ID, NewContactPhone { number: phone.number, label: phone.label, is_primary: phone.is_primary, }, ) .await { tracing::warn!(contact = %card.display_name, "Failed to add phone: {}", e); } } for social in card.social_handles { if let Err(e) = state .contacts .add_social_handle( contact.id, DESKTOP_USER_ID, NewSocialHandle { platform: social.platform, handle: social.handle, url: social.url, }, ) .await { tracing::warn!(contact = %card.display_name, "Failed to add social handle: {}", e); } } for field in card.custom_fields { if let Err(e) = state .contacts .add_custom_field( contact.id, DESKTOP_USER_ID, NewContactCustomField { label: field.label, value: field.value, url: field.url, }, ) .await { tracing::warn!(contact = %card.display_name, "Failed to add custom field: {}", e); } } imported += 1; } Err(e) => { errors.push(format!("{}: {}", card.display_name, e)); } } } Ok(ImportResult { imported, skipped, errors, }) } /// Import events from an iCalendar (.ics) file. #[tauri::command] #[instrument(skip_all)] pub async fn import_ics( state: State<'_, Arc>, file_path: String, ) -> Result { let content = read_import_file(&file_path)?; let parsed_events = ical::parse_ics(&content) .map_err(|e| ApiError::internal(format!("Failed to parse ICS: {}", e)))?; let mut imported = 0u64; let mut skipped = 0u64; let mut errors = Vec::new(); for mut parsed in parsed_events { // Generate synthetic dedup key if UID is missing if parsed.external_id.is_none() { parsed.external_id = Some(format!( "synth-{}-{}", parsed.title.replace(' ', "_"), parsed.start_time.timestamp() )); } // Dedup by UID (real or synthetic) if let Some(ref uid) = parsed.external_id { if let Ok(Some(_)) = state .events .find_by_external_id("ics", uid, DESKTOP_USER_ID) .await { skipped += 1; continue; } } let new_event = NewEvent { user_id: Some(DESKTOP_USER_ID), project_id: None, contact_id: None, title: parsed.title.clone(), description: parsed.description, start_time: parsed.start_time, end_time: parsed.end_time, location: parsed.location, linked_task_id: None, recurrence: parsed.recurrence, recurrence_rule: None, block_type: None, reminder_offsets_seconds: Vec::new(), }; match state.events.create(DESKTOP_USER_ID, new_event).await { Ok(event) => { // Set external source/id (file imports are editable, not read-only) if let Some(ref uid) = parsed.external_id { let _ = sqlx::query( "UPDATE events SET external_source = ?, external_id = ? WHERE id = ?", ) .bind("ics") .bind(uid) .bind(event.id.to_string()) .execute(&state.pool) .await; } imported += 1; } Err(e) => { errors.push(format!("{}: {}", parsed.title, e)); } } } Ok(ImportResult { imported, skipped, errors, }) }