diff --git a/src/main.rs b/src/main.rs index c0422b9..bf4d342 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use crate::app::App; mod app; mod components; mod models; +mod task; mod theme; mod view; diff --git a/src/task/error.rs b/src/task/error.rs new file mode 100644 index 0000000..1d8201d --- /dev/null +++ b/src/task/error.rs @@ -0,0 +1,35 @@ +use std::fmt; + +pub enum TaskError { + Storage(String), + NotFound(uuid::Uuid), + InvalidTag(String), + InvalidProject(String), + InvalidPriority(String), + InvalidDue(String), + InvalidWait(String), + InvalidAnnotation(String), + InvalidDependency(String), +} + +impl fmt::Display for TaskError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + TaskError::Storage(msg) => write!(f, "Storage error: {}", msg), + TaskError::NotFound(id) => write!(f, "Task not found: {}", id), + TaskError::InvalidTag(tag) => write!(f, "Invalid tag: {}", tag), + TaskError::InvalidProject(project) => write!(f, "Invalid project: {}", project), + TaskError::InvalidPriority(priority) => write!(f, "Invalid priority: {}", priority), + TaskError::InvalidDue(due) => write!(f, "Invalid due date: {}", due), + TaskError::InvalidWait(wait) => write!(f, "Invalid wait date: {}", wait), + TaskError::InvalidAnnotation(annotation) => { + write!(f, "Invalid annotation: {}", annotation) + } + TaskError::InvalidDependency(dependency) => { + write!(f, "Invalid dependency: {}", dependency) + } + } + } +} + +pub type TaskResult = Result; diff --git a/src/task/filter.rs b/src/task/filter.rs new file mode 100644 index 0000000..6926d5b --- /dev/null +++ b/src/task/filter.rs @@ -0,0 +1,266 @@ +use std::collections::HashSet; + +use chrono::{DateTime, Utc}; + +use super::model::{Task, TaskPriority, TaskStatus}; +use crate::models::{DueFilter, FilterState, PriorityFilter, StatusFilter}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TagsFilterMode { + #[default] + And, + Or, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DueDateFilter { + Overdue, + Today, + ThisWeek, + NoDate, + Before(DateTime), + After(DateTime), +} + +#[derive(Debug, Clone, Default)] +pub struct TaskFilter { + pub status: Option, + pub project: Option, + pub project_include_children: bool, + pub tags: HashSet, + pub tags_mode: TagsFilterMode, + pub priority: Option, + pub due_filter: Option, + pub search_text: Option, + pub is_active: Option, + pub is_blocked: Option, +} + +impl TaskFilter { + pub fn new() -> Self { + Self::default() + } + + pub fn with_status(mut self, status: TaskStatus) -> Self { + self.status = Some(status); + self + } + + pub fn with_project(mut self, project: String, include_children: bool) -> Self { + self.project = Some(project); + self.project_include_children = include_children; + self + } + + pub fn with_tags(mut self, tags: HashSet, mode: TagsFilterMode) -> Self { + self.tags = tags; + self.tags_mode = mode; + self + } + + pub fn with_priority(mut self, priority: TaskPriority) -> Self { + self.priority = Some(priority); + self + } + + pub fn with_due(mut self, filter: DueDateFilter) -> Self { + self.due_filter = Some(filter); + self + } + + pub fn with_search(mut self, text: String) -> Self { + if !text.is_empty() { + self.search_text = Some(text.to_lowercase()); + } + self + } +} + +impl From<&FilterState> for TaskFilter { + fn from(state: &FilterState) -> Self { + let mut filter = Self::new(); + + filter.status = match state.status_filter { + StatusFilter::All => None, + StatusFilter::Pending => Some(TaskStatus::Pending), + StatusFilter::Completed => Some(TaskStatus::Completed), + StatusFilter::Waiting => Some(TaskStatus::Pending), + StatusFilter::Deleted => Some(TaskStatus::Deleted), + }; + + if let Some(ref project) = state.selected_project { + filter.project = Some(project.clone()); + filter.project_include_children = true; + } + + if !state.active_tags.is_empty() { + filter.tags = state.active_tags.clone(); + filter.tags_mode = TagsFilterMode::And; + } + + filter.priority = match state.priority_filter { + PriorityFilter::All => None, + PriorityFilter::High => Some(TaskPriority::High), + PriorityFilter::Medium => Some(TaskPriority::Medium), + PriorityFilter::Low => Some(TaskPriority::Low), + PriorityFilter::None => Some(TaskPriority::None), + }; + + filter.due_filter = match state.due_filter { + DueFilter::All => None, + DueFilter::Overdue => Some(DueDateFilter::Overdue), + DueFilter::Today => Some(DueDateFilter::Today), + DueFilter::ThisWeek => Some(DueDateFilter::ThisWeek), + DueFilter::NoDate => Some(DueDateFilter::NoDate), + }; + + if !state.search_text.is_empty() { + filter.search_text = Some(state.search_text.to_lowercase()); + } + + filter + } +} + +impl From for TaskFilter { + fn from(state: FilterState) -> Self { + Self::from(&state) + } +} + +impl TaskFilter { + pub fn matches(&self, task: &Task) -> bool { + if let Some(status) = &self.status { + match status { + TaskStatus::Pending => { + if !matches!(task.status, TaskStatus::Pending) { + return false; + } + if task.wait.map(|w| w > Utc::now()).unwrap_or(false) { + return false; + } + } + _ => { + if &task.status != status { + return false; + } + } + } + } + + if let Some(project) = &self.project { + match &task.project { + None => return false, + Some(task_project) => { + if self.project_include_children { + if !task_project.starts_with(project) { + return false; + } + } else if task_project != project { + return false; + } + } + } + } + + if !self.tags.is_empty() { + match self.tags_mode { + TagsFilterMode::And => { + for tag in &self.tags { + if !task.tags.contains(tag) { + return false; + } + } + } + TagsFilterMode::Or => { + let has_any = self.tags.iter().any(|t| task.tags.contains(t)); + if !has_any { + return false; + } + } + } + } + + if let Some(priority) = &self.priority { + if &task.priority != priority { + return false; + } + } + + if let Some(due_filter) = &self.due_filter { + match due_filter { + DueDateFilter::Overdue => { + if !task.is_overdue() { + return false; + } + } + DueDateFilter::Today => { + if !task.is_due_today() { + return false; + } + } + DueDateFilter::ThisWeek => { + let is_due_this_week = task + .due + .map(|d| { + let now = Utc::now(); + let week_end = now + chrono::Duration::days(7); + d >= now && d <= week_end + }) + .unwrap_or(false); + + if !is_due_this_week { + return false; + } + } + DueDateFilter::NoDate => { + if task.due.is_some() { + return false; + } + } + DueDateFilter::Before(dt) => { + if task.due.map(|d| d >= *dt).unwrap_or(true) { + return false; + } + } + DueDateFilter::After(dt) => { + if task.due.map(|d| d <= *dt).unwrap_or(true) { + return false; + } + } + } + } + + if let Some(search) = &self.search_text { + let desc_match = task.description.to_lowercase().contains(search); + let proj_match = task + .project + .as_ref() + .map(|p| p.to_lowercase().contains(search)) + .unwrap_or(false); + let tag_match = task.tags.iter().any(|t| t.to_lowercase().contains(search)); + + if !desc_match && !proj_match && !tag_match { + return false; + } + } + + if let Some(is_active) = self.is_active { + if task.is_active != is_active { + return false; + } + } + + if let Some(is_blocked) = self.is_blocked { + if task.is_blocked != is_blocked { + return false; + } + } + + true + } + + pub fn apply(&self, tasks: &[Task]) -> Vec { + tasks.iter().filter(|t| self.matches(t)).cloned().collect() + } +} diff --git a/src/task/mod.rs b/src/task/mod.rs new file mode 100644 index 0000000..a6f1414 --- /dev/null +++ b/src/task/mod.rs @@ -0,0 +1,9 @@ +pub mod error; +pub mod filter; +pub mod model; +pub mod service; + +pub use error::{TaskError, TaskResult}; +pub use filter::{DueDateFilter, TagsFilterMode, TaskFilter}; +pub use model::{Task, TaskAnnotation, TaskPriority, TaskStatus, TaskUpdate}; +pub use service::{SyncResult, TaskService}; diff --git a/src/task/model.rs b/src/task/model.rs new file mode 100644 index 0000000..9bcbc04 --- /dev/null +++ b/src/task/model.rs @@ -0,0 +1,216 @@ +use std::collections::HashSet; + +use chrono::{DateTime, Utc}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskPriority { + High, + Medium, + Low, + None, +} + +impl Default for TaskPriority { + fn default() -> Self { + TaskPriority::None + } +} + +impl std::fmt::Display for TaskPriority { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TaskPriority::High => write!(f, "High"), + TaskPriority::Medium => write!(f, "Medium"), + TaskPriority::Low => write!(f, "Low"), + TaskPriority::None => write!(f, "None"), + } + } +} + +impl From<&str> for TaskPriority { + fn from(s: &str) -> Self { + match s { + "high" | "High" | "H" | "h" => TaskPriority::High, + "medium" | "Medium" | "M" | "m" => TaskPriority::Medium, + "low" | "Low" | "L" | "l" => TaskPriority::Low, + _ => TaskPriority::None, + } + } +} + +impl From for TaskPriority { + fn from(s: String) -> Self { + Self::from(s.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TaskStatus { + Pending, + Completed, + Deleted, + Unknown(String), + Recurring, +} + +impl Default for TaskStatus { + fn default() -> Self { + TaskStatus::Pending + } +} + +impl From<&str> for TaskStatus { + fn from(s: &str) -> Self { + match s { + "pending" | "Pending" | "P" | "p" => TaskStatus::Pending, + "completed" | "Completed" | "C" | "c" => TaskStatus::Completed, + "deleted" | "Deleted" | "D" | "d" => TaskStatus::Deleted, + "recurring" | "Recurring" | "R" | "r" => TaskStatus::Recurring, + _ => TaskStatus::Unknown(String::new()), + } + } +} + +impl From for TaskStatus { + fn from(s: String) -> Self { + Self::from(s.as_str()) + } +} + +impl From for TaskStatus { + fn from(status: taskchampion::Status) -> Self { + match status { + taskchampion::Status::Pending => TaskStatus::Pending, + taskchampion::Status::Completed => TaskStatus::Completed, + taskchampion::Status::Deleted => TaskStatus::Deleted, + taskchampion::Status::Unknown(reason) => TaskStatus::Unknown(reason), + taskchampion::Status::Recurring => TaskStatus::Recurring, + } + } +} + +impl std::fmt::Display for TaskStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TaskStatus::Pending => write!(f, "Pending"), + TaskStatus::Completed => write!(f, "Completed"), + TaskStatus::Deleted => write!(f, "Deleted"), + TaskStatus::Unknown(reason) => write!(f, "Unknown ({})", reason), + TaskStatus::Recurring => write!(f, "Recurring"), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct TaskAnnotation { + pub entry: DateTime, + pub content: String, +} + +impl From for TaskAnnotation { + fn from(annotation: taskchampion::Annotation) -> Self { + TaskAnnotation { + entry: annotation.entry, + content: annotation.description, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct Task { + pub uuid: uuid::Uuid, + pub status: TaskStatus, + pub description: String, + pub project: Option, + pub priority: TaskPriority, + pub tags: HashSet, + pub due: Option>, + pub wait: Option>, + pub entry: Option>, + pub modified: Option>, + pub annotations: Vec, + pub dependencies: HashSet, + pub is_active: bool, + pub is_blocked: bool, + pub working_id: Option, +} + +impl Task { + pub fn new( + uuid: uuid::Uuid, + status: TaskStatus, + description: String, + project: Option, + priority: TaskPriority, + tags: HashSet, + due: Option>, + wait: Option>, + entry: Option>, + modified: Option>, + annotations: Vec, + dependencies: HashSet, + is_active: bool, + is_blocked: bool, + working_id: Option, + ) -> Self { + Self { + uuid, + status, + description, + project, + priority, + tags, + due, + wait, + entry, + modified, + annotations, + dependencies, + is_active, + is_blocked, + working_id, + } + } + + pub fn is_overdue(&self) -> bool { + self.due.map_or(false, |due| due < Utc::now()) + } + + pub fn is_due_today(&self) -> bool { + self.due + .map_or(false, |due| due.date_naive() == Utc::now().date_naive()) + } +} + +impl From for Task { + fn from(task: taskchampion::Task) -> Self { + Self { + uuid: task.get_uuid(), + status: task.get_status().into(), + description: task.get_description().to_string(), + project: task.get_value("project").map(|v| v.to_string()), + priority: task.get_priority().into(), + tags: task.get_tags().map(|t| t.to_string()).collect(), + due: task.get_due().map(Into::into), + wait: task.get_wait().map(Into::into), + entry: task.get_entry().map(Into::into), + modified: task.get_modified().map(Into::into), + annotations: task.get_annotations().map(Into::into).collect(), + dependencies: task.get_dependencies().map(Into::into).collect(), + is_active: task.is_active(), + is_blocked: task.is_blocked(), + working_id: None, + } + } +} + +pub struct TaskUpdate { + pub description: Option, + pub project: Option, + pub priority: Option, + pub tags: Option>, + pub due: Option>, + pub wait: Option>, + pub annotations: Option>, + pub dependencies: Option>, +} diff --git a/src/task/service.rs b/src/task/service.rs new file mode 100644 index 0000000..8e494ae --- /dev/null +++ b/src/task/service.rs @@ -0,0 +1,570 @@ +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use taskchampion::{ + Operations, Replica, ServerConfig, Status, StorageConfig, Tag, storage::AccessMode, +}; +use uuid::Uuid; + +use super::error::{TaskError, TaskResult}; +use super::filter::TaskFilter; +use super::model::{Task, TaskPriority, TaskStatus}; + +pub struct TaskService { + replica: Replica, + taskdb_dir: PathBuf, +} + +impl TaskService { + pub fn new() -> TaskResult { + let taskdb_dir = dirs::home_dir() + .ok_or_else(|| TaskError::Storage("Cannot find home directory".into()))? + .join(".task"); + + Self::with_path(taskdb_dir) + } + + pub fn with_path(taskdb_dir: PathBuf) -> TaskResult { + let storage = StorageConfig::OnDisk { + taskdb_dir: taskdb_dir.clone(), + create_if_missing: true, + access_mode: AccessMode::ReadWrite, + }; + + let replica = Replica::new( + storage + .into_storage() + .map_err(|e| TaskError::Storage(e.to_string()))?, + ); + + Ok(Self { + replica, + taskdb_dir, + }) + } + + pub fn create_task(&mut self, description: String) -> TaskResult { + let uuid = Uuid::new_v4(); + let mut ops = Operations::new(); + + let mut tc_task = self + .replica + .create_task(uuid, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + tc_task + .set_description(description, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.replica + .commit_operations(ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + let working_set = self + .replica + .working_set() + .map_err(|e| TaskError::Storage(e.to_string()))?; + let mut task: Task = tc_task.into(); + task.working_id = working_set.by_uuid(uuid); + + Ok(task) + } + + pub fn get_task(&mut self, uuid: Uuid) -> TaskResult> { + let tc_task = self + .replica + .get_task(uuid) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + match tc_task { + None => Ok(None), + Some(task) => { + let working_set = self + .replica + .working_set() + .map_err(|e| TaskError::Storage(e.to_string()))?; + let mut task: Task = task.into(); + task.working_id = working_set.by_uuid(uuid); + Ok(Some(task)) + } + } + } + + pub fn get_all_tasks(&mut self) -> TaskResult> { + let all = self + .replica + .all_tasks() + .map_err(|e| TaskError::Storage(e.to_string()))?; + let working_set = self + .replica + .working_set() + .map_err(|e| TaskError::Storage(e.to_string()))?; + + let tasks: Vec = all + .iter() + .map(|(uuid, tc_task)| { + let mut task: Task = tc_task.clone().into(); + task.working_id = working_set.by_uuid(*uuid); + task + }) + .collect(); + + Ok(tasks) + } + + pub fn get_filtered_tasks(&mut self, filter: &TaskFilter) -> TaskResult> { + let all = self.get_all_tasks()?; + Ok(filter.apply(&all)) + } + + pub fn update_task( + &mut self, + uuid: Uuid, + description: Option, + project: Option>, + priority: Option, + tags: Option>, + due: Option>>, + wait: Option>>, + ) -> TaskResult { + let mut ops = Operations::new(); + + let mut tc_task = self + .replica + .get_task(uuid) + .map_err(|e| TaskError::Storage(e.to_string()))? + .ok_or(TaskError::NotFound(uuid))?; + + if let Some(desc) = description { + tc_task + .set_description(desc, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + } + + if let Some(proj) = project { + tc_task + .set_value("project", proj, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + } + + if let Some(pri) = priority { + tc_task + .set_priority(pri, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + } + + if let Some(d) = due { + tc_task + .set_due(d, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + } + + if let Some(w) = wait { + tc_task + .set_wait(w, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + } + + if let Some(new_tags) = tags { + let current_tags: HashSet = tc_task.get_tags().map(|t| t.to_string()).collect(); + + for tag_str in current_tags.difference(&new_tags) { + if let Ok(tag) = Tag::try_from(tag_str.as_str()) { + tc_task + .remove_tag(&tag, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + } + } + + for tag_str in new_tags.difference(¤t_tags) { + if let Ok(tag) = Tag::try_from(tag_str.as_str()) { + tc_task + .add_tag(&tag, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + } + } + } + + self.replica + .commit_operations(ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.get_task(uuid)?.ok_or(TaskError::NotFound(uuid)) + } + + pub fn complete_task(&mut self, uuid: Uuid) -> TaskResult { + let mut ops = Operations::new(); + + let mut tc_task = self + .replica + .get_task(uuid) + .map_err(|e| TaskError::Storage(e.to_string()))? + .ok_or(TaskError::NotFound(uuid))?; + + tc_task + .done(&mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.replica + .commit_operations(ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.get_task(uuid)?.ok_or(TaskError::NotFound(uuid)) + } + + pub fn reopen_task(&mut self, uuid: Uuid) -> TaskResult { + let mut ops = Operations::new(); + + let mut tc_task = self + .replica + .get_task(uuid) + .map_err(|e| TaskError::Storage(e.to_string()))? + .ok_or(TaskError::NotFound(uuid))?; + + tc_task + .set_status(Status::Pending, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.replica + .commit_operations(ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.get_task(uuid)?.ok_or(TaskError::NotFound(uuid)) + } + + pub fn delete_task(&mut self, uuid: Uuid) -> TaskResult<()> { + let mut ops = Operations::new(); + + let mut tc_task = self + .replica + .get_task(uuid) + .map_err(|e| TaskError::Storage(e.to_string()))? + .ok_or(TaskError::NotFound(uuid))?; + + tc_task + .set_status(Status::Deleted, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.replica + .commit_operations(ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + Ok(()) + } + + pub fn start_task(&mut self, uuid: Uuid) -> TaskResult { + let mut ops = Operations::new(); + + let mut tc_task = self + .replica + .get_task(uuid) + .map_err(|e| TaskError::Storage(e.to_string()))? + .ok_or(TaskError::NotFound(uuid))?; + + tc_task + .start(&mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.replica + .commit_operations(ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.get_task(uuid)?.ok_or(TaskError::NotFound(uuid)) + } + + pub fn stop_task(&mut self, uuid: Uuid) -> TaskResult { + let mut ops = Operations::new(); + + let mut tc_task = self + .replica + .get_task(uuid) + .map_err(|e| TaskError::Storage(e.to_string()))? + .ok_or(TaskError::NotFound(uuid))?; + + tc_task + .stop(&mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.replica + .commit_operations(ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.get_task(uuid)?.ok_or(TaskError::NotFound(uuid)) + } + + pub fn add_tag(&mut self, uuid: Uuid, tag_str: &str) -> TaskResult { + let mut ops = Operations::new(); + + let mut tc_task = self + .replica + .get_task(uuid) + .map_err(|e| TaskError::Storage(e.to_string()))? + .ok_or(TaskError::NotFound(uuid))?; + + let tag = Tag::try_from(tag_str).map_err(|_| TaskError::InvalidTag(tag_str.to_string()))?; + + tc_task + .add_tag(&tag, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.replica + .commit_operations(ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.get_task(uuid)?.ok_or(TaskError::NotFound(uuid)) + } + + pub fn remove_tag(&mut self, uuid: Uuid, tag_str: &str) -> TaskResult { + let mut ops = Operations::new(); + + let mut tc_task = self + .replica + .get_task(uuid) + .map_err(|e| TaskError::Storage(e.to_string()))? + .ok_or(TaskError::NotFound(uuid))?; + + let tag = Tag::try_from(tag_str).map_err(|_| TaskError::InvalidTag(tag_str.to_string()))?; + + tc_task + .remove_tag(&tag, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.replica + .commit_operations(ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.get_task(uuid)?.ok_or(TaskError::NotFound(uuid)) + } + + pub fn list_tags(&mut self) -> TaskResult> { + let all_tasks = self.get_all_tasks()?; + let mut tag_counts: HashMap = HashMap::new(); + + for task in all_tasks { + if matches!(task.status, TaskStatus::Pending) { + for tag in task.tags { + *tag_counts.entry(tag).or_insert(0) += 1; + } + } + } + + let mut stats: Vec<(String, usize)> = tag_counts.into_iter().collect(); + + stats.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); + + Ok(stats) + } + + pub fn list_projects(&mut self) -> TaskResult> { + let all_tasks = self.get_all_tasks()?; + let mut project_counts: HashMap = HashMap::new(); // (total, pending) + + for task in all_tasks { + if let Some(project) = &task.project { + let entry = project_counts.entry(project.clone()).or_insert((0, 0)); + entry.0 += 1; + if matches!(task.status, TaskStatus::Pending) { + entry.1 += 1; + } + } + } + + let stats: Vec<(String, usize, usize)> = project_counts + .into_iter() + .map(|(name, (task_count, pending_count))| (name, task_count, pending_count)) + .collect(); + + Ok(stats) + } + + pub fn get_projects_for_tree(&mut self) -> TaskResult> { + let stats = self.list_projects()?; + Ok(stats + .into_iter() + .map(|(name, _total, pending)| (name, pending)) + .collect()) + } + + pub fn add_annotation(&mut self, uuid: Uuid, description: String) -> TaskResult { + let mut ops = Operations::new(); + + let mut tc_task = self + .replica + .get_task(uuid) + .map_err(|e| TaskError::Storage(e.to_string()))? + .ok_or(TaskError::NotFound(uuid))?; + + let annotation = taskchampion::Annotation { + entry: Utc::now(), + description, + }; + + tc_task + .add_annotation(annotation, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.replica + .commit_operations(ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.get_task(uuid)?.ok_or(TaskError::NotFound(uuid)) + } + + pub fn remove_annotation(&mut self, uuid: Uuid, entry: DateTime) -> TaskResult { + let mut ops = Operations::new(); + + let mut tc_task = self + .replica + .get_task(uuid) + .map_err(|e| TaskError::Storage(e.to_string()))? + .ok_or(TaskError::NotFound(uuid))?; + + tc_task + .remove_annotation(entry, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.replica + .commit_operations(ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.get_task(uuid)?.ok_or(TaskError::NotFound(uuid)) + } + + pub fn add_dependency(&mut self, uuid: Uuid, depends_on: Uuid) -> TaskResult { + let mut ops = Operations::new(); + + let mut tc_task = self + .replica + .get_task(uuid) + .map_err(|e| TaskError::Storage(e.to_string()))? + .ok_or(TaskError::NotFound(uuid))?; + + tc_task + .add_dependency(depends_on, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.replica + .commit_operations(ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.get_task(uuid)?.ok_or(TaskError::NotFound(uuid)) + } + + pub fn remove_dependency(&mut self, uuid: Uuid, depends_on: Uuid) -> TaskResult { + let mut ops = Operations::new(); + + let mut tc_task = self + .replica + .get_task(uuid) + .map_err(|e| TaskError::Storage(e.to_string()))? + .ok_or(TaskError::NotFound(uuid))?; + + tc_task + .remove_dependency(depends_on, &mut ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.replica + .commit_operations(ops) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.get_task(uuid)?.ok_or(TaskError::NotFound(uuid)) + } + + pub fn sync(&mut self) -> TaskResult { + let server_dir = self.taskdb_dir.join("server"); + + if !server_dir.exists() { + return Ok(SyncResult { + success: false, + message: "Server not configured".to_string(), + local_ops_before: 0, + local_ops_after: 0, + }); + } + + let local_ops_before = self + .replica + .num_local_operations() + .map_err(|e| TaskError::Storage(e.to_string()))?; + + let server_config = ServerConfig::Local { server_dir }; + + let mut server = server_config + .into_server() + .map_err(|e| TaskError::Storage(e.to_string()))?; + + self.replica + .sync(&mut server, false) + .map_err(|e| TaskError::Storage(e.to_string()))?; + + let local_ops_after = self + .replica + .num_local_operations() + .map_err(|e| TaskError::Storage(e.to_string()))?; + + Ok(SyncResult { + success: true, + message: "Sync completed".to_string(), + local_ops_before, + local_ops_after, + }) + } + + pub fn pending_sync_operations(&mut self) -> TaskResult { + Ok(self + .replica + .num_local_operations() + .map_err(|e| TaskError::Storage(e.to_string()))?) + } + + pub fn rebuild_working_set(&mut self, renumber: bool) -> TaskResult<()> { + self.replica + .rebuild_working_set(renumber) + .map_err(|e| TaskError::Storage(e.to_string()))?; + Ok(()) + } + + pub fn expire_tasks(&mut self) -> TaskResult<()> { + self.replica + .expire_tasks() + .map_err(|e| TaskError::Storage(e.to_string()))?; + Ok(()) + } + + pub fn working_set(&mut self) -> TaskResult> { + let ws = self + .replica + .working_set() + .map_err(|e| TaskError::Storage(e.to_string()))?; + let mut result = Vec::new(); + + for (idx, uuid) in ws.iter() { + if let Some(task) = self.get_task(uuid)? { + result.push((idx, task)); + } + } + + Ok(result) + } + + pub fn get_task_by_working_id(&mut self, id: usize) -> TaskResult> { + let ws = self + .replica + .working_set() + .map_err(|e| TaskError::Storage(e.to_string()))?; + + match ws.by_index(id) { + None => Ok(None), + Some(uuid) => self.get_task(uuid), + } + } +} + +#[derive(Debug, Clone)] +pub struct SyncResult { + pub success: bool, + pub message: String, + pub local_ops_before: usize, + pub local_ops_after: usize, +}