Skip to main content

max / goingson

14.4 KB · 455 lines History Blame Raw
1 //! Plugin management commands.
2 //!
3 //! Provides Tauri commands for listing, previewing, and executing plugin imports.
4
5 use std::sync::Arc;
6
7 use serde::Deserialize;
8 use tauri::State;
9 use tokio::sync::RwLock;
10 use tracing::instrument;
11
12 use goingson_core::{
13 ImportEntityType, ImportExecuteResult, ImportFailure, ImportItem, ImportItemData,
14 ImportOptions, ImportParseResult, NewProject, NewEventBuilder,
15 NewTaskBuilder, ParseableEnum, PluginMeta, Priority, ProjectId,
16 };
17 use goingson_plugin_runtime::PluginRegistry;
18
19 use super::error::{ApiError, ErrorCode};
20 use crate::state::{AppState, DESKTOP_USER_ID};
21
22 /// Wrapper for the plugin registry that provides thread-safe access.
23 pub struct PluginState {
24 pub registry: RwLock<PluginRegistry>,
25 }
26
27 impl PluginState {
28 pub fn new(registry: PluginRegistry) -> Self {
29 Self {
30 registry: RwLock::new(registry),
31 }
32 }
33 }
34
35 /// Input for previewing an import.
36 #[derive(Debug, Deserialize)]
37 #[serde(rename_all = "camelCase")]
38 pub struct PreviewImportInput {
39 pub plugin_id: String,
40 pub file_path: String,
41 #[serde(default)]
42 pub options: ImportOptions,
43 }
44
45 /// Input for executing an import.
46 #[derive(Debug, Deserialize)]
47 #[serde(rename_all = "camelCase")]
48 pub struct ExecuteImportInput {
49 pub plugin_id: String,
50 pub file_path: String,
51 #[serde(default)]
52 pub options: ImportOptions,
53 /// Indices of items to import (all if empty).
54 #[serde(default)]
55 pub selected_indices: Vec<usize>,
56 }
57
58 /// Lists all available import plugins.
59 #[tauri::command]
60 #[instrument(skip_all)]
61 pub async fn list_import_plugins(
62 plugin_state: State<'_, PluginState>,
63 ) -> Result<Vec<PluginMeta>, ApiError> {
64 let registry = plugin_state.registry.read().await;
65 registry
66 .list_import_plugins()
67 .map_err(|e| ApiError::new(ErrorCode::InternalError, e.to_string()))
68 }
69
70 /// Lists all enabled import plugins.
71 #[tauri::command]
72 #[instrument(skip_all)]
73 pub async fn list_enabled_import_plugins(
74 plugin_state: State<'_, PluginState>,
75 ) -> Result<Vec<PluginMeta>, ApiError> {
76 let registry = plugin_state.registry.read().await;
77 Ok(registry.list_enabled_import_plugins())
78 }
79
80 /// Gets plugins that can handle a specific file extension.
81 #[tauri::command]
82 #[instrument(skip_all)]
83 pub async fn get_plugins_for_extension(
84 plugin_state: State<'_, PluginState>,
85 extension: String,
86 ) -> Result<Vec<PluginMeta>, ApiError> {
87 let registry = plugin_state.registry.read().await;
88 Ok(registry.get_plugins_for_extension(&extension))
89 }
90
91 /// Previews an import by parsing the file without creating entities.
92 #[tauri::command]
93 #[instrument(skip_all)]
94 pub async fn preview_import(
95 state: State<'_, Arc<AppState>>,
96 plugin_state: State<'_, PluginState>,
97 input: PreviewImportInput,
98 ) -> Result<ImportParseResult, ApiError> {
99 // Get project list for lookup
100 let projects = state
101 .projects
102 .list_all(DESKTOP_USER_ID)
103 .await
104 .map_err(ApiError::from)?;
105
106 let project_list: Vec<(String, String)> = projects
107 .iter()
108 .map(|p| (p.id.to_string(), p.name.clone()))
109 .collect();
110
111 let registry = plugin_state.registry.read().await;
112 registry
113 .preview_import(&input.plugin_id, &input.file_path, input.options, project_list)
114 .map_err(|e| ApiError::new(ErrorCode::InternalError, e.to_string()))
115 }
116
117 /// Executes an import, creating entities in the database.
118 #[tauri::command]
119 #[instrument(skip_all)]
120 pub async fn execute_import(
121 state: State<'_, Arc<AppState>>,
122 plugin_state: State<'_, PluginState>,
123 input: ExecuteImportInput,
124 ) -> Result<ImportExecuteResult, ApiError> {
125 // Get project list for lookup
126 let projects = state
127 .projects
128 .list_all(DESKTOP_USER_ID)
129 .await
130 .map_err(ApiError::from)?;
131
132 let project_list: Vec<(String, String)> = projects
133 .iter()
134 .map(|p| (p.id.to_string(), p.name.clone()))
135 .collect();
136
137 // Preview first to get the parsed items
138 let registry = plugin_state.registry.read().await;
139 let preview = registry
140 .preview_import(
141 &input.plugin_id,
142 &input.file_path,
143 input.options.clone(),
144 project_list.clone(),
145 )
146 .map_err(|e| ApiError::new(ErrorCode::InternalError, e.to_string()))?;
147
148 drop(registry); // Release lock before creating entities
149
150 // Filter items if specific indices were selected
151 let items: Vec<&ImportItem> = if input.selected_indices.is_empty() {
152 preview.items.iter().collect()
153 } else {
154 preview
155 .items
156 .iter()
157 .enumerate()
158 .filter(|(idx, _)| input.selected_indices.contains(idx))
159 .map(|(_, item)| item)
160 .collect()
161 };
162
163 // Execute import based on entity type
164 match preview.entity_type {
165 ImportEntityType::Task => {
166 import_tasks(&state, &items, &project_list).await
167 }
168 ImportEntityType::Project => {
169 import_projects(&state, &items).await
170 }
171 ImportEntityType::Event => {
172 import_events(&state, &items, &project_list).await
173 }
174 }
175 }
176
177 /// Enables a plugin.
178 #[tauri::command]
179 #[instrument(skip_all)]
180 pub async fn enable_plugin(
181 plugin_state: State<'_, PluginState>,
182 plugin_id: String,
183 ) -> Result<(), ApiError> {
184 let mut registry = plugin_state.registry.write().await;
185 registry
186 .enable_plugin(&plugin_id)
187 .map_err(|e| ApiError::new(ErrorCode::InternalError, e.to_string()))
188 }
189
190 /// Disables a plugin.
191 #[tauri::command]
192 #[instrument(skip_all)]
193 pub async fn disable_plugin(
194 plugin_state: State<'_, PluginState>,
195 plugin_id: String,
196 ) -> Result<(), ApiError> {
197 let mut registry = plugin_state.registry.write().await;
198 registry
199 .disable_plugin(&plugin_id)
200 .map_err(|e| ApiError::new(ErrorCode::InternalError, e.to_string()))
201 }
202
203 /// Reloads a plugin from disk.
204 #[tauri::command]
205 #[instrument(skip_all)]
206 pub async fn reload_plugin(
207 plugin_state: State<'_, PluginState>,
208 plugin_id: String,
209 ) -> Result<PluginMeta, ApiError> {
210 let mut registry = plugin_state.registry.write().await;
211 registry
212 .reload_plugin(&plugin_id)
213 .map_err(|e| ApiError::new(ErrorCode::InternalError, e.to_string()))
214 }
215
216 // ============ Import Helpers ============
217
218 /// Imports parsed task items into the database.
219 ///
220 /// For each item: resolves project by name (case-insensitive), parses
221 /// priority ("high"/"1" → High, "low"/"3" → Low, else Medium), parses
222 /// due date (RFC 3339, then `YYYY-MM-DD`), and creates the task.
223 /// Errors are accumulated per-item rather than failing the whole batch.
224 async fn import_tasks(
225 state: &AppState,
226 items: &[&ImportItem],
227 projects: &[(String, String)],
228 ) -> Result<ImportExecuteResult, ApiError> {
229 let mut imported = 0;
230 let mut failed = 0;
231 let mut failures = Vec::new();
232
233 for item in items {
234 if let ImportItemData::Task(data) = &item.data {
235 // Find project ID if project_name is specified
236 let project_id: Option<ProjectId> = data.project_name.as_ref().and_then(|name| {
237 let name_lower = name.to_lowercase();
238 projects
239 .iter()
240 .find(|(_, n)| n.to_lowercase() == name_lower)
241 .and_then(|(id, _)| uuid::Uuid::parse_str(id).ok().map(ProjectId::from))
242 });
243
244 // Parse priority
245 let priority = data
246 .priority
247 .as_ref()
248 .map(|p| match p.to_lowercase().as_str() {
249 "high" | "1" => Priority::High,
250 "low" | "3" => Priority::Low,
251 _ => Priority::Medium,
252 })
253 .unwrap_or(Priority::Medium);
254
255 // Parse due date
256 let due = data.due.as_ref().and_then(|d| {
257 chrono::DateTime::parse_from_rfc3339(d)
258 .ok()
259 .map(|dt| dt.with_timezone(&chrono::Utc))
260 .or_else(|| {
261 chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
262 .ok()
263 .map(|date| {
264 date.and_hms_opt(0, 0, 0)
265 .expect("midnight is valid")
266 .and_utc()
267 })
268 })
269 });
270
271 // Build task
272 let mut builder = NewTaskBuilder::new(&data.description)
273 .priority(priority)
274 .tags(data.tags.clone().unwrap_or_default());
275
276 if let Some(d) = due {
277 builder = builder.due(d);
278 }
279 if let Some(pid) = project_id {
280 builder = builder.project_id(pid);
281 }
282
283 let new_task = builder.build();
284
285 match state.tasks.create(DESKTOP_USER_ID, new_task).await {
286 Ok(_) => imported += 1,
287 Err(e) => {
288 failed += 1;
289 failures.push(ImportFailure {
290 source_index: item.source_index,
291 message: e.to_string(),
292 });
293 }
294 }
295 }
296 }
297
298 Ok(ImportExecuteResult {
299 imported_count: imported,
300 failed_count: failed,
301 skipped_count: items.len() - imported - failed,
302 failures,
303 })
304 }
305
306 /// Imports parsed project items into the database.
307 ///
308 /// Project type and status use `from_str_or_default` — unrecognized values
309 /// fall back to defaults rather than failing the import.
310 async fn import_projects(
311 state: &AppState,
312 items: &[&ImportItem],
313 ) -> Result<ImportExecuteResult, ApiError> {
314 let mut imported = 0;
315 let mut failed = 0;
316 let mut failures = Vec::new();
317
318 for item in items {
319 if let ImportItemData::Project(data) = &item.data {
320 let new_project = NewProject {
321 name: data.name.clone(),
322 description: data.description.clone().unwrap_or_default(),
323 project_type: data
324 .project_type
325 .as_ref()
326 .map(|t| goingson_core::ProjectType::from_str_or_default(t))
327 .unwrap_or_default(),
328 status: data
329 .status
330 .as_ref()
331 .map(|s| goingson_core::ProjectStatus::from_str_or_default(s))
332 .unwrap_or_default(),
333 };
334
335 match state.projects.create(DESKTOP_USER_ID, new_project).await {
336 Ok(_) => imported += 1,
337 Err(e) => {
338 failed += 1;
339 failures.push(ImportFailure {
340 source_index: item.source_index,
341 message: e.to_string(),
342 });
343 }
344 }
345 }
346 }
347
348 Ok(ImportExecuteResult {
349 imported_count: imported,
350 failed_count: failed,
351 skipped_count: items.len() - imported - failed,
352 failures,
353 })
354 }
355
356 /// Imports parsed event items into the database.
357 ///
358 /// `start` is required — events without a parseable start time are counted
359 /// as failures. `end`, `location`, `description`, and `project` are optional.
360 async fn import_events(
361 state: &AppState,
362 items: &[&ImportItem],
363 projects: &[(String, String)],
364 ) -> Result<ImportExecuteResult, ApiError> {
365 let mut imported = 0;
366 let mut failed = 0;
367 let mut failures = Vec::new();
368
369 for item in items {
370 if let ImportItemData::Event(data) = &item.data {
371 // Find project ID
372 let project_id: Option<ProjectId> = data.project_name.as_ref().and_then(|name| {
373 let name_lower = name.to_lowercase();
374 projects
375 .iter()
376 .find(|(_, n)| n.to_lowercase() == name_lower)
377 .and_then(|(id, _)| uuid::Uuid::parse_str(id).ok().map(ProjectId::from))
378 });
379
380 // Parse start time
381 let start = match parse_datetime(&data.start) {
382 Some(dt) => dt,
383 None => {
384 failed += 1;
385 failures.push(ImportFailure {
386 source_index: item.source_index,
387 message: format!("Invalid start time: {}", data.start),
388 });
389 continue;
390 }
391 };
392
393 // Parse end time
394 let end = data.end.as_ref().and_then(|e| parse_datetime(e));
395
396 let mut builder = NewEventBuilder::new(&data.title, start);
397
398 if let Some(e) = end {
399 builder = builder.end_time(e);
400 }
401 if let Some(ref loc) = data.location {
402 builder = builder.location(loc);
403 }
404 if let Some(ref desc) = data.description {
405 builder = builder.description(desc);
406 }
407 if let Some(pid) = project_id {
408 builder = builder.project_id(pid);
409 }
410
411 let new_event = builder.build();
412
413 match state.events.create(DESKTOP_USER_ID, new_event).await {
414 Ok(_) => imported += 1,
415 Err(e) => {
416 failed += 1;
417 failures.push(ImportFailure {
418 source_index: item.source_index,
419 message: e.to_string(),
420 });
421 }
422 }
423 }
424 }
425
426 Ok(ImportExecuteResult {
427 imported_count: imported,
428 failed_count: failed,
429 skipped_count: items.len() - imported - failed,
430 failures,
431 })
432 }
433
434 /// Parses a datetime string with a three-format fallback chain:
435 /// 1. RFC 3339 with timezone (`2024-01-15T10:30:00Z`)
436 /// 2. ISO 8601 without timezone (`2024-01-15T10:30:00`, assumed UTC)
437 /// 3. Date-only (`2024-01-15`, midnight UTC)
438 ///
439 /// Returns `None` if none of the formats match.
440 fn parse_datetime(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
441 chrono::DateTime::parse_from_rfc3339(s)
442 .ok()
443 .map(|dt| dt.with_timezone(&chrono::Utc))
444 .or_else(|| {
445 chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
446 .ok()
447 .map(|dt| dt.and_utc())
448 })
449 .or_else(|| {
450 chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
451 .ok()
452 .map(|d| d.and_hms_opt(0, 0, 0).expect("midnight is valid").and_utc())
453 })
454 }
455