//! Plugin management commands. //! //! Provides Tauri commands for listing, previewing, and executing plugin imports. use std::sync::Arc; use serde::Deserialize; use tauri::State; use tokio::sync::RwLock; use tracing::instrument; use goingson_core::{ ImportEntityType, ImportExecuteResult, ImportFailure, ImportItem, ImportItemData, ImportOptions, ImportParseResult, NewProject, NewEventBuilder, NewTaskBuilder, ParseableEnum, PluginMeta, Priority, ProjectId, }; use goingson_plugin_runtime::PluginRegistry; use super::error::{ApiError, ErrorCode}; use crate::state::{AppState, DESKTOP_USER_ID}; /// Wrapper for the plugin registry that provides thread-safe access. pub struct PluginState { pub registry: RwLock, } impl PluginState { pub fn new(registry: PluginRegistry) -> Self { Self { registry: RwLock::new(registry), } } } /// Input for previewing an import. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PreviewImportInput { pub plugin_id: String, pub file_path: String, #[serde(default)] pub options: ImportOptions, } /// Input for executing an import. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExecuteImportInput { pub plugin_id: String, pub file_path: String, #[serde(default)] pub options: ImportOptions, /// Indices of items to import (all if empty). #[serde(default)] pub selected_indices: Vec, } /// Lists all available import plugins. #[tauri::command] #[instrument(skip_all)] pub async fn list_import_plugins( plugin_state: State<'_, PluginState>, ) -> Result, ApiError> { let registry = plugin_state.registry.read().await; registry .list_import_plugins() .map_err(|e| ApiError::new(ErrorCode::InternalError, e.to_string())) } /// Lists all enabled import plugins. #[tauri::command] #[instrument(skip_all)] pub async fn list_enabled_import_plugins( plugin_state: State<'_, PluginState>, ) -> Result, ApiError> { let registry = plugin_state.registry.read().await; Ok(registry.list_enabled_import_plugins()) } /// Gets plugins that can handle a specific file extension. #[tauri::command] #[instrument(skip_all)] pub async fn get_plugins_for_extension( plugin_state: State<'_, PluginState>, extension: String, ) -> Result, ApiError> { let registry = plugin_state.registry.read().await; Ok(registry.get_plugins_for_extension(&extension)) } /// Previews an import by parsing the file without creating entities. #[tauri::command] #[instrument(skip_all)] pub async fn preview_import( state: State<'_, Arc>, plugin_state: State<'_, PluginState>, input: PreviewImportInput, ) -> Result { // Get project list for lookup 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 registry = plugin_state.registry.read().await; registry .preview_import(&input.plugin_id, &input.file_path, input.options, project_list) .map_err(|e| ApiError::new(ErrorCode::InternalError, e.to_string())) } /// Executes an import, creating entities in the database. #[tauri::command] #[instrument(skip_all)] pub async fn execute_import( state: State<'_, Arc>, plugin_state: State<'_, PluginState>, input: ExecuteImportInput, ) -> Result { // Get project list for lookup 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(); // Preview first to get the parsed items let registry = plugin_state.registry.read().await; let preview = registry .preview_import( &input.plugin_id, &input.file_path, input.options.clone(), project_list.clone(), ) .map_err(|e| ApiError::new(ErrorCode::InternalError, e.to_string()))?; drop(registry); // Release lock before creating entities // Filter items if specific indices were selected let items: Vec<&ImportItem> = if input.selected_indices.is_empty() { preview.items.iter().collect() } else { preview .items .iter() .enumerate() .filter(|(idx, _)| input.selected_indices.contains(idx)) .map(|(_, item)| item) .collect() }; // Execute import based on entity type match preview.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 } } } /// Enables a plugin. #[tauri::command] #[instrument(skip_all)] pub async fn enable_plugin( plugin_state: State<'_, PluginState>, plugin_id: String, ) -> Result<(), ApiError> { let mut registry = plugin_state.registry.write().await; registry .enable_plugin(&plugin_id) .map_err(|e| ApiError::new(ErrorCode::InternalError, e.to_string())) } /// Disables a plugin. #[tauri::command] #[instrument(skip_all)] pub async fn disable_plugin( plugin_state: State<'_, PluginState>, plugin_id: String, ) -> Result<(), ApiError> { let mut registry = plugin_state.registry.write().await; registry .disable_plugin(&plugin_id) .map_err(|e| ApiError::new(ErrorCode::InternalError, e.to_string())) } /// Reloads a plugin from disk. #[tauri::command] #[instrument(skip_all)] pub async fn reload_plugin( plugin_state: State<'_, PluginState>, plugin_id: String, ) -> Result { let mut registry = plugin_state.registry.write().await; registry .reload_plugin(&plugin_id) .map_err(|e| ApiError::new(ErrorCode::InternalError, e.to_string())) } // ============ Import Helpers ============ /// Imports parsed task items into the database. /// /// For each item: resolves project by name (case-insensitive), parses /// priority ("high"/"1" → High, "low"/"3" → Low, else Medium), parses /// due date (RFC 3339, then `YYYY-MM-DD`), and creates the task. /// Errors are accumulated per-item rather than failing the whole batch. 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 { // Find project ID if project_name is specified let project_id: Option = data.project_name.as_ref().and_then(|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)) }); // Parse priority let priority = data .priority .as_ref() .map(|p| match p.to_lowercase().as_str() { "high" | "1" => Priority::High, "low" | "3" => Priority::Low, _ => Priority::Medium, }) .unwrap_or(Priority::Medium); // Parse due date let due = data.due.as_ref().and_then(|d| { chrono::DateTime::parse_from_rfc3339(d) .ok() .map(|dt| dt.with_timezone(&chrono::Utc)) .or_else(|| { chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d") .ok() .map(|date| { date.and_hms_opt(0, 0, 0) .expect("midnight is valid") .and_utc() }) }) }); // Build task 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); } let new_task = builder.build(); match state.tasks.create(DESKTOP_USER_ID, new_task).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 into the database. /// /// Project type and status use `from_str_or_default` — unrecognized values /// fall back to defaults 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 into the database. /// /// `start` is required — events without a parseable start time are counted /// as failures. `end`, `location`, `description`, and `project` are optional. 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 { // Find project ID let project_id: Option = data.project_name.as_ref().and_then(|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)) }); // Parse start time let start = match parse_datetime(&data.start) { Some(dt) => dt, None => { failed += 1; failures.push(ImportFailure { source_index: item.source_index, message: format!("Invalid start time: {}", data.start), }); continue; } }; // Parse end time 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); } let new_event = builder.build(); match state.events.create(DESKTOP_USER_ID, new_event).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, }) } /// Parses a datetime string with a three-format fallback chain: /// 1. RFC 3339 with timezone (`2024-01-15T10:30:00Z`) /// 2. ISO 8601 without timezone (`2024-01-15T10:30:00`, assumed UTC) /// 3. Date-only (`2024-01-15`, midnight UTC) /// /// Returns `None` if none of the formats 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()) }) }