max / goingson
41 files changed,
+10 insertions,
-5689 deletions
| @@ -1943,25 +1943,6 @@ dependencies = [ | |||
| 1943 | 1943 | ] | |
| 1944 | 1944 | ||
| 1945 | 1945 | [[package]] | |
| 1946 | - | name = "goingson-mcp" | |
| 1947 | - | version = "0.3.1" | |
| 1948 | - | dependencies = [ | |
| 1949 | - | "chrono", | |
| 1950 | - | "dirs", | |
| 1951 | - | "goingson-core", | |
| 1952 | - | "goingson-db-sqlite", | |
| 1953 | - | "rmcp", | |
| 1954 | - | "schemars 0.8.22", | |
| 1955 | - | "serde", | |
| 1956 | - | "serde_json", | |
| 1957 | - | "sqlx", | |
| 1958 | - | "tokio", | |
| 1959 | - | "tracing", | |
| 1960 | - | "tracing-subscriber", | |
| 1961 | - | "uuid", | |
| 1962 | - | ] | |
| 1963 | - | ||
| 1964 | - | [[package]] | |
| 1965 | 1946 | name = "goingson-plugin-runtime" | |
| 1966 | 1947 | version = "0.3.1" | |
| 1967 | 1948 | dependencies = [ | |
| @@ -3647,12 +3628,6 @@ dependencies = [ | |||
| 3647 | 3628 | ] | |
| 3648 | 3629 | ||
| 3649 | 3630 | [[package]] | |
| 3650 | - | name = "paste" | |
| 3651 | - | version = "1.0.15" | |
| 3652 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3653 | - | checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" | |
| 3654 | - | ||
| 3655 | - | [[package]] | |
| 3656 | 3631 | name = "pathdiff" | |
| 3657 | 3632 | version = "0.2.3" | |
| 3658 | 3633 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4526,38 +4501,6 @@ dependencies = [ | |||
| 4526 | 4501 | ] | |
| 4527 | 4502 | ||
| 4528 | 4503 | [[package]] | |
| 4529 | - | name = "rmcp" | |
| 4530 | - | version = "0.1.5" | |
| 4531 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4532 | - | checksum = "33a0110d28bd076f39e14bfd5b0340216dd18effeb5d02b43215944cc3e5c751" | |
| 4533 | - | dependencies = [ | |
| 4534 | - | "base64 0.21.7", | |
| 4535 | - | "chrono", | |
| 4536 | - | "futures", | |
| 4537 | - | "paste", | |
| 4538 | - | "pin-project-lite", | |
| 4539 | - | "rmcp-macros", | |
| 4540 | - | "schemars 0.8.22", | |
| 4541 | - | "serde", | |
| 4542 | - | "serde_json", | |
| 4543 | - | "thiserror 2.0.18", | |
| 4544 | - | "tokio", | |
| 4545 | - | "tokio-util", | |
| 4546 | - | "tracing", | |
| 4547 | - | ] | |
| 4548 | - | ||
| 4549 | - | [[package]] | |
| 4550 | - | name = "rmcp-macros" | |
| 4551 | - | version = "0.1.5" | |
| 4552 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4553 | - | checksum = "a6e2b2fd7497540489fa2db285edd43b7ed14c49157157438664278da6e42a7a" | |
| 4554 | - | dependencies = [ | |
| 4555 | - | "proc-macro2", | |
| 4556 | - | "quote", | |
| 4557 | - | "syn 2.0.117", | |
| 4558 | - | ] | |
| 4559 | - | ||
| 4560 | - | [[package]] | |
| 4561 | 4504 | name = "rsa" | |
| 4562 | 4505 | version = "0.9.10" | |
| 4563 | 4506 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2,7 +2,6 @@ | |||
| 2 | 2 | members = [ | |
| 3 | 3 | "crates/core", | |
| 4 | 4 | "crates/db-sqlite", | |
| 5 | - | "crates/goingson-mcp", | |
| 6 | 5 | "crates/plugin-runtime", | |
| 7 | 6 | "src-tauri", | |
| 8 | 7 | ] |
| @@ -1,114 +0,0 @@ | |||
| 1 | - | //! LLM provider types and DTOs. | |
| 2 | - | ||
| 3 | - | use chrono::{DateTime, Utc}; | |
| 4 | - | use serde::{Deserialize, Serialize}; | |
| 5 | - | use strum_macros::EnumString; | |
| 6 | - | use crate::id_types::{LlmSettingsId, UserId}; | |
| 7 | - | ||
| 8 | - | use super::shared::DbValue; | |
| 9 | - | ||
| 10 | - | // ============ LLM Types ============ | |
| 11 | - | ||
| 12 | - | /// Supported LLM provider types. | |
| 13 | - | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, EnumString)] | |
| 14 | - | pub enum LlmProviderType { | |
| 15 | - | /// Local Ollama instance. | |
| 16 | - | #[strum(serialize = "ollama")] | |
| 17 | - | #[default] | |
| 18 | - | Ollama, | |
| 19 | - | /// OpenAI-compatible API (OpenAI, Groq, etc.). | |
| 20 | - | #[strum(serialize = "openai_compatible")] | |
| 21 | - | OpenAiCompatible, | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | impl LlmProviderType { | |
| 25 | - | /// Returns a human-readable display string. | |
| 26 | - | pub fn as_str(&self) -> &'static str { | |
| 27 | - | match self { | |
| 28 | - | LlmProviderType::Ollama => "Ollama", | |
| 29 | - | LlmProviderType::OpenAiCompatible => "OpenAI Compatible", | |
| 30 | - | } | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | /// Parses a string into an LlmProviderType, falling back to `Ollama` on invalid input. | |
| 34 | - | /// | |
| 35 | - | /// Accepts various formats: "ollama"/"Ollama", "openai_compatible"/"OpenAiCompatible"/"openai"/"OpenAI". | |
| 36 | - | /// This intentional fallback ensures database reads and frontend input never fail. | |
| 37 | - | /// Use `str.parse::<LlmProviderType>()` if you need error handling. | |
| 38 | - | #[allow(clippy::should_implement_trait)] | |
| 39 | - | pub fn from_str_or_default(s: &str) -> Self { | |
| 40 | - | match s { | |
| 41 | - | "ollama" | "Ollama" => LlmProviderType::Ollama, | |
| 42 | - | "openai_compatible" | "OpenAiCompatible" | "openai" | "OpenAI" => LlmProviderType::OpenAiCompatible, | |
| 43 | - | _ => LlmProviderType::default(), | |
| 44 | - | } | |
| 45 | - | } | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | impl DbValue for LlmProviderType { | |
| 49 | - | fn db_value(&self) -> &'static str { | |
| 50 | - | match self { | |
| 51 | - | LlmProviderType::Ollama => "ollama", | |
| 52 | - | LlmProviderType::OpenAiCompatible => "openai_compatible", | |
| 53 | - | } | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | /// User's LLM provider configuration. | |
| 58 | - | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 59 | - | pub struct LlmSettings { | |
| 60 | - | /// Unique identifier. | |
| 61 | - | pub id: LlmSettingsId, | |
| 62 | - | /// Owner user ID. | |
| 63 | - | pub user_id: UserId, | |
| 64 | - | /// Provider type. | |
| 65 | - | pub provider_type: LlmProviderType, | |
| 66 | - | /// API base URL. | |
| 67 | - | pub base_url: String, | |
| 68 | - | /// API key (if required). | |
| 69 | - | pub api_key: Option<String>, | |
| 70 | - | /// Model name/identifier. | |
| 71 | - | pub model_name: String, | |
| 72 | - | /// Request timeout in milliseconds. | |
| 73 | - | pub timeout_ms: i32, | |
| 74 | - | /// Maximum tokens in response. | |
| 75 | - | pub max_tokens: i32, | |
| 76 | - | /// Sampling temperature (0.0 to 1.0). | |
| 77 | - | pub temperature: f64, | |
| 78 | - | /// Whether LLM features are enabled. | |
| 79 | - | pub is_enabled: bool, | |
| 80 | - | /// When settings were created. | |
| 81 | - | pub created_at: DateTime<Utc>, | |
| 82 | - | /// When settings were last modified. | |
| 83 | - | pub updated_at: DateTime<Utc>, | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | /// Data for creating or updating LLM settings. | |
| 87 | - | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 88 | - | pub struct NewLlmSettings { | |
| 89 | - | pub provider_type: LlmProviderType, | |
| 90 | - | pub base_url: String, | |
| 91 | - | pub api_key: Option<String>, | |
| 92 | - | pub model_name: String, | |
| 93 | - | pub timeout_ms: i32, | |
| 94 | - | pub max_tokens: i32, | |
| 95 | - | pub temperature: f64, | |
| 96 | - | pub is_enabled: bool, | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | /// Temporal context passed to LLM prompts. | |
| 100 | - | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 101 | - | pub struct LlmContext { | |
| 102 | - | /// Current date (YYYY-MM-DD). | |
| 103 | - | pub today: String, | |
| 104 | - | /// Day of year (1-366). | |
| 105 | - | pub day_of_year: i32, | |
| 106 | - | /// Day name ("Monday", etc.). | |
| 107 | - | pub day_of_week: String, | |
| 108 | - | /// ISO week number. | |
| 109 | - | pub week_of_year: i32, | |
| 110 | - | /// Month (1-12). | |
| 111 | - | pub month: i32, | |
| 112 | - | /// Year. | |
| 113 | - | pub year: i32, | |
| 114 | - | } |
| @@ -5,7 +5,6 @@ mod backup; | |||
| 5 | 5 | mod email; | |
| 6 | 6 | mod email_account; | |
| 7 | 7 | mod event; | |
| 8 | - | mod llm; | |
| 9 | 8 | mod milestone; | |
| 10 | 9 | mod project; | |
| 11 | 10 | mod saved_view; | |
| @@ -22,7 +21,6 @@ pub use backup::*; | |||
| 22 | 21 | pub use email::*; | |
| 23 | 22 | pub use email_account::*; | |
| 24 | 23 | pub use event::*; | |
| 25 | - | pub use llm::*; | |
| 26 | 24 | pub use milestone::*; | |
| 27 | 25 | pub use monthly_review::*; | |
| 28 | 26 | pub use project::*; |
| @@ -19,8 +19,8 @@ use crate::contact::{ | |||
| 19 | 19 | }; | |
| 20 | 20 | use crate::error::CoreError; | |
| 21 | 21 | use crate::models::{ | |
| 22 | - | Annotation, Attachment, Email, EmailAccount, EmailAuthType, EmailThread, Event, LlmSettings, | |
| 23 | - | NewAttachment, NewEmail, NewEmailWithTracking, NewEvent, NewLlmSettings, NewProject, | |
| 22 | + | Annotation, Attachment, Email, EmailAccount, EmailAuthType, EmailThread, Event, | |
| 23 | + | NewAttachment, NewEmail, NewEmailWithTracking, NewEvent, NewProject, | |
| 24 | 24 | NewSavedView, NewTask, Project, SavedView, Subtask, Task, TaskFilterQuery, TimeSession, | |
| 25 | 25 | TimeTrackingSummary, UpdateTask, User, | |
| 26 | 26 | }; | |
| @@ -660,44 +660,6 @@ pub trait SearchRepository: Send + Sync { | |||
| 660 | 660 | async fn search(&self, user_id: UserId, query: SearchQuery) -> Result<(Vec<SearchResultItem>, usize)>; | |
| 661 | 661 | } | |
| 662 | 662 | ||
| 663 | - | /// Repository for LLM provider settings. | |
| 664 | - | #[async_trait] | |
| 665 | - | pub trait LlmSettingsRepository: Send + Sync { | |
| 666 | - | /// Gets the LLM settings for a user. | |
| 667 | - | async fn get(&self, user_id: UserId) -> Result<Option<LlmSettings>>; | |
| 668 | - | ||
| 669 | - | /// Creates or updates LLM settings. | |
| 670 | - | async fn upsert(&self, user_id: UserId, settings: NewLlmSettings) -> Result<LlmSettings>; | |
| 671 | - | } | |
| 672 | - | ||
| 673 | - | /// Repository for caching LLM responses. | |
| 674 | - | /// | |
| 675 | - | /// Caches are keyed by prompt hash and optional context date to support | |
| 676 | - | /// date-sensitive queries (e.g., "what's due today?"). | |
| 677 | - | #[async_trait] | |
| 678 | - | pub trait LlmCacheRepository: Send + Sync { | |
| 679 | - | /// Gets a cached response by prompt hash and context date. | |
| 680 | - | async fn get( | |
| 681 | - | &self, | |
| 682 | - | user_id: UserId, | |
| 683 | - | prompt_hash: &str, | |
| 684 | - | context_date: Option<&str>, | |
| 685 | - | ) -> Result<Option<String>>; | |
| 686 | - | ||
| 687 | - | /// Stores a response in the cache. | |
| 688 | - | async fn set( | |
| 689 | - | &self, | |
| 690 | - | user_id: UserId, | |
| 691 | - | prompt_hash: &str, | |
| 692 | - | context_date: Option<&str>, | |
| 693 | - | response: &str, | |
| 694 | - | expires_at: Option<DateTime<Utc>>, | |
| 695 | - | ) -> Result<()>; | |
| 696 | - | ||
| 697 | - | /// Clears all cached responses for a user, returning the count deleted. | |
| 698 | - | async fn clear_all(&self, user_id: UserId) -> Result<u64>; | |
| 699 | - | } | |
| 700 | - | ||
| 701 | 663 | /// Repository for saved view / filter configurations. | |
| 702 | 664 | #[async_trait] | |
| 703 | 665 | pub trait SavedViewRepository: Send + Sync { |
| @@ -56,8 +56,6 @@ pub use repository::{ | |||
| 56 | 56 | SqliteEmailAccountRepository, | |
| 57 | 57 | SqliteStatsRepository, | |
| 58 | 58 | SqliteSearchRepository, | |
| 59 | - | SqliteLlmSettingsRepository, | |
| 60 | - | SqliteLlmCacheRepository, | |
| 61 | 59 | SqliteMilestoneRepository, | |
| 62 | 60 | SqliteMonthlyReviewRepository, | |
| 63 | 61 | SqliteSavedViewRepository, |
| @@ -1,253 +0,0 @@ | |||
| 1 | - | //! SQLite implementations of LLM-related repositories. | |
| 2 | - | //! | |
| 3 | - | //! Manages LLM provider settings (API keys, model configs, timeouts) and | |
| 4 | - | //! caches LLM responses to reduce API calls and improve response times. | |
| 5 | - | ||
| 6 | - | use async_trait::async_trait; | |
| 7 | - | use chrono::{DateTime, Utc}; | |
| 8 | - | use sqlx::SqlitePool; | |
| 9 | - | use uuid::Uuid; | |
| 10 | - | ||
| 11 | - | use goingson_core::{ | |
| 12 | - | CoreError, DbValue, LlmCacheRepository, LlmProviderType, LlmSettings, LlmSettingsId, | |
| 13 | - | LlmSettingsRepository, NewLlmSettings, Result, UserId, | |
| 14 | - | }; | |
| 15 | - | ||
| 16 | - | use crate::utils::{format_datetime_now, format_datetime_opt, parse_datetime, parse_uuid}; | |
| 17 | - | ||
| 18 | - | // ============ LLM Settings ============ | |
| 19 | - | ||
| 20 | - | #[derive(Debug, Clone, sqlx::FromRow)] | |
| 21 | - | struct LlmSettingsRow { | |
| 22 | - | pub id: String, | |
| 23 | - | pub user_id: String, | |
| 24 | - | pub provider_type: String, | |
| 25 | - | pub base_url: String, | |
| 26 | - | pub api_key: Option<String>, | |
| 27 | - | pub model_name: String, | |
| 28 | - | pub timeout_ms: i32, | |
| 29 | - | pub max_tokens: i32, | |
| 30 | - | pub temperature: f64, | |
| 31 | - | pub is_enabled: i32, | |
| 32 | - | pub created_at: String, | |
| 33 | - | pub updated_at: String, | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | impl TryFrom<LlmSettingsRow> for LlmSettings { | |
| 37 | - | type Error = CoreError; | |
| 38 | - | ||
| 39 | - | fn try_from(row: LlmSettingsRow) -> std::result::Result<Self, Self::Error> { | |
| 40 | - | Ok(LlmSettings { | |
| 41 | - | id: parse_uuid(&row.id)?.into(), | |
| 42 | - | user_id: parse_uuid(&row.user_id)?.into(), | |
| 43 | - | provider_type: LlmProviderType::from_str_or_default(&row.provider_type), | |
| 44 | - | base_url: row.base_url, | |
| 45 | - | api_key: row.api_key, | |
| 46 | - | model_name: row.model_name, | |
| 47 | - | timeout_ms: row.timeout_ms, | |
| 48 | - | max_tokens: row.max_tokens, | |
| 49 | - | temperature: row.temperature, | |
| 50 | - | is_enabled: row.is_enabled != 0, | |
| 51 | - | created_at: parse_datetime(&row.created_at)?, | |
| 52 | - | updated_at: parse_datetime(&row.updated_at)?, | |
| 53 | - | }) | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | /// SQLite-backed implementation of [`LlmSettingsRepository`]. | |
| 58 | - | /// | |
| 59 | - | /// Stores LLM provider configuration (API keys, model selection, parameters) | |
| 60 | - | /// with one settings record per user. | |
| 61 | - | pub struct SqliteLlmSettingsRepository { | |
| 62 | - | pool: SqlitePool, | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | impl SqliteLlmSettingsRepository { | |
| 66 | - | /// Creates a new repository instance with the given connection pool. | |
| 67 | - | pub fn new(pool: SqlitePool) -> Self { | |
| 68 | - | Self { pool } | |
| 69 | - | } | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | #[async_trait] | |
| 73 | - | impl LlmSettingsRepository for SqliteLlmSettingsRepository { | |
| 74 | - | async fn get(&self, user_id: UserId) -> Result<Option<LlmSettings>> { | |
| 75 | - | let row = sqlx::query_as::<_, LlmSettingsRow>( | |
| 76 | - | r#"SELECT id, user_id, provider_type, base_url, api_key, model_name, | |
| 77 | - | timeout_ms, max_tokens, temperature, is_enabled, created_at, updated_at | |
| 78 | - | FROM llm_settings WHERE user_id = ?"#, | |
| 79 | - | ) | |
| 80 | - | .bind(user_id.to_string()) | |
| 81 | - | .fetch_optional(&self.pool) | |
| 82 | - | .await | |
| 83 | - | .map_err(CoreError::database)?; | |
| 84 | - | ||
| 85 | - | row.map(LlmSettings::try_from).transpose() | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | async fn upsert(&self, user_id: UserId, settings: NewLlmSettings) -> Result<LlmSettings> { | |
| 89 | - | let now = format_datetime_now(); | |
| 90 | - | ||
| 91 | - | // Check if settings exist | |
| 92 | - | let existing = self.get(user_id).await?; | |
| 93 | - | ||
| 94 | - | if existing.is_some() { | |
| 95 | - | // Update | |
| 96 | - | sqlx::query( | |
| 97 | - | r#"UPDATE llm_settings SET | |
| 98 | - | provider_type = ?, base_url = ?, api_key = ?, model_name = ?, | |
| 99 | - | timeout_ms = ?, max_tokens = ?, temperature = ?, is_enabled = ?, updated_at = ? | |
| 100 | - | WHERE user_id = ?"#, | |
| 101 | - | ) | |
| 102 | - | .bind(settings.provider_type.db_value()) | |
| 103 | - | .bind(&settings.base_url) | |
| 104 | - | .bind(&settings.api_key) | |
| 105 | - | .bind(&settings.model_name) | |
| 106 | - | .bind(settings.timeout_ms) | |
| 107 | - | .bind(settings.max_tokens) | |
| 108 | - | .bind(settings.temperature) | |
| 109 | - | .bind(if settings.is_enabled { 1 } else { 0 }) | |
| 110 | - | .bind(&now) | |
| 111 | - | .bind(user_id.to_string()) | |
| 112 | - | .execute(&self.pool) | |
| 113 | - | .await | |
| 114 | - | .map_err(CoreError::database)?; | |
| 115 | - | ||
| 116 | - | self.get(user_id) | |
| 117 | - | .await? | |
| 118 | - | .ok_or_else(|| CoreError::internal("Failed to retrieve updated settings")) | |
| 119 | - | } else { | |
| 120 | - | // Insert | |
| 121 | - | let id = LlmSettingsId::new(); | |
| 122 | - | sqlx::query( | |
| 123 | - | r#"INSERT INTO llm_settings | |
| 124 | - | (id, user_id, provider_type, base_url, api_key, model_name, | |
| 125 | - | timeout_ms, max_tokens, temperature, is_enabled, created_at, updated_at) | |
| 126 | - | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, | |
| 127 | - | ) | |
| 128 | - | .bind(id.to_string()) | |
| 129 | - | .bind(user_id.to_string()) | |
| 130 | - | .bind(settings.provider_type.db_value()) | |
| 131 | - | .bind(&settings.base_url) | |
| 132 | - | .bind(&settings.api_key) | |
| 133 | - | .bind(&settings.model_name) | |
| 134 | - | .bind(settings.timeout_ms) | |
| 135 | - | .bind(settings.max_tokens) | |
| 136 | - | .bind(settings.temperature) | |
| 137 | - | .bind(if settings.is_enabled { 1 } else { 0 }) | |
| 138 | - | .bind(&now) | |
| 139 | - | .bind(&now) | |
| 140 | - | .execute(&self.pool) | |
| 141 | - | .await | |
| 142 | - | .map_err(CoreError::database)?; | |
| 143 | - | ||
| 144 | - | self.get(user_id) | |
| 145 | - | .await? | |
| 146 | - | .ok_or_else(|| CoreError::internal("Failed to retrieve created settings")) | |
| 147 | - | } | |
| 148 | - | } | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | // ============ LLM Cache ============ | |
| 152 | - | ||
| 153 | - | #[derive(Debug, Clone, sqlx::FromRow)] | |
| 154 | - | struct LlmCacheRow { | |
| 155 | - | pub response: String, | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | /// SQLite-backed implementation of [`LlmCacheRepository`]. | |
| 159 | - | /// | |
| 160 | - | /// Caches LLM responses keyed by prompt hash and optional context date. | |
| 161 | - | /// Supports automatic expiration for time-sensitive cached responses. | |
| 162 | - | pub struct SqliteLlmCacheRepository { | |
| 163 | - | pool: SqlitePool, | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | impl SqliteLlmCacheRepository { | |
| 167 | - | /// Creates a new repository instance with the given connection pool. | |
| 168 | - | pub fn new(pool: SqlitePool) -> Self { | |
| 169 | - | Self { pool } | |
| 170 | - | } | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | #[async_trait] | |
| 174 | - | impl LlmCacheRepository for SqliteLlmCacheRepository { | |
| 175 | - | async fn get( | |
| 176 | - | &self, | |
| 177 | - | user_id: UserId, | |
| 178 | - | prompt_hash: &str, | |
| 179 | - | context_date: Option<&str>, | |
| 180 | - | ) -> Result<Option<String>> { | |
| 181 | - | let now = format_datetime_now(); | |
| 182 | - | ||
| 183 | - | let row = if let Some(date) = context_date { | |
| 184 | - | sqlx::query_as::<_, LlmCacheRow>( | |
| 185 | - | r#"SELECT response FROM llm_cache | |
| 186 | - | WHERE user_id = ? AND prompt_hash = ? AND context_date = ? | |
| 187 | - | AND (expires_at IS NULL OR expires_at > ?)"#, | |
| 188 | - | ) | |
| 189 | - | .bind(user_id.to_string()) | |
| 190 | - | .bind(prompt_hash) | |
| 191 | - | .bind(date) | |
| 192 | - | .bind(&now) | |
| 193 | - | .fetch_optional(&self.pool) | |
| 194 | - | .await | |
| 195 | - | } else { | |
| 196 | - | sqlx::query_as::<_, LlmCacheRow>( | |
| 197 | - | r#"SELECT response FROM llm_cache | |
| 198 | - | WHERE user_id = ? AND prompt_hash = ? AND context_date IS NULL | |
| 199 | - | AND (expires_at IS NULL OR expires_at > ?)"#, | |
| 200 | - | ) | |
| 201 | - | .bind(user_id.to_string()) | |
| 202 | - | .bind(prompt_hash) | |
| 203 | - | .bind(&now) | |
| 204 | - | .fetch_optional(&self.pool) | |
| 205 | - | .await | |
| 206 | - | }; | |
| 207 | - | ||
| 208 | - | row.map_err(CoreError::database) | |
| 209 | - | .map(|r| r.map(|row| row.response)) | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | async fn set( | |
| 213 | - | &self, | |
| 214 | - | user_id: UserId, | |
| 215 | - | prompt_hash: &str, | |
| 216 | - | context_date: Option<&str>, | |
| 217 | - | response: &str, | |
| 218 | - | expires_at: Option<DateTime<Utc>>, | |
| 219 | - | ) -> Result<()> { | |
| 220 | - | let id = Uuid::new_v4(); | |
| 221 | - | let now = format_datetime_now(); | |
| 222 | - | let expires = format_datetime_opt(expires_at); | |
| 223 | - | ||
| 224 | - | // Use INSERT OR REPLACE to handle unique constraint | |
| 225 | - | sqlx::query( | |
| 226 | - | r#"INSERT OR REPLACE INTO llm_cache | |
| 227 | - | (id, user_id, prompt_hash, context_date, response, created_at, expires_at) | |
| 228 | - | VALUES (?, ?, ?, ?, ?, ?, ?)"#, | |
| 229 | - | ) | |
| 230 | - | .bind(id.to_string()) | |
| 231 | - | .bind(user_id.to_string()) | |
| 232 | - | .bind(prompt_hash) | |
| 233 | - | .bind(context_date) | |
| 234 | - | .bind(response) | |
| 235 | - | .bind(&now) | |
| 236 | - | .bind(expires) | |
| 237 | - | .execute(&self.pool) | |
| 238 | - | .await | |
| 239 | - | .map_err(CoreError::database)?; | |
| 240 | - | ||
| 241 | - | Ok(()) | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | async fn clear_all(&self, user_id: UserId) -> Result<u64> { | |
| 245 | - | let result = sqlx::query("DELETE FROM llm_cache WHERE user_id = ?") | |
| 246 | - | .bind(user_id.to_string()) | |
| 247 | - | .execute(&self.pool) | |
| 248 | - | .await | |
| 249 | - | .map_err(CoreError::database)?; | |
| 250 | - | ||
| 251 | - | Ok(result.rows_affected()) | |
| 252 | - | } | |
| 253 | - | } |
| @@ -18,7 +18,6 @@ mod user_repo; | |||
| 18 | 18 | mod email_account_repo; | |
| 19 | 19 | mod stats_repo; | |
| 20 | 20 | mod search_repo; | |
| 21 | - | mod llm_repo; | |
| 22 | 21 | mod saved_view_repo; | |
| 23 | 22 | mod milestone_repo; | |
| 24 | 23 | mod monthly_review_repo; | |
| @@ -36,7 +35,6 @@ pub use user_repo::SqliteUserRepository; | |||
| 36 | 35 | pub use email_account_repo::SqliteEmailAccountRepository; | |
| 37 | 36 | pub use stats_repo::SqliteStatsRepository; | |
| 38 | 37 | pub use search_repo::SqliteSearchRepository; | |
| 39 | - | pub use llm_repo::{SqliteLlmSettingsRepository, SqliteLlmCacheRepository}; | |
| 40 | 38 | pub use saved_view_repo::SqliteSavedViewRepository; | |
| 41 | 39 | pub use milestone_repo::SqliteMilestoneRepository; | |
| 42 | 40 | pub use monthly_review_repo::SqliteMonthlyReviewRepository; |
| @@ -1,37 +0,0 @@ | |||
| 1 | - | [package] | |
| 2 | - | name = "goingson-mcp" | |
| 3 | - | version.workspace = true | |
| 4 | - | edition.workspace = true | |
| 5 | - | ||
| 6 | - | [lib] | |
| 7 | - | name = "goingson_mcp" | |
| 8 | - | path = "src/lib.rs" | |
| 9 | - | ||
| 10 | - | [[bin]] | |
| 11 | - | name = "goingson-mcp" | |
| 12 | - | path = "src/main.rs" | |
| 13 | - | ||
| 14 | - | [dependencies] | |
| 15 | - | goingson-core = { workspace = true } | |
| 16 | - | goingson-db-sqlite = { workspace = true } | |
| 17 | - | ||
| 18 | - | # MCP protocol | |
| 19 | - | rmcp = { version = "0.1", features = ["server", "transport-io"] } | |
| 20 | - | ||
| 21 | - | # Async runtime | |
| 22 | - | tokio = { workspace = true, features = ["io-std", "io-util"] } | |
| 23 | - | ||
| 24 | - | # Serialization | |
| 25 | - | serde = { workspace = true } | |
| 26 | - | serde_json = { workspace = true } | |
| 27 | - | schemars = "0.8" | |
| 28 | - | ||
| 29 | - | # Database | |
| 30 | - | sqlx = { workspace = true, features = ["sqlite"] } | |
| 31 | - | ||
| 32 | - | # Utilities | |
| 33 | - | chrono = { workspace = true } | |
| 34 | - | uuid = { workspace = true } | |
| 35 | - | dirs = { workspace = true } | |
| 36 | - | tracing = { workspace = true } | |
| 37 | - | tracing-subscriber = { workspace = true } |
| @@ -1,6 +0,0 @@ | |||
| 1 | - | //! GoingsOn MCP Server library. | |
| 2 | - | //! | |
| 3 | - | //! Re-exports the core server types for integration tests. | |
| 4 | - | ||
| 5 | - | pub mod state; | |
| 6 | - | pub mod tools; |
| @@ -1,68 +0,0 @@ | |||
| 1 | - | //! GoingsOn MCP Server | |
| 2 | - | //! | |
| 3 | - | //! A standalone MCP (Model Context Protocol) server that allows Claude Code | |
| 4 | - | //! to manage tasks in GoingsOn. | |
| 5 | - | //! | |
| 6 | - | //! # Usage | |
| 7 | - | //! | |
| 8 | - | //! ```bash | |
| 9 | - | //! goingson-mcp # uses installed app database (com.goingson.app) | |
| 10 | - | //! goingson-mcp --dev # uses standalone MCP database (com.goingson.desktop) | |
| 11 | - | //! GOINGSON_DB=/path/to/db goingson-mcp # explicit database path | |
| 12 | - | //! ``` | |
| 13 | - | //! | |
| 14 | - | //! Configure Claude Code (~/.claude/mcp.json): | |
| 15 | - | //! ```json | |
| 16 | - | //! { | |
| 17 | - | //! "mcpServers": { | |
| 18 | - | //! "goingson": { | |
| 19 | - | //! "command": "/path/to/goingson-mcp", | |
| 20 | - | //! "args": [] | |
| 21 | - | //! } | |
| 22 | - | //! } | |
| 23 | - | //! } | |
| 24 | - | //! ``` | |
| 25 | - | ||
| 26 | - | use rmcp::ServiceExt; | |
| 27 | - | use tokio::io::{stdin, stdout}; | |
| 28 | - | use tracing::info; | |
| 29 | - | use tracing_subscriber::{fmt, prelude::*, EnvFilter}; | |
| 30 | - | ||
| 31 | - | use goingson_mcp::state::{DbMode, McpState}; | |
| 32 | - | use goingson_mcp::tools::GoingsOnServer; | |
| 33 | - | ||
| 34 | - | #[tokio::main] | |
| 35 | - | async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { | |
| 36 | - | // Initialize logging to stderr (stdout is used for MCP communication) | |
| 37 | - | tracing_subscriber::registry() | |
| 38 | - | .with(fmt::layer().with_writer(std::io::stderr)) | |
| 39 | - | .with(EnvFilter::from_default_env().add_directive("goingson_mcp=info".parse()?)) | |
| 40 | - | .init(); | |
| 41 | - | ||
| 42 | - | let mode = if std::env::args().any(|a| a == "--dev") { | |
| 43 | - | DbMode::Dev | |
| 44 | - | } else { | |
| 45 | - | DbMode::App | |
| 46 | - | }; | |
| 47 | - | ||
| 48 | - | info!(?mode, "Starting GoingsOn MCP server"); | |
| 49 | - | ||
| 50 | - | // Initialize database connection | |
| 51 | - | let state = McpState::new(mode).await?; | |
| 52 | - | ||
| 53 | - | // Create MCP server | |
| 54 | - | let server = GoingsOnServer::new(state); | |
| 55 | - | ||
| 56 | - | // Build stdio transport | |
| 57 | - | let transport = (stdin(), stdout()); | |
| 58 | - | ||
| 59 | - | // Serve MCP protocol | |
| 60 | - | info!("MCP server ready, waiting for connections..."); | |
| 61 | - | let service = server.serve(transport).await?; | |
| 62 | - | ||
| 63 | - | // Wait for shutdown | |
| 64 | - | let quit_reason = service.waiting().await?; | |
| 65 | - | info!(?quit_reason, "MCP server shutting down"); | |
| 66 | - | ||
| 67 | - | Ok(()) | |
| 68 | - | } |
| @@ -1,137 +0,0 @@ | |||
| 1 | - | //! Database connection and repository state for MCP server. | |
| 2 | - | //! | |
| 3 | - | //! Connects to the GoingsOn SQLite database. By default uses the installed | |
| 4 | - | //! app's database (`com.goingson.app`). Pass `--dev` to use a standalone | |
| 5 | - | //! MCP database (`com.goingson.desktop`), or set `GOINGSON_DB` to an | |
| 6 | - | //! explicit path. | |
| 7 | - | ||
| 8 | - | use goingson_core::{ContactRepository, EventRepository, MilestoneRepository, ProjectRepository, StatsRepository, TaskRepository, UserId}; | |
| 9 | - | use goingson_db_sqlite::{SqliteContactRepository, SqliteEventRepository, SqliteMilestoneRepository, SqliteProjectRepository, SqliteStatsRepository, SqliteTaskRepository}; | |
| 10 | - | use sqlx::SqlitePool; | |
| 11 | - | use std::path::PathBuf; | |
| 12 | - | use std::sync::Arc; | |
| 13 | - | use tracing::{debug, info}; | |
| 14 | - | ||
| 15 | - | /// Fixed user ID for single-user desktop app (matches Tauri app) | |
| 16 | - | pub const DESKTOP_USER_ID: UserId = UserId::from_uuid(uuid::Uuid::from_u128(1)); | |
| 17 | - | ||
| 18 | - | /// Which database the MCP server should connect to. | |
| 19 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 20 | - | pub enum DbMode { | |
| 21 | - | /// Installed Tauri app database (com.goingson.app) | |
| 22 | - | App, | |
| 23 | - | /// Standalone MCP development database (com.goingson.desktop) | |
| 24 | - | Dev, | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | /// Application state with database connection and repositories | |
| 28 | - | #[derive(Clone)] | |
| 29 | - | pub struct McpState { | |
| 30 | - | pub tasks: Arc<dyn TaskRepository>, | |
| 31 | - | pub projects: Arc<dyn ProjectRepository>, | |
| 32 | - | pub events: Arc<dyn EventRepository>, | |
| 33 | - | pub milestones: Arc<dyn MilestoneRepository>, | |
| 34 | - | pub contacts: Arc<dyn ContactRepository>, | |
| 35 | - | pub stats: Arc<dyn StatsRepository>, | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | impl McpState { | |
| 39 | - | /// Creates new state by connecting to the GoingsOn database. | |
| 40 | - | /// | |
| 41 | - | /// Database resolution order: | |
| 42 | - | /// 1. `GOINGSON_DB` env var (explicit path) | |
| 43 | - | /// 2. `--dev` flag → `com.goingson.desktop` | |
| 44 | - | /// 3. Default → `com.goingson.app` (installed Tauri app) | |
| 45 | - | pub async fn new(mode: DbMode) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { | |
| 46 | - | let db_path = get_database_path(mode)?; | |
| 47 | - | info!(?db_path, ?mode, "Connecting to GoingsOn database"); | |
| 48 | - | ||
| 49 | - | let db_url = format!("sqlite:{}?mode=rwc", db_path.display()); | |
| 50 | - | let pool = SqlitePool::connect(&db_url).await?; | |
| 51 | - | ||
| 52 | - | // Run migrations to ensure schema is up to date | |
| 53 | - | sqlx::migrate!("../../migrations/sqlite") | |
| 54 | - | .run(&pool) | |
| 55 | - | .await?; | |
| 56 | - | ||
| 57 | - | info!("Database connection established"); | |
| 58 | - | ||
| 59 | - | // Ensure desktop user exists (single-user mode) | |
| 60 | - | ensure_desktop_user_exists(&pool).await?; | |
| 61 | - | ||
| 62 | - | let tasks = Arc::new(SqliteTaskRepository::new(pool.clone())); | |
| 63 | - | let projects = Arc::new(SqliteProjectRepository::new(pool.clone())); | |
| 64 | - | let events = Arc::new(SqliteEventRepository::new(pool.clone())); | |
| 65 | - | let milestones = Arc::new(SqliteMilestoneRepository::new(pool.clone())); | |
| 66 | - | let contacts = Arc::new(SqliteContactRepository::new(pool.clone())); | |
| 67 | - | let stats = Arc::new(SqliteStatsRepository::new(pool)); | |
| 68 | - | ||
| 69 | - | Ok(Self { tasks, projects, events, milestones, contacts, stats }) | |
| 70 | - | } | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | /// Gets the path to the GoingsOn database. | |
| 74 | - | /// | |
| 75 | - | /// Resolution order: | |
| 76 | - | /// 1. `GOINGSON_DB` env var → use that path directly | |
| 77 | - | /// 2. `DbMode::Dev` → `<data_dir>/com.goingson.desktop/goingson.db` | |
| 78 | - | /// 3. `DbMode::App` → `<data_dir>/com.goingson.app/goingson.db` | |
| 79 | - | /// | |
| 80 | - | /// Data dir locations: | |
| 81 | - | /// - macOS: ~/Library/Application Support/ | |
| 82 | - | /// - Linux: ~/.local/share/ | |
| 83 | - | /// - Windows: %APPDATA%/ | |
| 84 | - | fn get_database_path(mode: DbMode) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> { | |
| 85 | - | // Explicit override via env var | |
| 86 | - | if let Ok(explicit) = std::env::var("GOINGSON_DB") { | |
| 87 | - | let path = PathBuf::from(&explicit); | |
| 88 | - | if let Some(parent) = path.parent() { | |
| 89 | - | std::fs::create_dir_all(parent)?; | |
| 90 | - | } | |
| 91 | - | info!(path = %path.display(), "Using GOINGSON_DB override"); | |
| 92 | - | return Ok(path); | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | let data_dir = dirs::data_dir() | |
| 96 | - | .ok_or("Could not determine data directory")?; | |
| 97 | - | ||
| 98 | - | let dir_name = match mode { | |
| 99 | - | DbMode::App => "com.goingson.app", | |
| 100 | - | DbMode::Dev => "com.goingson.desktop", | |
| 101 | - | }; | |
| 102 | - | ||
| 103 | - | let app_dir = data_dir.join(dir_name); | |
| 104 | - | std::fs::create_dir_all(&app_dir)?; | |
| 105 | - | ||
| 106 | - | Ok(app_dir.join("goingson.db")) | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | /// Ensure the desktop user exists in the database | |
| 110 | - | async fn ensure_desktop_user_exists(pool: &SqlitePool) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { | |
| 111 | - | let user_id = DESKTOP_USER_ID.to_string(); | |
| 112 | - | ||
| 113 | - | // Check if user already exists | |
| 114 | - | let exists: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE id = ?") | |
| 115 | - | .bind(&user_id) | |
| 116 | - | .fetch_optional(pool) | |
| 117 | - | .await?; | |
| 118 | - | ||
| 119 | - | if exists.is_none() { | |
| 120 | - | info!("Creating desktop user"); | |
| 121 | - | let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); | |
| 122 | - | sqlx::query( | |
| 123 | - | "INSERT INTO users (id, email, password_hash, display_name, created_at) VALUES (?, ?, ?, ?, ?)" | |
| 124 | - | ) | |
| 125 | - | .bind(&user_id) | |
| 126 | - | .bind("desktop@localhost") | |
| 127 | - | .bind("desktop-mode-no-password") | |
| 128 | - | .bind("Desktop User") | |
| 129 | - | .bind(&now) | |
| 130 | - | .execute(pool) | |
| 131 | - | .await?; | |
| 132 | - | } else { | |
| 133 | - | debug!("Desktop user already exists"); | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | Ok(()) | |
| 137 | - | } |
| @@ -1,267 +0,0 @@ | |||
| 1 | - | //! Contact implementation methods for GoingsOnServer. | |
| 2 | - | ||
| 3 | - | use chrono::NaiveDate; | |
| 4 | - | use tracing::instrument; | |
| 5 | - | ||
| 6 | - | use goingson_core::{ | |
| 7 | - | ContactId, ContactEmailId, ContactPhoneId, | |
| 8 | - | NewContact, UpdateContact, NewContactEmail, NewContactPhone, | |
| 9 | - | NewSocialHandle, NewContactCustomField, | |
| 10 | - | }; | |
| 11 | - | ||
| 12 | - | use crate::state::DESKTOP_USER_ID; | |
| 13 | - | ||
| 14 | - | use super::GoingsOnServer; | |
| 15 | - | use super::results::ContactResult; | |
| 16 | - | use super::contact_params::*; | |
| 17 | - | ||
| 18 | - | impl GoingsOnServer { | |
| 19 | - | pub(crate) fn contact_to_result(contact: &goingson_core::Contact) -> ContactResult { | |
| 20 | - | ContactResult { | |
| 21 | - | id: contact.id.to_string(), | |
| 22 | - | display_name: contact.display_name.clone(), | |
| 23 | - | company: contact.company.clone(), | |
| 24 | - | title: contact.title.clone(), | |
| 25 | - | tags: contact.tags.clone(), | |
| 26 | - | primary_email: contact.primary_email().map(|s| s.to_string()), | |
| 27 | - | email_count: contact.email_count(), | |
| 28 | - | phone_count: contact.phones.len(), | |
| 29 | - | has_socials: contact.has_social(), | |
| 30 | - | } | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | #[instrument(skip_all)] | |
| 34 | - | pub(crate) async fn list_contacts_impl( | |
| 35 | - | &self, | |
| 36 | - | params: ListContactsParams, | |
| 37 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 38 | - | let contacts = self.state.contacts.list_filtered( | |
| 39 | - | DESKTOP_USER_ID, | |
| 40 | - | params.search.as_deref(), | |
| 41 | - | params.tag.as_deref(), | |
| 42 | - | ).await?; | |
| 43 | - | ||
| 44 | - | if contacts.is_empty() { | |
| 45 | - | return Ok("No contacts found.".to_string()); | |
| 46 | - | } | |
| 47 | - | let results: Vec<ContactResult> = contacts.iter().map(Self::contact_to_result).collect(); | |
| 48 | - | Ok(serde_json::to_string_pretty(&results)?) | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | #[instrument(skip_all)] | |
| 52 | - | pub(crate) async fn create_contact_impl( | |
| 53 | - | &self, | |
| 54 | - | params: CreateContactParams, | |
| 55 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 56 | - | if params.name.trim().is_empty() { | |
| 57 | - | return Err("Contact name is required".into()); | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | let tags: Vec<String> = params.tags | |
| 61 | - | .as_deref() | |
| 62 | - | .map(|s| s.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect()) | |
| 63 | - | .unwrap_or_default(); | |
| 64 | - | ||
| 65 | - | let birthday = match ¶ms.birthday { | |
| 66 | - | Some(s) if !s.is_empty() => Some(NaiveDate::parse_from_str(s, "%Y-%m-%d")?), | |
| 67 | - | _ => None, | |
| 68 | - | }; | |
| 69 | - | ||
| 70 | - | let new_contact = NewContact { | |
| 71 | - | display_name: params.name, | |
| 72 | - | nickname: None, | |
| 73 | - | company: params.company, | |
| 74 | - | title: params.title, | |
| 75 | - | notes: params.notes.unwrap_or_default(), | |
| 76 | - | tags, | |
| 77 | - | birthday, | |
| 78 | - | timezone: params.timezone, | |
| 79 | - | }; | |
| 80 | - | ||
| 81 | - | let contact = self.state.contacts.create(DESKTOP_USER_ID, new_contact).await?; | |
| 82 | - | let result = Self::contact_to_result(&contact); | |
| 83 | - | Ok(format!("Contact created:\n{}", serde_json::to_string_pretty(&result)?)) | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | #[instrument(skip_all)] | |
| 87 | - | pub(crate) async fn get_contact_impl( | |
| 88 | - | &self, | |
| 89 | - | params: GetContactParams, | |
| 90 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 91 | - | let id: ContactId = params.id.parse::<uuid::Uuid>()?.into(); | |
| 92 | - | let contact = self.state.contacts.get_by_id(id, DESKTOP_USER_ID).await? | |
| 93 | - | .ok_or_else(|| format!("Contact {} not found", id))?; | |
| 94 | - | ||
| 95 | - | // Full detail response (not just the summary ContactResult) | |
| 96 | - | Ok(serde_json::to_string_pretty(&contact)?) | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | #[instrument(skip_all)] | |
| 100 | - | pub(crate) async fn update_contact_impl( | |
| 101 | - | &self, | |
| 102 | - | params: UpdateContactParams, | |
| 103 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 104 | - | let id: ContactId = params.id.parse::<uuid::Uuid>()?.into(); | |
| 105 | - | let existing = self.state.contacts.get_by_id(id, DESKTOP_USER_ID).await? | |
| 106 | - | .ok_or_else(|| format!("Contact {} not found", id))?; | |
| 107 | - | ||
| 108 | - | let display_name = params.name.unwrap_or(existing.display_name); | |
| 109 | - | let company = match ¶ms.company { | |
| 110 | - | Some(s) if s.is_empty() => None, | |
| 111 | - | Some(s) => Some(s.clone()), | |
| 112 | - | None => existing.company, | |
| 113 | - | }; | |
| 114 | - | let title = match ¶ms.title { | |
| 115 | - | Some(s) if s.is_empty() => None, | |
| 116 | - | Some(s) => Some(s.clone()), | |
| 117 | - | None => existing.title, | |
| 118 | - | }; | |
| 119 | - | let notes = params.notes.unwrap_or(existing.notes); | |
| 120 | - | let tags: Vec<String> = params.tags | |
| 121 | - | .as_deref() | |
| 122 | - | .map(|s| s.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect()) | |
| 123 | - | .unwrap_or(existing.tags); | |
| 124 | - | let birthday = match ¶ms.birthday { | |
| 125 | - | Some(s) if s.is_empty() => None, | |
| 126 | - | Some(s) => Some(NaiveDate::parse_from_str(s, "%Y-%m-%d")?), | |
| 127 | - | None => existing.birthday, | |
| 128 | - | }; | |
| 129 | - | let timezone = match ¶ms.timezone { | |
| 130 | - | Some(s) if s.is_empty() => None, | |
| 131 | - | Some(s) => Some(s.clone()), | |
| 132 | - | None => existing.timezone, | |
| 133 | - | }; | |
| 134 | - | ||
| 135 | - | let update = UpdateContact { | |
| 136 | - | display_name, | |
| 137 | - | nickname: existing.nickname, | |
| 138 | - | company, | |
| 139 | - | title, | |
| 140 | - | notes, | |
| 141 | - | tags, | |
| 142 | - | birthday, | |
| 143 | - | timezone, | |
| 144 | - | }; | |
| 145 | - | ||
| 146 | - | let contact = self.state.contacts.update(id, DESKTOP_USER_ID, update).await? | |
| 147 | - | .ok_or_else(|| format!("Contact {} not found", id))?; | |
| 148 | - | let result = Self::contact_to_result(&contact); | |
| 149 | - | Ok(format!("Contact updated:\n{}", serde_json::to_string_pretty(&result)?)) | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | #[instrument(skip_all)] | |
| 153 | - | pub(crate) async fn delete_contact_impl( | |
| 154 | - | &self, | |
| 155 | - | params: DeleteContactParams, | |
| 156 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 157 | - | let id: ContactId = params.id.parse::<uuid::Uuid>()?.into(); | |
| 158 | - | let deleted = self.state.contacts.delete(id, DESKTOP_USER_ID).await?; | |
| 159 | - | if deleted { | |
| 160 | - | Ok(format!("Contact {} deleted.", id)) | |
| 161 | - | } else { | |
| 162 | - | Ok(format!("Contact {} not found.", id)) | |
| 163 | - | } | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | #[instrument(skip_all)] | |
| 167 | - | pub(crate) async fn add_contact_email_impl( | |
| 168 | - | &self, | |
| 169 | - | params: AddContactEmailParams, | |
| 170 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 171 | - | let contact_id: ContactId = params.contact_id.parse::<uuid::Uuid>()?.into(); | |
| 172 | - | let new_email = NewContactEmail { | |
| 173 | - | address: params.address, | |
| 174 | - | label: params.label.unwrap_or_default(), | |
| 175 | - | is_primary: params.is_primary.unwrap_or(false), | |
| 176 | - | }; | |
| 177 | - | let email = self.state.contacts.add_email(contact_id, DESKTOP_USER_ID, new_email).await?; | |
| 178 | - | Ok(format!("Email added: {} ({})", email.address, email.label)) | |
| 179 | - | } | |
| 180 | - | ||
| 181 | - | #[instrument(skip_all)] | |
| 182 | - | pub(crate) async fn remove_contact_email_impl( | |
| 183 | - | &self, | |
| 184 | - | params: RemoveContactSubItemParams, | |
| 185 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 186 | - | let id: ContactEmailId = params.id.parse::<uuid::Uuid>()?.into(); | |
| 187 | - | let deleted = self.state.contacts.remove_email(id, DESKTOP_USER_ID).await?; | |
| 188 | - | if deleted { | |
| 189 | - | Ok(format!("Email {} removed.", id)) | |
| 190 | - | } else { | |
| 191 | - | Ok(format!("Email {} not found.", id)) | |
| 192 | - | } | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | #[instrument(skip_all)] | |
| 196 | - | pub(crate) async fn add_contact_phone_impl( | |
| 197 | - | &self, | |
| 198 | - | params: AddContactPhoneParams, | |
| 199 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 200 | - | let contact_id: ContactId = params.contact_id.parse::<uuid::Uuid>()?.into(); | |
| 201 | - | let new_phone = NewContactPhone { | |
| 202 | - | number: params.number, | |
| 203 | - | label: params.label.unwrap_or_default(), | |
| 204 | - | is_primary: params.is_primary.unwrap_or(false), | |
| 205 | - | }; | |
| 206 | - | let phone = self.state.contacts.add_phone(contact_id, DESKTOP_USER_ID, new_phone).await?; | |
| 207 | - | Ok(format!("Phone added: {} ({})", phone.number, phone.label)) | |
| 208 | - | } | |
| 209 | - | ||
| 210 | - | #[instrument(skip_all)] | |
| 211 | - | pub(crate) async fn remove_contact_phone_impl( | |
| 212 | - | &self, | |
| 213 | - | params: RemoveContactSubItemParams, | |
| 214 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 215 | - | let id: ContactPhoneId = params.id.parse::<uuid::Uuid>()?.into(); | |
| 216 | - | let deleted = self.state.contacts.remove_phone(id, DESKTOP_USER_ID).await?; | |
| 217 | - | if deleted { | |
| 218 | - | Ok(format!("Phone {} removed.", id)) | |
| 219 | - | } else { | |
| 220 | - | Ok(format!("Phone {} not found.", id)) | |
| 221 | - | } | |
| 222 | - | } | |
| 223 | - | ||
| 224 | - | #[instrument(skip_all)] | |
| 225 | - | pub(crate) async fn add_contact_social_impl( | |
| 226 | - | &self, | |
| 227 | - | params: AddContactSocialParams, | |
| 228 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 229 | - | let contact_id: ContactId = params.contact_id.parse::<uuid::Uuid>()?.into(); | |
| 230 | - | let new_handle = NewSocialHandle { | |
| 231 | - | platform: params.platform, | |
| 232 | - | handle: params.handle, | |
| 233 | - | url: params.url, | |
| 234 | - | }; | |
| 235 | - | let handle = self.state.contacts.add_social_handle(contact_id, DESKTOP_USER_ID, new_handle).await?; | |
| 236 | - | Ok(format!("Social handle added: {} on {}", handle.handle, handle.platform)) | |
| 237 | - | } | |
| 238 | - | ||
| 239 | - | #[instrument(skip_all)] | |
| 240 | - | pub(crate) async fn add_contact_custom_field_impl( | |
| 241 | - | &self, | |
| 242 | - | params: AddContactCustomFieldParams, | |
| 243 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 244 | - | let contact_id: ContactId = params.contact_id.parse::<uuid::Uuid>()?.into(); | |
| 245 | - | let new_field = NewContactCustomField { | |
| 246 | - | label: params.label, | |
| 247 | - | value: params.value, | |
| 248 | - | url: params.url, | |
| 249 | - | }; | |
| 250 | - | let field = self.state.contacts.add_custom_field(contact_id, DESKTOP_USER_ID, new_field).await?; | |
| 251 | - | Ok(format!("Custom field added: {}: {}", field.label, field.value)) | |
| 252 | - | } | |
| 253 | - | ||
| 254 | - | /// Find a contact by display name (case-insensitive partial match). | |
| 255 | - | /// Used internally by task/event creation to resolve contact names. | |
| 256 | - | pub(crate) async fn find_contact_by_name( | |
| 257 | - | &self, | |
| 258 | - | name: &str, | |
| 259 | - | ) -> Result<Option<goingson_core::Contact>, Box<dyn std::error::Error + Send + Sync>> { | |
| 260 | - | let contacts = self.state.contacts.list_filtered( | |
| 261 | - | DESKTOP_USER_ID, | |
| 262 | - | Some(name), | |
| 263 | - | None, | |
| 264 | - | ).await?; | |
| 265 | - | Ok(contacts.into_iter().next()) | |
| 266 | - | } | |
| 267 | - | } |
| @@ -1,188 +0,0 @@ | |||
| 1 | - | //! Parameter types for contact MCP tools. | |
| 2 | - | ||
| 3 | - | use schemars::JsonSchema; | |
| 4 | - | use serde::Deserialize; | |
| 5 | - | ||
| 6 | - | /// Parameters for listing contacts | |
| 7 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 8 | - | pub struct ListContactsParams { | |
| 9 | - | /// Filter by tag (optional) | |
| 10 | - | #[schemars(description = "Filter by tag (optional, e.g. 'client', 'friend')")] | |
| 11 | - | pub tag: Option<String>, | |
| 12 | - | ||
| 13 | - | /// Search query (optional, matches name, company, notes) | |
| 14 | - | #[schemars(description = "Search contacts by name, company, or notes (optional)")] | |
| 15 | - | pub search: Option<String>, | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | /// Parameters for creating a contact | |
| 19 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 20 | - | pub struct CreateContactParams { | |
| 21 | - | /// Display name (required) | |
| 22 | - | #[schemars(description = "Contact display name")] | |
| 23 | - | pub name: String, | |
| 24 | - | ||
| 25 | - | /// Company (optional) | |
| 26 | - | #[schemars(description = "Company or organization (optional)")] | |
| 27 | - | pub company: Option<String>, | |
| 28 | - | ||
| 29 | - | /// Title/role (optional) | |
| 30 | - | #[schemars(description = "Job title or role (optional)")] | |
| 31 | - | pub title: Option<String>, | |
| 32 | - | ||
| 33 | - | /// Notes (optional) | |
| 34 | - | #[schemars(description = "Free-text notes about the contact (optional)")] | |
| 35 | - | pub notes: Option<String>, | |
| 36 | - | ||
| 37 | - | /// Tags as comma-separated string (optional) | |
| 38 | - | #[schemars(description = "Comma-separated tags, e.g. 'client,engineering'")] | |
| 39 | - | pub tags: Option<String>, | |
| 40 | - | ||
| 41 | - | /// Birthday in YYYY-MM-DD format (optional) | |
| 42 | - | #[schemars(description = "Birthday in YYYY-MM-DD format (optional)")] | |
| 43 | - | pub birthday: Option<String>, | |
| 44 | - | ||
| 45 | - | /// Timezone (optional, IANA format e.g. America/New_York) | |
| 46 | - | #[schemars(description = "Timezone in IANA format, e.g. 'America/New_York' (optional)")] | |
| 47 | - | pub timezone: Option<String>, | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | /// Parameters for getting a contact | |
| 51 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 52 | - | pub struct GetContactParams { | |
| 53 | - | /// Contact ID | |
| 54 | - | #[schemars(description = "Contact ID (UUID)")] | |
| 55 | - | pub id: String, | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | /// Parameters for updating a contact | |
| 59 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 60 | - | pub struct UpdateContactParams { | |
| 61 | - | /// Contact ID to update | |
| 62 | - | #[schemars(description = "Contact ID (UUID) to update")] | |
| 63 | - | pub id: String, | |
| 64 | - | ||
| 65 | - | /// New display name (optional) | |
| 66 | - | #[schemars(description = "New display name (optional)")] | |
| 67 | - | pub name: Option<String>, | |
| 68 | - | ||
| 69 | - | /// New company (optional, empty string clears) | |
| 70 | - | #[schemars(description = "New company (optional, empty string clears)")] | |
| 71 | - | pub company: Option<String>, | |
| 72 | - | ||
| 73 | - | /// New title (optional, empty string clears) | |
| 74 | - | #[schemars(description = "New title (optional, empty string clears)")] | |
| 75 | - | pub title: Option<String>, | |
| 76 | - | ||
| 77 | - | /// New notes (optional) | |
| 78 | - | #[schemars(description = "New notes (optional)")] | |
| 79 | - | pub notes: Option<String>, | |
| 80 | - | ||
| 81 | - | /// New tags as comma-separated string (optional) | |
| 82 | - | #[schemars(description = "New comma-separated tags (optional)")] | |
| 83 | - | pub tags: Option<String>, | |
| 84 | - | ||
| 85 | - | /// New birthday (optional, empty string clears) | |
| 86 | - | #[schemars(description = "New birthday in YYYY-MM-DD format (optional, empty string clears)")] | |
| 87 | - | pub birthday: Option<String>, | |
| 88 | - | ||
| 89 | - | /// New timezone (optional, empty string clears) | |
| 90 | - | #[schemars(description = "New timezone in IANA format (optional, empty string clears)")] | |
| 91 | - | pub timezone: Option<String>, | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | /// Parameters for deleting a contact | |
| 95 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 96 | - | pub struct DeleteContactParams { | |
| 97 | - | /// Contact ID | |
| 98 | - | #[schemars(description = "Contact ID (UUID) to delete")] | |
| 99 | - | pub id: String, | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | /// Parameters for adding an email to a contact | |
| 103 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 104 | - | pub struct AddContactEmailParams { | |
| 105 | - | /// Contact ID | |
| 106 | - | #[schemars(description = "Contact ID (UUID) to add email to")] | |
| 107 | - | pub contact_id: String, | |
| 108 | - | ||
| 109 | - | /// Email address | |
| 110 | - | #[schemars(description = "Email address")] | |
| 111 | - | pub address: String, | |
| 112 | - | ||
| 113 | - | /// Label (e.g. 'work', 'personal') | |
| 114 | - | #[schemars(description = "Label for this email, e.g. 'work', 'personal'")] | |
| 115 | - | pub label: Option<String>, | |
| 116 | - | ||
| 117 | - | /// Whether this is the primary email | |
| 118 | - | #[schemars(description = "Set as primary email (default: false)")] | |
| 119 | - | pub is_primary: Option<bool>, | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | /// Parameters for adding a phone to a contact | |
| 123 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 124 | - | pub struct AddContactPhoneParams { | |
| 125 | - | /// Contact ID | |
| 126 | - | #[schemars(description = "Contact ID (UUID) to add phone to")] | |
| 127 | - | pub contact_id: String, | |
| 128 | - | ||
| 129 | - | /// Phone number | |
| 130 | - | #[schemars(description = "Phone number")] | |
| 131 | - | pub number: String, | |
| 132 | - | ||
| 133 | - | /// Label (e.g. 'mobile', 'work') | |
| 134 | - | #[schemars(description = "Label for this phone, e.g. 'mobile', 'work'")] | |
| 135 | - | pub label: Option<String>, | |
| 136 | - | ||
| 137 | - | /// Whether this is the primary phone | |
| 138 | - | #[schemars(description = "Set as primary phone (default: false)")] | |
| 139 | - | pub is_primary: Option<bool>, | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | /// Parameters for adding a social handle to a contact | |
| 143 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 144 | - | pub struct AddContactSocialParams { | |
| 145 | - | /// Contact ID | |
| 146 | - | #[schemars(description = "Contact ID (UUID) to add social handle to")] | |
| 147 | - | pub contact_id: String, | |
| 148 | - | ||
| 149 | - | /// Platform name (e.g. 'GitHub', 'Twitter', 'LinkedIn') | |
| 150 | - | #[schemars(description = "Platform name, e.g. 'GitHub', 'Twitter', 'LinkedIn'")] | |
| 151 | - | pub platform: String, | |
| 152 | - | ||
| 153 | - | /// Handle/username | |
| 154 | - | #[schemars(description = "Handle or username on the platform")] | |
| 155 | - | pub handle: String, | |
| 156 | - | ||
| 157 | - | /// Profile URL (optional) | |
| 158 | - | #[schemars(description = "Profile URL (optional)")] | |
| 159 | - | pub url: Option<String>, | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | /// Parameters for adding a custom field to a contact | |
| 163 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 164 | - | pub struct AddContactCustomFieldParams { | |
| 165 | - | /// Contact ID | |
| 166 | - | #[schemars(description = "Contact ID (UUID) to add custom field to")] | |
| 167 | - | pub contact_id: String, | |
| 168 | - | ||
| 169 | - | /// Field label | |
| 170 | - | #[schemars(description = "Custom field label, e.g. 'Portfolio', 'Office Hours'")] | |
| 171 | - | pub label: String, | |
| 172 | - | ||
| 173 | - | /// Field value | |
| 174 | - | #[schemars(description = "Custom field value")] | |
| 175 | - | pub value: String, | |
| 176 | - | ||
| 177 | - | /// URL (optional, makes the field a link) | |
| 178 | - | #[schemars(description = "URL associated with the field (optional)")] | |
| 179 | - | pub url: Option<String>, | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | /// Parameters for removing a sub-collection item by ID | |
| 183 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 184 | - | pub struct RemoveContactSubItemParams { | |
| 185 | - | /// Item ID to remove | |
| 186 | - | #[schemars(description = "ID (UUID) of the email, phone, social handle, or custom field to remove")] | |
| 187 | - | pub id: String, | |
| 188 | - | } |
| @@ -1,243 +0,0 @@ | |||
| 1 | - | //! Event implementation methods for GoingsOnServer. | |
| 2 | - | ||
| 3 | - | use chrono::{DateTime, Utc}; | |
| 4 | - | use tracing::instrument; | |
| 5 | - | ||
| 6 | - | use goingson_core::{BlockType, EventId, NewEvent, ParseableEnum, Recurrence, UpdateEvent}; | |
| 7 | - | ||
| 8 | - | use crate::state::DESKTOP_USER_ID; | |
| 9 | - | ||
| 10 | - | use super::GoingsOnServer; | |
| 11 | - | use super::results::EventResult; | |
| 12 | - | use super::event_params::*; | |
| 13 | - | ||
| 14 | - | impl GoingsOnServer { | |
| 15 | - | pub(crate) fn event_to_result(event: &goingson_core::Event) -> EventResult { | |
| 16 | - | EventResult { | |
| 17 | - | id: event.id.to_string(), | |
| 18 | - | title: event.title.clone(), | |
| 19 | - | description: event.description.clone(), | |
| 20 | - | start_time: event.start_time.to_rfc3339(), | |
| 21 | - | end_time: event.end_time.map(|d| d.to_rfc3339()), | |
| 22 | - | location: event.location.clone(), | |
| 23 | - | project_name: event.project_name.clone(), | |
| 24 | - | recurrence: event.recurrence.as_str().to_string(), | |
| 25 | - | } | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | #[instrument(skip_all)] | |
| 29 | - | pub(crate) async fn create_event_impl( | |
| 30 | - | &self, | |
| 31 | - | params: CreateEventParams, | |
| 32 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 33 | - | if params.title.trim().is_empty() { | |
| 34 | - | return Err("Event title is required".into()); | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | let start_time: DateTime<Utc> = DateTime::parse_from_rfc3339(¶ms.start_time)?.with_timezone(&Utc); | |
| 38 | - | let end_time: Option<DateTime<Utc>> = match ¶ms.end_time { | |
| 39 | - | Some(s) => Some(DateTime::parse_from_rfc3339(s)?.with_timezone(&Utc)), | |
| 40 | - | None => None, | |
| 41 | - | }; | |
| 42 | - | ||
| 43 | - | let project_id = match ¶ms.project { | |
| 44 | - | Some(name) => { | |
| 45 | - | let project = self.state.projects.find_by_name(DESKTOP_USER_ID, name).await?; | |
| 46 | - | match project { | |
| 47 | - | Some(p) => Some(p.id), | |
| 48 | - | None => return Err(format!("Project '{}' not found", name).into()), | |
| 49 | - | } | |
| 50 | - | } | |
| 51 | - | None => None, | |
| 52 | - | }; | |
| 53 | - | ||
| 54 | - | let recurrence = params.recurrence | |
| 55 | - | .as_deref() | |
| 56 | - | .map(Recurrence::from_str_or_default) | |
| 57 | - | .unwrap_or(Recurrence::None); | |
| 58 | - | ||
| 59 | - | // Look up contact by name | |
| 60 | - | let contact_id = match ¶ms.contact { | |
| 61 | - | Some(name) => { | |
| 62 | - | let contact = self.find_contact_by_name(name).await?; | |
| 63 | - | match contact { | |
| 64 | - | Some(c) => Some(c.id), | |
| 65 | - | None => return Err(format!("Contact '{}' not found", name).into()), | |
| 66 | - | } | |
| 67 | - | } | |
| 68 | - | None => None, | |
| 69 | - | }; | |
| 70 | - | ||
| 71 | - | // Parse block type | |
| 72 | - | let block_type = params.block_type | |
| 73 | - | .as_deref() | |
| 74 | - | .and_then(BlockType::from_str_opt); | |
| 75 | - | ||
| 76 | - | let new_event = NewEvent { | |
| 77 | - | user_id: Some(DESKTOP_USER_ID), | |
| 78 | - | project_id, | |
| 79 | - | title: params.title, | |
| 80 | - | description: params.description.unwrap_or_default(), | |
| 81 | - | start_time, | |
| 82 | - | end_time, | |
| 83 | - | location: params.location, | |
| 84 | - | linked_task_id: None, | |
| 85 | - | recurrence, | |
| 86 | - | contact_id, | |
| 87 | - | block_type, | |
| 88 | - | }; | |
| 89 | - | ||
| 90 | - | let event = self.state.events.create(DESKTOP_USER_ID, new_event).await?; | |
| 91 | - | let result = Self::event_to_result(&event); | |
| 92 | - | Ok(format!("Event created:\n{}", serde_json::to_string_pretty(&result)?)) | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | #[instrument(skip_all)] | |
| 96 | - | pub(crate) async fn list_events_impl( | |
| 97 | - | &self, | |
| 98 | - | params: ListEventsParams, | |
| 99 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 100 | - | let events = if let Some(days) = params.days { | |
| 101 | - | self.state.events.get_upcoming(DESKTOP_USER_ID, days).await? | |
| 102 | - | } else { | |
| 103 | - | self.state.events.list_all(DESKTOP_USER_ID).await? | |
| 104 | - | }; | |
| 105 | - | ||
| 106 | - | // Filter by project name if specified | |
| 107 | - | let filtered: Vec<_> = if let Some(ref project_filter) = params.project { | |
| 108 | - | events.into_iter() | |
| 109 | - | .filter(|e| e.project_name.as_ref() | |
| 110 | - | .map(|n| n.to_lowercase().contains(&project_filter.to_lowercase())) | |
| 111 | - | .unwrap_or(false)) | |
| 112 | - | .collect() | |
| 113 | - | } else { | |
| 114 | - | events | |
| 115 | - | }; | |
| 116 | - | ||
| 117 | - | if filtered.is_empty() { | |
| 118 | - | return Ok("No events found.".to_string()); | |
| 119 | - | } | |
| 120 | - | let results: Vec<EventResult> = filtered.iter().map(Self::event_to_result).collect(); | |
| 121 | - | Ok(serde_json::to_string_pretty(&results)?) | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | #[instrument(skip_all)] | |
| 125 | - | pub(crate) async fn list_upcoming_events_impl( | |
| 126 | - | &self, | |
| 127 | - | params: ListUpcomingEventsParams, | |
| 128 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 129 | - | let days = params.days.unwrap_or(7); | |
| 130 | - | let events = self.state.events.get_upcoming(DESKTOP_USER_ID, days).await?; | |
| 131 | - | if events.is_empty() { | |
| 132 | - | return Ok(format!("No events in the next {} days.", days)); | |
| 133 | - | } | |
| 134 | - | let results: Vec<EventResult> = events.iter().map(Self::event_to_result).collect(); | |
| 135 | - | Ok(serde_json::to_string_pretty(&results)?) | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | #[instrument(skip_all)] | |
| 139 | - | pub(crate) async fn get_event_impl( | |
| 140 | - | &self, | |
| 141 | - | params: GetEventParams, | |
| 142 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 143 | - | let id: EventId = params.id.parse::<uuid::Uuid>()?.into(); | |
| 144 | - | let event = self.state.events.get_by_id(id, DESKTOP_USER_ID).await? | |
| 145 | - | .ok_or_else(|| format!("Event {} not found", id))?; | |
| 146 | - | let result = Self::event_to_result(&event); | |
| 147 | - | Ok(serde_json::to_string_pretty(&result)?) | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | #[instrument(skip_all)] | |
| 151 | - | pub(crate) async fn update_event_impl( | |
| 152 | - | &self, | |
| 153 | - | params: UpdateEventParams, | |
| 154 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 155 | - | let id: EventId = params.id.parse::<uuid::Uuid>()?.into(); | |
| 156 | - | let existing = self.state.events.get_by_id(id, DESKTOP_USER_ID).await? | |
| 157 | - | .ok_or_else(|| format!("Event {} not found", id))?; | |
| 158 | - | ||
| 159 | - | let title = params.title.unwrap_or(existing.title); | |
| 160 | - | let start_time = match ¶ms.start_time { | |
| 161 | - | Some(s) => DateTime::parse_from_rfc3339(s)?.with_timezone(&Utc), | |
| 162 | - | None => existing.start_time, | |
| 163 | - | }; | |
| 164 | - | let end_time = match ¶ms.end_time { | |
| 165 | - | Some(s) if s.is_empty() => None, | |
| 166 | - | Some(s) => Some(DateTime::parse_from_rfc3339(s)?.with_timezone(&Utc)), | |
| 167 | - | None => existing.end_time, | |
| 168 | - | }; | |
| 169 | - | let description = params.description.unwrap_or(existing.description); | |
| 170 | - | let location = match ¶ms.location { | |
| 171 | - | Some(s) if s.is_empty() => None, | |
| 172 | - | Some(s) => Some(s.clone()), | |
| 173 | - | None => existing.location, | |
| 174 | - | }; | |
| 175 | - | let project_id = match ¶ms.project { | |
| 176 | - | Some(name) if name.is_empty() => None, | |
| 177 | - | Some(name) => { | |
| 178 | - | let project = self.state.projects.find_by_name(DESKTOP_USER_ID, name).await?; | |
| 179 | - | match project { | |
| 180 | - | Some(p) => Some(p.id), | |
| 181 | - | None => return Err(format!("Project '{}' not found", name).into()), | |
| 182 | - | } | |
| 183 | - | } | |
| 184 | - | None => existing.project_id, | |
| 185 | - | }; | |
| 186 | - | let recurrence = params.recurrence | |
| 187 | - | .as_deref() | |
| 188 | - | .map(Recurrence::from_str_or_default) | |
| 189 | - | .unwrap_or(existing.recurrence); | |
| 190 | - | ||
| 191 | - | // Look up contact (empty string clears) | |
| 192 | - | let contact_id = match ¶ms.contact { | |
| 193 | - | Some(name) if name.is_empty() => None, | |
| 194 | - | Some(name) => { | |
| 195 | - | let contact = self.find_contact_by_name(name).await?; | |
| 196 | - | match contact { | |
| 197 | - | Some(c) => Some(c.id), | |
| 198 | - | None => return Err(format!("Contact '{}' not found", name).into()), | |
| 199 | - | } | |
| 200 | - | } | |
| 201 | - | None => existing.contact_id, | |
| 202 | - | }; | |
| 203 | - | ||
| 204 | - | // Parse block type (empty string clears) | |
| 205 | - | let block_type = match ¶ms.block_type { | |
| 206 | - | Some(s) if s.is_empty() => None, | |
| 207 | - | Some(s) => BlockType::from_str_opt(s), | |
| 208 | - | None => existing.block_type, | |
| 209 | - | }; | |
| 210 | - | ||
| 211 | - | let updated_event = UpdateEvent { | |
| 212 | - | project_id, | |
| 213 | - | title, | |
| 214 | - | description, | |
| 215 | - | start_time, | |
| 216 | - | end_time, | |
| 217 | - | location, | |
| 218 | - | linked_task_id: existing.linked_task_id, | |
| 219 | - | recurrence, | |
| 220 | - | contact_id, | |
| 221 | - | block_type, | |
| 222 | - | }; | |
| 223 | - | ||
| 224 | - | let event = self.state.events.update(id, DESKTOP_USER_ID, updated_event).await? | |
| 225 | - | .ok_or_else(|| format!("Event {} not found", id))?; | |
| 226 | - | let result = Self::event_to_result(&event); | |
| 227 | - | Ok(format!("Event updated:\n{}", serde_json::to_string_pretty(&result)?)) | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | #[instrument(skip_all)] | |
| 231 | - | pub(crate) async fn delete_event_impl( | |
| 232 | - | &self, | |
| 233 | - | params: DeleteEventParams, | |
| 234 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 235 | - | let id: EventId = params.id.parse::<uuid::Uuid>()?.into(); | |
| 236 | - | let deleted = self.state.events.delete(id, DESKTOP_USER_ID).await?; | |
| 237 | - | if deleted { | |
| 238 | - | Ok(format!("Event {} deleted.", id)) | |
| 239 | - | } else { | |
| 240 | - | Ok(format!("Event {} not found.", id)) | |
| 241 | - | } | |
| 242 | - | } | |
| 243 | - | } |
| @@ -1,124 +0,0 @@ | |||
| 1 | - | //! Parameter types for event MCP tools. | |
| 2 | - | ||
| 3 | - | use schemars::JsonSchema; | |
| 4 | - | use serde::Deserialize; | |
| 5 | - | ||
| 6 | - | /// Parameters for creating an event | |
| 7 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 8 | - | pub struct CreateEventParams { | |
| 9 | - | /// Event title | |
| 10 | - | #[schemars(description = "Event title")] | |
| 11 | - | pub title: String, | |
| 12 | - | ||
| 13 | - | /// Start time (ISO 8601) | |
| 14 | - | #[schemars(description = "Start time in ISO 8601 format")] | |
| 15 | - | pub start_time: String, | |
| 16 | - | ||
| 17 | - | /// End time (optional, ISO 8601) | |
| 18 | - | #[schemars(description = "End time in ISO 8601 format (optional)")] | |
| 19 | - | pub end_time: Option<String>, | |
| 20 | - | ||
| 21 | - | /// Description (optional) | |
| 22 | - | #[schemars(description = "Event description (optional)")] | |
| 23 | - | pub description: Option<String>, | |
| 24 | - | ||
| 25 | - | /// Location (optional) | |
| 26 | - | #[schemars(description = "Event location (optional)")] | |
| 27 | - | pub location: Option<String>, | |
| 28 | - | ||
| 29 | - | /// Project name (optional) | |
| 30 | - | #[schemars(description = "Project name to assign event to (optional)")] | |
| 31 | - | pub project: Option<String>, | |
| 32 | - | ||
| 33 | - | /// Recurrence (optional) | |
| 34 | - | #[schemars(description = "Recurrence: Daily, Weekly, Monthly, or None (default: None)")] | |
| 35 | - | pub recurrence: Option<String>, | |
| 36 | - | ||
| 37 | - | /// Contact name to link (optional, partial match) | |
| 38 | - | #[schemars(description = "Contact name to link to event (optional, partial match)")] | |
| 39 | - | pub contact: Option<String>, | |
| 40 | - | ||
| 41 | - | /// Block type (optional: Focus, Personal, Vacation) | |
| 42 | - | #[schemars(description = "Block type: Focus, Personal, or Vacation (optional)")] | |
| 43 | - | pub block_type: Option<String>, | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | /// Parameters for listing events | |
| 47 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 48 | - | pub struct ListEventsParams { | |
| 49 | - | /// Filter by project name (optional) | |
| 50 | - | #[schemars(description = "Filter by project name (optional)")] | |
| 51 | - | pub project: Option<String>, | |
| 52 | - | ||
| 53 | - | /// Limit to upcoming N days (optional) | |
| 54 | - | #[schemars(description = "Only show events in the next N days (optional)")] | |
| 55 | - | pub days: Option<i64>, | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | /// Parameters for listing upcoming events | |
| 59 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 60 | - | pub struct ListUpcomingEventsParams { | |
| 61 | - | /// Number of days ahead (default: 7) | |
| 62 | - | #[schemars(description = "Number of days ahead to look (default: 7)")] | |
| 63 | - | pub days: Option<i64>, | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | /// Parameters for getting an event | |
| 67 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 68 | - | pub struct GetEventParams { | |
| 69 | - | /// Event ID | |
| 70 | - | #[schemars(description = "Event ID (UUID)")] | |
| 71 | - | pub id: String, | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | /// Parameters for updating an event | |
| 75 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 76 | - | pub struct UpdateEventParams { | |
| 77 | - | /// Event ID to update | |
| 78 | - | #[schemars(description = "Event ID (UUID) to update")] | |
| 79 | - | pub id: String, | |
| 80 | - | ||
| 81 | - | /// New title (optional) | |
| 82 | - | #[schemars(description = "New event title (optional)")] | |
| 83 | - | pub title: Option<String>, | |
| 84 | - | ||
| 85 | - | /// New start time (optional, ISO 8601) | |
| 86 | - | #[schemars(description = "New start time in ISO 8601 format (optional)")] | |
| 87 | - | pub start_time: Option<String>, | |
| 88 | - | ||
| 89 | - | /// New end time (optional, ISO 8601, empty string clears) | |
| 90 | - | #[schemars(description = "New end time in ISO 8601 format (optional, empty string clears)")] | |
| 91 | - | pub end_time: Option<String>, | |
| 92 | - | ||
| 93 | - | /// New description (optional) | |
| 94 | - | #[schemars(description = "New event description (optional)")] | |
| 95 | - | pub description: Option<String>, | |
| 96 | - | ||
| 97 | - | /// New location (optional, empty string clears) | |
| 98 | - | #[schemars(description = "New location (optional, empty string clears)")] | |
| 99 | - | pub location: Option<String>, | |
| 100 | - | ||
| 101 | - | /// New project name (optional, empty string clears) | |
| 102 | - | #[schemars(description = "New project name (optional, empty string clears)")] | |
| 103 | - | pub project: Option<String>, | |
| 104 | - | ||
| 105 | - | /// New recurrence (optional) | |
| 106 | - | #[schemars(description = "New recurrence: Daily, Weekly, Monthly, or None (optional)")] | |
| 107 | - | pub recurrence: Option<String>, | |
| 108 | - | ||
| 109 | - | /// New contact name (optional, empty string clears) | |
| 110 | - | #[schemars(description = "New contact name (optional, empty string clears)")] | |
| 111 | - | pub contact: Option<String>, | |
| 112 | - | ||
| 113 | - | /// New block type (optional, empty string clears) | |
| 114 | - | #[schemars(description = "New block type: Focus, Personal, Vacation, or empty string to clear (optional)")] | |
| 115 | - | pub block_type: Option<String>, | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | /// Parameters for deleting an event | |
| 119 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 120 | - | pub struct DeleteEventParams { | |
| 121 | - | /// Event ID | |
| 122 | - | #[schemars(description = "Event ID (UUID) to delete")] | |
| 123 | - | pub id: String, | |
| 124 | - | } |
| @@ -1,161 +0,0 @@ | |||
| 1 | - | //! Milestone implementation methods for GoingsOnServer. | |
| 2 | - | ||
| 3 | - | use chrono::NaiveDate; | |
| 4 | - | use tracing::instrument; | |
| 5 | - | ||
| 6 | - | use goingson_core::{MilestoneId, MilestoneStatus, NewMilestone, ParseableEnum, ProjectId, TaskStatus}; | |
| 7 | - | ||
| 8 | - | use crate::state::DESKTOP_USER_ID; | |
| 9 | - | ||
| 10 | - | use super::GoingsOnServer; | |
| 11 | - | use super::results::MilestoneResult; | |
| 12 | - | use super::milestone_params::*; | |
| 13 | - | ||
| 14 | - | impl GoingsOnServer { | |
| 15 | - | #[instrument(skip_all)] | |
| 16 | - | pub(crate) async fn list_milestones_impl( | |
| 17 | - | &self, | |
| 18 | - | params: ListMilestonesParams, | |
| 19 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 20 | - | let project_id: ProjectId = params.project_id.parse::<uuid::Uuid>()?.into(); | |
| 21 | - | let milestones = self.state.milestones.list_by_project(project_id, DESKTOP_USER_ID).await?; | |
| 22 | - | ||
| 23 | - | if milestones.is_empty() { | |
| 24 | - | return Ok("No milestones found for this project.".to_string()); | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | // Get tasks for progress calculation | |
| 28 | - | let all_tasks = self.state.tasks.list_all(DESKTOP_USER_ID).await?; | |
| 29 | - | ||
| 30 | - | let results: Vec<MilestoneResult> = milestones.iter().map(|m| { | |
| 31 | - | let milestone_tasks: Vec<_> = all_tasks.iter() | |
| 32 | - | .filter(|t| t.milestone_id == Some(m.id) && t.status != TaskStatus::Deleted) | |
| 33 | - | .collect(); | |
| 34 | - | let task_count = milestone_tasks.len(); | |
| 35 | - | let completed_count = milestone_tasks.iter() | |
| 36 | - | .filter(|t| t.status == TaskStatus::Completed) | |
| 37 | - | .count(); | |
| 38 | - | let progress = if task_count > 0 { | |
| 39 | - | (completed_count as f32 / task_count as f32) * 100.0 | |
| 40 | - | } else { | |
| 41 | - | 0.0 | |
| 42 | - | }; | |
| 43 | - | ||
| 44 | - | MilestoneResult { | |
| 45 | - | id: m.id.to_string(), | |
| 46 | - | project_id: m.project_id.to_string(), | |
| 47 | - | name: m.name.clone(), | |
| 48 | - | description: m.description.clone(), | |
| 49 | - | position: m.position, | |
| 50 | - | target_date: m.target_date.map(|d| d.to_string()), | |
| 51 | - | status: m.status.as_str().to_string(), | |
| 52 | - | task_count, | |
| 53 | - | completed_count, | |
| 54 | - | progress, | |
| 55 | - | } | |
| 56 | - | }).collect(); | |
| 57 | - | ||
| 58 | - | Ok(serde_json::to_string_pretty(&results)?) | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | #[instrument(skip_all)] | |
| 62 | - | pub(crate) async fn create_milestone_impl( | |
| 63 | - | &self, | |
| 64 | - | params: CreateMilestoneParams, | |
| 65 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 66 | - | let project_id: ProjectId = params.project_id.parse::<uuid::Uuid>()?.into(); | |
| 67 | - | ||
| 68 | - | if params.name.trim().is_empty() { | |
| 69 | - | return Err("Milestone name is required".into()); | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | let target_date: Option<NaiveDate> = match ¶ms.target_date { | |
| 73 | - | Some(s) => Some(NaiveDate::parse_from_str(s, "%Y-%m-%d")?), | |
| 74 | - | None => None, | |
| 75 | - | }; | |
| 76 | - | ||
| 77 | - | // Get current milestones to determine position | |
| 78 | - | let existing = self.state.milestones.list_by_project(project_id, DESKTOP_USER_ID).await?; | |
| 79 | - | let position = existing.len() as i32; | |
| 80 | - | ||
| 81 | - | let new_milestone = NewMilestone { | |
| 82 | - | project_id, | |
| 83 | - | name: params.name, | |
| 84 | - | description: params.description.unwrap_or_default(), | |
| 85 | - | position, | |
| 86 | - | target_date, | |
| 87 | - | }; | |
| 88 | - | ||
| 89 | - | let milestone = self.state.milestones.create(DESKTOP_USER_ID, new_milestone).await?; | |
| 90 | - | ||
| 91 | - | let result = MilestoneResult { | |
| 92 | - | id: milestone.id.to_string(), | |
| 93 | - | project_id: milestone.project_id.to_string(), | |
| 94 | - | name: milestone.name, | |
| 95 | - | description: milestone.description, | |
| 96 | - | position: milestone.position, | |
| 97 | - | target_date: milestone.target_date.map(|d| d.to_string()), | |
| 98 | - | status: milestone.status.as_str().to_string(), | |
| 99 | - | task_count: 0, | |
| 100 | - | completed_count: 0, | |
| 101 | - | progress: 0.0, | |
| 102 | - | }; | |
| 103 | - | ||
| 104 | - | Ok(format!("Milestone created:\n{}", serde_json::to_string_pretty(&result)?)) | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | #[instrument(skip_all)] | |
| 108 | - | pub(crate) async fn update_milestone_impl( | |
| 109 | - | &self, | |
| 110 | - | params: UpdateMilestoneParams, | |
| 111 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 112 | - | let id: MilestoneId = params.id.parse::<uuid::Uuid>()?.into(); | |
| 113 | - | ||
| 114 | - | let existing = self.state.milestones.get_by_id(id, DESKTOP_USER_ID).await? | |
| 115 | - | .ok_or_else(|| format!("Milestone {} not found", id))?; | |
| 116 | - | ||
| 117 | - | let name = params.name.unwrap_or(existing.name); | |
| 118 | - | let description = params.description.unwrap_or(existing.description); | |
| 119 | - | let target_date = match ¶ms.target_date { | |
| 120 | - | Some(s) if s.is_empty() => None, | |
| 121 | - | Some(s) => Some(NaiveDate::parse_from_str(s, "%Y-%m-%d")?), | |
| 122 | - | None => existing.target_date, | |
| 123 | - | }; | |
| 124 | - | let status = params.status | |
| 125 | - | .as_deref() | |
| 126 | - | .map(MilestoneStatus::from_str_or_default) | |
| 127 | - | .unwrap_or(existing.status); | |
| 128 | - | ||
| 129 | - | let milestone = self.state.milestones.update(id, DESKTOP_USER_ID, &name, &description, target_date, &status).await? | |
| 130 | - | .ok_or_else(|| format!("Milestone {} not found", id))?; | |
| 131 | - | ||
| 132 | - | let result = MilestoneResult { | |
| 133 | - | id: milestone.id.to_string(), | |
| 134 | - | project_id: milestone.project_id.to_string(), | |
| 135 | - | name: milestone.name, | |
| 136 | - | description: milestone.description, | |
| 137 | - | position: milestone.position, | |
| 138 | - | target_date: milestone.target_date.map(|d| d.to_string()), | |
| 139 | - | status: milestone.status.as_str().to_string(), | |
| 140 | - | task_count: 0, | |
| 141 | - | completed_count: 0, | |
| 142 | - | progress: 0.0, | |
| 143 | - | }; | |
| 144 | - | ||
| 145 | - | Ok(format!("Milestone updated:\n{}", serde_json::to_string_pretty(&result)?)) | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | #[instrument(skip_all)] | |
| 149 | - | pub(crate) async fn delete_milestone_impl( | |
| 150 | - | &self, | |
| 151 | - | params: DeleteMilestoneParams, | |
| 152 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 153 | - | let id: MilestoneId = params.id.parse::<uuid::Uuid>()?.into(); | |
| 154 | - | let deleted = self.state.milestones.delete(id, DESKTOP_USER_ID).await?; | |
| 155 | - | if deleted { | |
| 156 | - | Ok(format!("Milestone {} deleted.", id)) | |
| 157 | - | } else { | |
| 158 | - | Ok(format!("Milestone {} not found.", id)) | |
| 159 | - | } | |
| 160 | - | } | |
| 161 | - | } |
| @@ -1,64 +0,0 @@ | |||
| 1 | - | //! Parameter types for milestone MCP tools. | |
| 2 | - | ||
| 3 | - | use schemars::JsonSchema; | |
| 4 | - | use serde::Deserialize; | |
| 5 | - | ||
| 6 | - | /// Parameters for listing milestones | |
| 7 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 8 | - | pub struct ListMilestonesParams { | |
| 9 | - | /// Project ID to list milestones for | |
| 10 | - | #[schemars(description = "Project ID (UUID) to list milestones for")] | |
| 11 | - | pub project_id: String, | |
| 12 | - | } | |
| 13 | - | ||
| 14 | - | /// Parameters for creating a milestone | |
| 15 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 16 | - | pub struct CreateMilestoneParams { | |
| 17 | - | /// Project ID to create milestone in | |
| 18 | - | #[schemars(description = "Project ID (UUID) to create milestone in")] | |
| 19 | - | pub project_id: String, | |
| 20 | - | ||
| 21 | - | /// Milestone name | |
| 22 | - | #[schemars(description = "Milestone name")] | |
| 23 | - | pub name: String, | |
| 24 | - | ||
| 25 | - | /// Milestone description (optional) | |
| 26 | - | #[schemars(description = "Milestone description (optional)")] | |
| 27 | - | pub description: Option<String>, | |
| 28 | - | ||
| 29 | - | /// Target date in ISO 8601 format (optional) | |
| 30 | - | #[schemars(description = "Target date in ISO 8601 format, e.g. 2026-03-01 (optional)")] | |
| 31 | - | pub target_date: Option<String>, | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | /// Parameters for updating a milestone | |
| 35 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 36 | - | pub struct UpdateMilestoneParams { | |
| 37 | - | /// Milestone ID to update | |
| 38 | - | #[schemars(description = "Milestone ID (UUID) to update")] | |
| 39 | - | pub id: String, | |
| 40 | - | ||
| 41 | - | /// New name (optional) | |
| 42 | - | #[schemars(description = "New milestone name (optional)")] | |
| 43 | - | pub name: Option<String>, | |
| 44 | - | ||
| 45 | - | /// New description (optional) | |
| 46 | - | #[schemars(description = "New milestone description (optional)")] | |
| 47 | - | pub description: Option<String>, | |
| 48 | - | ||
| 49 | - | /// New target date (optional, empty string clears) | |
| 50 | - | #[schemars(description = "New target date in ISO 8601 format (optional, empty string clears)")] | |
| 51 | - | pub target_date: Option<String>, | |
| 52 | - | ||
| 53 | - | /// New status: open or completed (optional) | |
| 54 | - | #[schemars(description = "New status: open or completed (optional)")] | |
| 55 | - | pub status: Option<String>, | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | /// Parameters for deleting a milestone | |
| 59 | - | #[derive(Debug, Deserialize, JsonSchema)] | |
| 60 | - | pub struct DeleteMilestoneParams { | |
| 61 | - | /// Milestone ID | |
| 62 | - | #[schemars(description = "Milestone ID (UUID) to delete")] | |
| 63 | - | pub id: String, | |
| 64 | - | } |
| @@ -1,701 +0,0 @@ | |||
| 1 | - | //! MCP tool definitions for GoingsOn task management. | |
| 2 | - | //! | |
| 3 | - | //! Provides 47 tools for full task, project, event, contact, and dashboard management. | |
| 4 | - | //! | |
| 5 | - | //! This module is split into sub-modules by domain: | |
| 6 | - | //! - `results` - All response/result structs | |
| 7 | - | //! - `task_params` / `task_impl` - Task, annotation, and subtask tools | |
| 8 | - | //! - `project_params` / `project_impl` - Project tools | |
| 9 | - | //! - `event_params` / `event_impl` - Event tools | |
| 10 | - | //! - `milestone_params` / `milestone_impl` - Milestone tools | |
| 11 | - | //! - `contact_params` / `contact_impl` - Contact tools | |
| 12 | - | //! - `utility_params` / `utility_impl` - Search, context, roadmap, dashboard tools | |
| 13 | - | ||
| 14 | - | mod results; | |
| 15 | - | mod task_params; | |
| 16 | - | mod task_impl; | |
| 17 | - | mod project_params; | |
| 18 | - | mod project_impl; | |
| 19 | - | mod event_params; | |
| 20 | - | mod event_impl; | |
| 21 | - | mod milestone_params; | |
| 22 | - | mod milestone_impl; | |
| 23 | - | mod contact_params; | |
| 24 | - | mod contact_impl; | |
| 25 | - | mod utility_params; | |
| 26 | - | mod utility_impl; | |
| 27 | - | ||
| 28 | - | // Re-export param types for external use | |
| 29 | - | pub use task_params::*; | |
| 30 | - | pub use project_params::*; | |
| 31 | - | pub use event_params::*; | |
| 32 | - | pub use milestone_params::*; | |
| 33 | - | pub use contact_params::*; | |
| 34 | - | pub use utility_params::*; | |
| 35 | - | ||
| 36 | - | use rmcp::tool; | |
| 37 | - | ||
| 38 | - | use crate::state::McpState; | |
| 39 | - | ||
| 40 | - | // ============ MCP Server ============ | |
| 41 | - | ||
| 42 | - | /// GoingsOn MCP server for task management | |
| 43 | - | #[derive(Clone)] | |
| 44 | - | pub struct GoingsOnServer { | |
| 45 | - | pub(crate) state: McpState, | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | impl GoingsOnServer { | |
| 49 | - | pub fn new(state: McpState) -> Self { | |
| 50 | - | Self { state } | |
| 51 | - | } | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // All #[tool(...)] method definitions must live in this single #[tool(tool_box)] block | |
| 55 | - | // because the rmcp macro requires it. Each method is a thin wrapper that delegates | |
| 56 | - | // to a `_impl` method defined in the corresponding domain `_impl` sub-module. | |
| 57 | - | #[tool(tool_box)] | |
| 58 | - | impl GoingsOnServer { | |
| 59 | - | // --- Task Tools --- | |
| 60 | - | ||
| 61 | - | /// List pending tasks from GoingsOn. | |
| 62 | - | #[tool(description = "List pending tasks from GoingsOn. Returns tasks sorted by urgency. Use this to see what needs to be done.")] | |
| 63 | - | pub async fn list_tasks( | |
| 64 | - | &self, | |
| 65 | - | #[tool(aggr)] params: ListTasksParams, | |
| 66 | - | ) -> String { | |
| 67 | - | match self.list_tasks_impl(params).await { | |
| 68 | - | Ok(result) => result, | |
| 69 | - | Err(e) => format!("Error listing tasks: {}", e), | |
| 70 | - | } | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | /// Create a new task in GoingsOn. | |
| 74 | - | #[tool(description = "Create a new task in GoingsOn. Provide description and optionally priority (high/medium/low), due date (ISO 8601), project name, tags, contact name, and milestone name.")] | |
| 75 | - | pub async fn create_task( | |
| 76 | - | &self, | |
| 77 | - | #[tool(aggr)] params: CreateTaskParams, | |
| 78 | - | ) -> String { | |
| 79 | - | match self.create_task_impl(params).await { | |
| 80 | - | Ok(result) => result, | |
| 81 | - | Err(e) => format!("Error creating task: {}", e), | |
| 82 | - | } | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | /// Update an existing task in GoingsOn. | |
| 86 | - | #[tool(description = "Update an existing task in GoingsOn. Provide task ID and fields to update (description, priority, due, project, tags, status, contact, milestone).")] | |
| 87 | - | pub async fn update_task( | |
| 88 | - | &self, | |
| 89 | - | #[tool(aggr)] params: UpdateTaskParams, | |
| 90 | - | ) -> String { | |
| 91 | - | match self.update_task_impl(params).await { | |
| 92 | - | Ok(result) => result, | |
| 93 | - | Err(e) => format!("Error updating task: {}", e), | |
| 94 | - | } | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | /// Delete a task from GoingsOn. | |
| 98 | - | #[tool(description = "Delete (soft-delete) a task from GoingsOn by its ID.")] | |
| 99 | - | pub async fn delete_task( | |
| 100 | - | &self, | |
| 101 | - | #[tool(aggr)] params: DeleteTaskParams, | |
| 102 | - | ) -> String { | |
| 103 | - | match self.delete_task_impl(params).await { | |
| 104 | - | Ok(result) => result, | |
| 105 | - | Err(e) => format!("Error deleting task: {}", e), | |
| 106 | - | } | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | /// Mark a task as completed in GoingsOn. | |
| 110 | - | #[tool(description = "Mark a task as completed in GoingsOn by its ID.")] | |
| 111 | - | pub async fn complete_task( | |
| 112 | - | &self, | |
| 113 | - | #[tool(aggr)] params: CompleteTaskParams, | |
| 114 | - | ) -> String { | |
| 115 | - | match self.complete_task_impl(params).await { | |
| 116 | - | Ok(result) => result, | |
| 117 | - | Err(e) => format!("Error completing task: {}", e), | |
| 118 | - | } | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | /// Snooze a task until a later time. | |
| 122 | - | #[tool(description = "Snooze a task until a later time. Use 'tomorrow', 'next_week', 'weekend', or an ISO 8601 datetime.")] | |
| 123 | - | pub async fn snooze_task( | |
| 124 | - | &self, | |
| 125 | - | #[tool(aggr)] params: SnoozeTaskParams, | |
| 126 | - | ) -> String { | |
| 127 | - | match self.snooze_task_impl(params).await { | |
| 128 | - | Ok(result) => result, | |
| 129 | - | Err(e) => format!("Error snoozing task: {}", e), | |
| 130 | - | } | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | /// Get a task by ID. | |
| 134 | - | #[tool(description = "Get a task by its ID. Returns full task details including annotations and subtasks.")] | |
| 135 | - | pub async fn get_task( | |
| 136 | - | &self, | |
| 137 | - | #[tool(aggr)] params: GetTaskParams, | |
| 138 | - | ) -> String { | |
| 139 | - | match self.get_task_impl(params).await { | |
| 140 | - | Ok(result) => result, | |
| 141 | - | Err(e) => format!("Error getting task: {}", e), | |
| 142 | - | } | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | /// Mark a task as started. | |
| 146 | - | #[tool(description = "Mark a task as started (in progress) by its ID.")] | |
| 147 | - | pub async fn start_task( | |
| 148 | - | &self, | |
| 149 | - | #[tool(aggr)] params: StartTaskParams, | |
| 150 | - | ) -> String { | |
| 151 | - | match self.start_task_impl(params).await { | |
| 152 | - | Ok(result) => result, | |
| 153 | - | Err(e) => format!("Error starting task: {}", e), | |
| 154 | - | } | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | /// List snoozed tasks. | |
| 158 | - | #[tool(description = "List all currently snoozed tasks.")] | |
| 159 | - | pub async fn list_snoozed_tasks(&self) -> String { | |
| 160 | - | match self.list_snoozed_tasks_impl().await { | |
| 161 | - | Ok(result) => result, | |
| 162 | - | Err(e) => format!("Error listing snoozed tasks: {}", e), | |
| 163 | - | } | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | /// Remove snooze from a task. | |
| 167 | - | #[tool(description = "Remove snooze from a task, making it active again.")] | |
| 168 | - | pub async fn unsnooze_task( | |
| 169 | - | &self, | |
| 170 | - | #[tool(aggr)] params: UnsnoozeTaskParams, | |
| 171 | - | ) -> String { | |
| 172 | - | match self.unsnooze_task_impl(params).await { | |
| 173 | - | Ok(result) => result, | |
| 174 | - | Err(e) => format!("Error unsnoozing task: {}", e), | |
| 175 | - | } | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | /// List tasks waiting for external response. | |
| 179 | - | #[tool(description = "List all tasks marked as waiting for external response.")] | |
| 180 | - | pub async fn list_waiting_tasks(&self) -> String { | |
| 181 | - | match self.list_waiting_tasks_impl().await { | |
| 182 | - | Ok(result) => result, | |
| 183 | - | Err(e) => format!("Error listing waiting tasks: {}", e), | |
| 184 | - | } | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | /// Mark a task as waiting for response. | |
| 188 | - | #[tool(description = "Mark a task as waiting for external response. Optionally provide an expected response date.")] | |
| 189 | - | pub async fn mark_task_waiting( | |
| 190 | - | &self, | |
| 191 | - | #[tool(aggr)] params: MarkTaskWaitingParams, | |
| 192 | - | ) -> String { | |
| 193 | - | match self.mark_task_waiting_impl(params).await { | |
| 194 | - | Ok(result) => result, | |
| 195 | - | Err(e) => format!("Error marking task as waiting: {}", e), | |
| 196 | - | } | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | /// Clear waiting status from a task. | |
| 200 | - | #[tool(description = "Clear the waiting-for-response status from a task.")] | |
| 201 | - | pub async fn clear_task_waiting( | |
| 202 | - | &self, | |
| 203 | - | #[tool(aggr)] params: ClearTaskWaitingParams, | |
| 204 | - | ) -> String { | |
| 205 | - | match self.clear_task_waiting_impl(params).await { | |
| 206 | - | Ok(result) => result, | |
| 207 | - | Err(e) => format!("Error clearing task waiting: {}", e), | |
| 208 | - | } | |
| 209 | - | } | |
| 210 | - | ||
| 211 | - | /// Add an annotation (note) to a task. | |
| 212 | - | #[tool(description = "Add a note/annotation to a task. Use for progress updates, context, or decisions.")] | |
| 213 | - | pub async fn add_annotation( | |
| 214 | - | &self, | |
| 215 | - | #[tool(aggr)] params: AddAnnotationParams, | |
| 216 | - | ) -> String { | |
| 217 | - | match self.add_annotation_impl(params).await { | |
| 218 | - | Ok(result) => result, | |
| 219 | - | Err(e) => format!("Error adding annotation: {}", e), | |
| 220 | - | } | |
| 221 | - | } | |
| 222 | - | ||
| 223 | - | /// List annotations for a task. | |
| 224 | - | #[tool(description = "List all annotations (notes) for a task.")] | |
| 225 | - | pub async fn list_annotations( | |
| 226 | - | &self, | |
| 227 | - | #[tool(aggr)] params: ListAnnotationsParams, | |
| 228 | - | ) -> String { | |
| 229 | - | match self.list_annotations_impl(params).await { | |
| 230 | - | Ok(result) => result, | |
| 231 | - | Err(e) => format!("Error listing annotations: {}", e), | |
| 232 | - | } | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | /// Delete an annotation. | |
| 236 | - | #[tool(description = "Delete an annotation by its ID.")] | |
| 237 | - | pub async fn delete_annotation( | |
| 238 | - | &self, | |
| 239 | - | #[tool(aggr)] params: DeleteAnnotationParams, | |
| 240 | - | ) -> String { | |
| 241 | - | match self.delete_annotation_impl(params).await { | |
| 242 | - | Ok(result) => result, | |
| 243 | - | Err(e) => format!("Error deleting annotation: {}", e), | |
| 244 | - | } | |
| 245 | - | } | |
| 246 | - | ||
| 247 | - | /// Add a subtask to a task. | |
| 248 | - | #[tool(description = "Add a checklist subtask to a task.")] | |
| 249 | - | pub async fn add_subtask( | |
| 250 | - | &self, | |
| 251 | - | #[tool(aggr)] params: AddSubtaskParams, | |
| 252 | - | ) -> String { | |
| 253 | - | match self.add_subtask_impl(params).await { | |
| 254 | - | Ok(result) => result, | |
| 255 | - | Err(e) => format!("Error adding subtask: {}", e), | |
| 256 | - | } | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | /// Link a task as a subtask of another task. | |
| 260 | - | #[tool(description = "Link a task as a subtask of another task. Used for multi-phase features where Phase 2 is linked to Phase 1.")] | |
| 261 | - | pub async fn add_subtask_link( | |
| 262 | - | &self, | |
| 263 | - | #[tool(aggr)] params: AddSubtaskLinkParams, | |
| 264 | - | ) -> String { | |
| 265 | - | match self.add_subtask_link_impl(params).await { | |
| 266 | - | Ok(result) => result, | |
| 267 | - | Err(e) => format!("Error linking task: {}", e), | |
| 268 | - | } | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | /// List subtasks for a task. | |
| 272 | - | #[tool(description = "List all subtasks (checklist items) for a task.")] | |
| 273 | - | pub async fn list_subtasks( | |
| 274 | - | &self, | |
| 275 | - | #[tool(aggr)] params: ListSubtasksParams, | |
| 276 | - | ) -> String { | |
| 277 | - | match self.list_subtasks_impl(params).await { | |
| 278 | - | Ok(result) => result, | |
| 279 | - | Err(e) => format!("Error listing subtasks: {}", e), | |
| 280 | - | } | |
| 281 | - | } | |
| 282 | - | ||
| 283 | - | /// Toggle a subtask's completion status. | |
| 284 | - | #[tool(description = "Toggle a subtask between completed and not completed.")] | |
| 285 | - | pub async fn toggle_subtask( | |
| 286 | - | &self, | |
| 287 | - | #[tool(aggr)] params: ToggleSubtaskParams, | |
| 288 | - | ) -> String { | |
| 289 | - | match self.toggle_subtask_impl(params).await { | |
| 290 | - | Ok(result) => result, | |
| 291 | - | Err(e) => format!("Error toggling subtask: {}", e), | |
| 292 | - | } | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | /// Delete a subtask. | |
| 296 | - | #[tool(description = "Delete a subtask by its ID.")] | |
| 297 | - | pub async fn delete_subtask( | |
| 298 | - | &self, | |
| 299 | - | #[tool(aggr)] params: DeleteSubtaskParams, | |
| 300 | - | ) -> String { | |
| 301 | - | match self.delete_subtask_impl(params).await { | |
| 302 | - | Ok(result) => result, | |
| 303 | - | Err(e) => format!("Error deleting subtask: {}", e), | |
| 304 | - | } | |
| 305 | - | } | |
| 306 | - | ||
| 307 | - | // --- Project Tools --- | |
| 308 | - | ||
| 309 | - | /// Create a new project in GoingsOn. | |
| 310 | - | #[tool(description = "Create a new project in GoingsOn. Provide a name and optionally description, type (Job/SideProject/Company/Essay/Article/Painting/Other), and status (Active/OnHold/Completed/Archived).")] | |
| 311 | - | pub async fn create_project( | |
| 312 | - | &self, | |
| 313 | - | #[tool(aggr)] params: CreateProjectParams, | |
| 314 | - | ) -> String { | |
| 315 | - | match self.create_project_impl(params).await { | |
| 316 | - | Ok(result) => result, | |
| 317 | - | Err(e) => format!("Error creating project: {}", e), | |
| 318 | - | } | |
| 319 | - | } | |
| 320 | - | ||
| 321 | - | /// List available projects in GoingsOn. | |
| 322 | - | #[tool(description = "List available projects in GoingsOn. Use project names when creating tasks.")] | |
| 323 | - | pub async fn list_projects(&self) -> String { | |
| 324 | - | match self.list_projects_impl().await { | |
| 325 | - | Ok(result) => result, | |
| 326 | - | Err(e) => format!("Error listing projects: {}", e), | |
| 327 | - | } | |
| 328 | - | } | |
| 329 | - | ||
| 330 | - | /// Get a project by ID. | |
| 331 | - | #[tool(description = "Get a project by its ID. Returns project details.")] | |
| 332 | - | pub async fn get_project( | |
| 333 | - | &self, | |
| 334 | - | #[tool(aggr)] params: GetProjectParams, | |
| 335 | - | ) -> String { | |
| 336 | - | match self.get_project_impl(params).await { | |
| 337 | - | Ok(result) => result, | |
| 338 | - | Err(e) => format!("Error getting project: {}", e), | |
| 339 | - | } | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | /// Update an existing project. | |
| 343 | - | #[tool(description = "Update an existing project. Provide project ID and fields to update (name, description, project_type, status).")] | |
| 344 | - | pub async fn update_project( | |
| 345 | - | &self, | |
| 346 | - | #[tool(aggr)] params: UpdateProjectParams, | |
| 347 | - | ) -> String { | |
| 348 | - | match self.update_project_impl(params).await { | |
| 349 | - | Ok(result) => result, | |
| 350 | - | Err(e) => format!("Error updating project: {}", e), | |
| 351 | - | } | |
| 352 | - | } | |
| 353 | - | ||
| 354 | - | /// Delete a project. | |
| 355 | - | #[tool(description = "Delete a project by its ID.")] | |
| 356 | - | pub async fn delete_project( | |
| 357 | - | &self, | |
| 358 | - | #[tool(aggr)] params: DeleteProjectParams, | |
| 359 | - | ) -> String { | |
| 360 | - | match self.delete_project_impl(params).await { | |
| 361 | - | Ok(result) => result, | |
| 362 | - | Err(e) => format!("Error deleting project: {}", e), | |
| 363 | - | } | |
| 364 | - | } | |
| 365 | - | ||
| 366 | - | // --- Event Tools --- | |
| 367 | - | ||
| 368 | - | /// Create a new event. | |
| 369 | - | #[tool(description = "Create a new calendar event. Provide title, start_time (ISO 8601), and optionally end_time, description, location, project name, recurrence, contact name, and block_type (Focus/Personal/Vacation).")] | |
| 370 | - | pub async fn create_event( | |
| 371 | - | &self, | |
| 372 | - | #[tool(aggr)] params: CreateEventParams, | |
| 373 | - | ) -> String { | |
| 374 | - | match self.create_event_impl(params).await { | |
| 375 | - | Ok(result) => result, | |
| 376 | - | Err(e) => format!("Error creating event: {}", e), | |
| 377 | - | } | |
| 378 | - | } | |
| 379 | - | ||
| 380 | - | /// List events. | |
| 381 | - | #[tool(description = "List events, optionally filtered by project name and/or limited to upcoming N days.")] | |
| 382 | - | pub async fn list_events( | |
| 383 | - | &self, | |
| 384 | - | #[tool(aggr)] params: ListEventsParams, | |
| 385 | - | ) -> String { | |
| 386 | - | match self.list_events_impl(params).await { | |
| 387 | - | Ok(result) => result, | |
| 388 | - | Err(e) => format!("Error listing events: {}", e), | |
| 389 | - | } | |
| 390 | - | } | |
| 391 | - | ||
| 392 | - | /// List upcoming events. | |
| 393 | - | #[tool(description = "List events in the next N days (default: 7). Quick way to see what's coming up.")] | |
| 394 | - | pub async fn list_upcoming_events( | |
| 395 | - | &self, | |
| 396 | - | #[tool(aggr)] params: ListUpcomingEventsParams, | |
| 397 | - | ) -> String { | |
| 398 | - | match self.list_upcoming_events_impl(params).await { | |
| 399 | - | Ok(result) => result, | |
| 400 | - | Err(e) => format!("Error listing upcoming events: {}", e), | |
| 401 | - | } | |
| 402 | - | } | |
| 403 | - | ||
| 404 | - | /// Get an event by ID. | |
| 405 | - | #[tool(description = "Get an event by its ID. Returns full event details.")] | |
| 406 | - | pub async fn get_event( | |
| 407 | - | &self, | |
| 408 | - | #[tool(aggr)] params: GetEventParams, | |
| 409 | - | ) -> String { | |
| 410 | - | match self.get_event_impl(params).await { | |
| 411 | - | Ok(result) => result, | |
| 412 | - | Err(e) => format!("Error getting event: {}", e), | |
| 413 | - | } | |
| 414 | - | } | |
| 415 | - | ||
| 416 | - | /// Update an existing event. | |
| 417 | - | #[tool(description = "Update an existing event. Provide event ID and fields to update (title, start_time, end_time, description, location, project, recurrence, contact, block_type).")] | |
| 418 | - | pub async fn update_event( | |
| 419 | - | &self, | |
| 420 | - | #[tool(aggr)] params: UpdateEventParams, | |
| 421 | - | ) -> String { | |
| 422 | - | match self.update_event_impl(params).await { | |
| 423 | - | Ok(result) => result, | |
| 424 | - | Err(e) => format!("Error updating event: {}", e), | |
| 425 | - | } | |
| 426 | - | } | |
| 427 | - | ||
| 428 | - | /// Delete an event. | |
| 429 | - | #[tool(description = "Delete an event by its ID.")] | |
| 430 | - | pub async fn delete_event( | |
| 431 | - | &self, | |
| 432 | - | #[tool(aggr)] params: DeleteEventParams, | |
| 433 | - | ) -> String { | |
| 434 | - | match self.delete_event_impl(params).await { | |
| 435 | - | Ok(result) => result, | |
| 436 | - | Err(e) => format!("Error deleting event: {}", e), | |
| 437 | - | } | |
| 438 | - | } | |
| 439 | - | ||
| 440 | - | // --- Milestone Tools --- | |
| 441 | - | ||
| 442 | - | /// List milestones for a project. | |
| 443 | - | #[tool(description = "List milestones for a project. Returns milestones with progress tracking.")] | |
| 444 | - | pub async fn list_milestones( | |
| 445 | - | &self, | |
| 446 | - | #[tool(aggr)] params: ListMilestonesParams, | |
| 447 | - | ) -> String { | |
| 448 | - | match self.list_milestones_impl(params).await { | |
| 449 | - | Ok(result) => result, | |
| 450 | - | Err(e) => format!("Error listing milestones: {}", e), | |
| 451 | - | } | |
| 452 | - | } | |
| 453 | - | ||
| 454 | - | /// Create a new milestone. | |
| 455 | - | #[tool(description = "Create a new milestone for a project. Provide project_id, name, and optionally description and target_date.")] | |
| 456 | - | pub async fn create_milestone( | |
| 457 | - | &self, | |
| 458 | - | #[tool(aggr)] params: CreateMilestoneParams, | |
| 459 | - | ) -> String { | |
| 460 | - | match self.create_milestone_impl(params).await { | |
| 461 | - | Ok(result) => result, | |
| 462 | - | Err(e) => format!("Error creating milestone: {}", e), | |
| 463 | - | } | |
| 464 | - | } | |
| 465 | - | ||
| 466 | - | /// Update a milestone. | |
| 467 | - | #[tool(description = "Update an existing milestone. Provide milestone ID and fields to update (name, description, target_date, status).")] | |
| 468 | - | pub async fn update_milestone( | |
| 469 | - | &self, | |
| 470 | - | #[tool(aggr)] params: UpdateMilestoneParams, | |
| 471 | - | ) -> String { | |
| 472 | - | match self.update_milestone_impl(params).await { | |
| 473 | - | Ok(result) => result, | |
| 474 | - | Err(e) => format!("Error updating milestone: {}", e), | |
| 475 | - | } | |
| 476 | - | } | |
| 477 | - | ||
| 478 | - | /// Delete a milestone. | |
| 479 | - | #[tool(description = "Delete a milestone by its ID. Tasks assigned to it will have their milestone cleared.")] | |
| 480 | - | pub async fn delete_milestone( | |
| 481 | - | &self, | |
| 482 | - | #[tool(aggr)] params: DeleteMilestoneParams, | |
| 483 | - | ) -> String { | |
| 484 | - | match self.delete_milestone_impl(params).await { | |
| 485 | - | Ok(result) => result, | |
| 486 | - | Err(e) => format!("Error deleting milestone: {}", e), | |
| 487 | - | } | |
| 488 | - | } | |
| 489 | - | ||
| 490 | - | // --- Contact Tools --- | |
| 491 | - | ||
| 492 | - | /// List contacts. | |
| 493 | - | #[tool(description = "List contacts, optionally filtered by tag or search query (matches name, company, notes).")] | |
| 494 | - | pub async fn list_contacts( | |
| 495 | - | &self, | |
| 496 | - | #[tool(aggr)] params: ListContactsParams, | |
| 497 | - | ) -> String { | |
| 498 | - | match self.list_contacts_impl(params).await { | |
| 499 | - | Ok(result) => result, | |
| 500 | - | Err(e) => format!("Error listing contacts: {}", e), |
Lines truncated
| @@ -1,143 +0,0 @@ | |||
| 1 | - | //! Project implementation methods for GoingsOnServer. | |
| 2 | - | ||
| 3 | - | use goingson_core::{NewProject, ParseableEnum, ProjectId, ProjectStatus, ProjectType, UpdateProject}; | |
| 4 | - | use tracing::instrument; | |
| 5 | - | ||
| 6 | - | use crate::state::DESKTOP_USER_ID; | |
| 7 | - | ||
| 8 | - | use super::GoingsOnServer; | |
| 9 | - | use super::results::ProjectResult; | |
| 10 | - | use super::project_params::*; | |
| 11 | - | ||
| 12 | - | impl GoingsOnServer { | |
| 13 | - | #[instrument(skip_all)] | |
| 14 | - | pub(crate) async fn create_project_impl( | |
| 15 | - | &self, | |
| 16 | - | params: CreateProjectParams, | |
| 17 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 18 | - | if params.name.trim().is_empty() { | |
| 19 | - | return Err("Project name is required".into()); | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | let project_type = params | |
| 23 | - | .project_type | |
| 24 | - | .as_deref() | |
| 25 | - | .map(ProjectType::from_str_or_default) | |
| 26 | - | .unwrap_or(ProjectType::Other); | |
| 27 | - | ||
| 28 | - | let status = params | |
| 29 | - | .status | |
| 30 | - | .as_deref() | |
| 31 | - | .map(ProjectStatus::from_str_or_default) | |
| 32 | - | .unwrap_or(ProjectStatus::Active); | |
| 33 | - | ||
| 34 | - | let new_project = NewProject { | |
| 35 | - | name: params.name, | |
| 36 | - | description: params.description.unwrap_or_default(), | |
| 37 | - | project_type, | |
| 38 | - | status, | |
| 39 | - | }; | |
| 40 | - | ||
| 41 | - | let project = self.state.projects.create(DESKTOP_USER_ID, new_project).await?; | |
| 42 | - | ||
| 43 | - | let result = ProjectResult { | |
| 44 | - | id: project.id.to_string(), | |
| 45 | - | name: project.name, | |
| 46 | - | project_type: project.project_type.as_str().to_string(), | |
| 47 | - | status: project.status.as_str().to_string(), | |
| 48 | - | }; | |
| 49 | - | ||
| 50 | - | Ok(format!( | |
| 51 | - | "Project created successfully:\n{}", | |
| 52 | - | serde_json::to_string_pretty(&result)? | |
| 53 | - | )) | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | #[instrument(skip_all)] | |
| 57 | - | pub(crate) async fn list_projects_impl( | |
| 58 | - | &self, | |
| 59 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 60 | - | let projects = self.state.projects.list_all(DESKTOP_USER_ID).await?; | |
| 61 | - | ||
| 62 | - | if projects.is_empty() { | |
| 63 | - | return Ok("No projects found.".to_string()); | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | let results: Vec<ProjectResult> = projects | |
| 67 | - | .into_iter() | |
| 68 | - | .map(|p| ProjectResult { | |
| 69 | - | id: p.id.to_string(), | |
| 70 | - | name: p.name, | |
| 71 | - | project_type: p.project_type.as_str().to_string(), | |
| 72 | - | status: p.status.as_str().to_string(), | |
| 73 | - | }) | |
| 74 | - | .collect(); | |
| 75 | - | ||
| 76 | - | Ok(serde_json::to_string_pretty(&results)?) | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | #[instrument(skip_all)] | |
| 80 | - | pub(crate) async fn get_project_impl( | |
| 81 | - | &self, | |
| 82 | - | params: GetProjectParams, | |
| 83 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 84 | - | let id: ProjectId = params.id.parse::<uuid::Uuid>()?.into(); | |
| 85 | - | let project = self.state.projects.get_by_id(id, DESKTOP_USER_ID).await? | |
| 86 | - | .ok_or_else(|| format!("Project {} not found", id))?; | |
| 87 | - | ||
| 88 | - | let result = ProjectResult { | |
| 89 | - | id: project.id.to_string(), | |
| 90 | - | name: project.name, | |
| 91 | - | project_type: project.project_type.as_str().to_string(), | |
| 92 | - | status: project.status.as_str().to_string(), | |
| 93 | - | }; | |
| 94 | - | Ok(serde_json::to_string_pretty(&result)?) | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | #[instrument(skip_all)] | |
| 98 | - | pub(crate) async fn update_project_impl( | |
| 99 | - | &self, | |
| 100 | - | params: UpdateProjectParams, | |
| 101 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 102 | - | let id: ProjectId = params.id.parse::<uuid::Uuid>()?.into(); | |
| 103 | - | let existing = self.state.projects.get_by_id(id, DESKTOP_USER_ID).await? | |
| 104 | - | .ok_or_else(|| format!("Project {} not found", id))?; | |
| 105 | - | ||
| 106 | - | let name = params.name.unwrap_or(existing.name); | |
| 107 | - | let description = params.description.unwrap_or(existing.description); | |
| 108 | - | let project_type = params.project_type | |
| 109 | - | .as_deref() | |
| 110 | - | .map(ProjectType::from_str_or_default) | |
| 111 | - | .unwrap_or(existing.project_type); | |
| 112 | - | let status = params.status | |
| 113 | - | .as_deref() | |
| 114 | - | .map(ProjectStatus::from_str_or_default) | |
| 115 | - | .unwrap_or(existing.status); | |
| 116 | - | ||
| 117 | - | let update = UpdateProject { name, description, project_type, status }; | |
| 118 | - | let project = self.state.projects.update(id, DESKTOP_USER_ID, update).await? | |
| 119 | - | .ok_or_else(|| format!("Project {} not found", id))?; | |
| 120 | - | ||
| 121 | - | let result = ProjectResult { | |
| 122 | - | id: project.id.to_string(), | |
| 123 | - | name: project.name, | |
| 124 | - | project_type: project.project_type.as_str().to_string(), | |
| 125 | - | status: project.status.as_str().to_string(), | |
| 126 | - | }; | |
| 127 | - | Ok(format!("Project updated:\n{}", serde_json::to_string_pretty(&result)?)) | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | #[instrument(skip_all)] | |
| 131 | - | pub(crate) async fn delete_project_impl( | |
| 132 | - | &self, | |
| 133 | - | params: DeleteProjectParams, | |
| 134 | - | ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { | |
| 135 | - | let id: ProjectId = params.id.parse::<uuid::Uuid>()?.into(); | |
| 136 | - | let deleted = self.state.projects.delete(id, DESKTOP_USER_ID).await?; | |
| 137 | - | if deleted { | |
| 138 | - | Ok(format!("Project {} deleted.", id)) | |
| 139 | - | } else { | |
| 140 | - | Ok(format!("Project {} not found.", id)) | |
| 141 | - | } | |
| 142 | - | } | |
| 143 | - | } |