From 27363c8424f48308ae248af7588fbd3a9285e10d Mon Sep 17 00:00:00 2001 From: Tristan Michael Date: Tue, 31 Mar 2026 09:47:02 -0700 Subject: [PATCH] rename onyx-cli crate (formerly bevy-tasks-cli) --- crates/onyx-cli/Cargo.toml | 22 ++ crates/onyx-cli/src/commands/group.rs | 39 +++ crates/onyx-cli/src/commands/init.rs | 43 ++++ crates/onyx-cli/src/commands/list.rs | 91 +++++++ crates/onyx-cli/src/commands/mod.rs | 43 ++++ crates/onyx-cli/src/commands/sync.rs | 229 ++++++++++++++++++ crates/onyx-cli/src/commands/task.rs | 222 +++++++++++++++++ crates/onyx-cli/src/commands/workspace.rs | 209 ++++++++++++++++ crates/onyx-cli/src/main.rs | 277 ++++++++++++++++++++++ crates/onyx-cli/src/output.rs | 33 +++ 10 files changed, 1208 insertions(+) create mode 100644 crates/onyx-cli/Cargo.toml create mode 100644 crates/onyx-cli/src/commands/group.rs create mode 100644 crates/onyx-cli/src/commands/init.rs create mode 100644 crates/onyx-cli/src/commands/list.rs create mode 100644 crates/onyx-cli/src/commands/mod.rs create mode 100644 crates/onyx-cli/src/commands/sync.rs create mode 100644 crates/onyx-cli/src/commands/task.rs create mode 100644 crates/onyx-cli/src/commands/workspace.rs create mode 100644 crates/onyx-cli/src/main.rs create mode 100644 crates/onyx-cli/src/output.rs diff --git a/crates/onyx-cli/Cargo.toml b/crates/onyx-cli/Cargo.toml new file mode 100644 index 0000000..620b42b --- /dev/null +++ b/crates/onyx-cli/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "onyx-cli" +version = "0.1.0" +edition = "2021" +description = "CLI frontend for Onyx, a local-first task management app" +license = "GPL-3.0-or-later" +repository = "https://github.com/SteelDynamite/onyx" + +[[bin]] +name = "onyx" +path = "src/main.rs" + +[dependencies] +onyx-core = { path = "../onyx-core" } +clap = { version = "4.5", features = ["derive", "env"] } +colored = "2.0" +anyhow = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +fs_extra = "1.3" +tokio = { workspace = true } +rpassword = "5.0" diff --git a/crates/onyx-cli/src/commands/group.rs b/crates/onyx-cli/src/commands/group.rs new file mode 100644 index 0000000..23cf69f --- /dev/null +++ b/crates/onyx-cli/src/commands/group.rs @@ -0,0 +1,39 @@ +use anyhow::{Context, Result}; +use crate::output; +use crate::commands::get_repository; + +pub fn enable(list_name: String, workspace: Option) -> Result<()> { + let (mut repo, _workspace_name) = get_repository(workspace)?; + + let lists = repo.get_lists() + .context("Failed to get lists")?; + + let list = lists.iter() + .find(|l| l.title == list_name) + .ok_or_else(|| anyhow::anyhow!("List '{}' not found", list_name))?; + + repo.set_group_by_due_date(list.id, true) + .context("Failed to enable grouping")?; + + output::success(&format!("Enabled group-by-due-date for list \"{}\"", list_name)); + + Ok(()) +} + +pub fn disable(list_name: String, workspace: Option) -> Result<()> { + let (mut repo, _workspace_name) = get_repository(workspace)?; + + let lists = repo.get_lists() + .context("Failed to get lists")?; + + let list = lists.iter() + .find(|l| l.title == list_name) + .ok_or_else(|| anyhow::anyhow!("List '{}' not found", list_name))?; + + repo.set_group_by_due_date(list.id, false) + .context("Failed to disable grouping")?; + + output::success(&format!("Disabled group-by-due-date for list \"{}\"", list_name)); + + Ok(()) +} diff --git a/crates/onyx-cli/src/commands/init.rs b/crates/onyx-cli/src/commands/init.rs new file mode 100644 index 0000000..807258d --- /dev/null +++ b/crates/onyx-cli/src/commands/init.rs @@ -0,0 +1,43 @@ +use anyhow::{Context, Result}; +use onyx_core::{AppConfig, TaskRepository, WorkspaceConfig}; +use std::path::PathBuf; +use crate::output; + +pub fn execute(path: String, name: String) -> Result<()> { + let path_buf = PathBuf::from(path); + let path_buf = if path_buf.is_relative() { + std::env::current_dir()?.join(path_buf) + } else { + path_buf + }; + + // Initialize the repository + let mut repo = TaskRepository::init(path_buf.clone()) + .context("Failed to initialize tasks folder")?; + + // Create default list if it doesn't exist + let lists = repo.get_lists().context("Failed to get lists")?; + if !lists.iter().any(|l| l.title == "My Tasks") { + repo.create_list("My Tasks".to_string()) + .context("Failed to create default list")?; + } + + // Load or create config + let config_path = AppConfig::get_config_path(); + let mut config = AppConfig::load_from_file(&config_path) + .unwrap_or_else(|_| AppConfig::new()); + + // Add workspace + config.add_workspace(name.clone(), WorkspaceConfig::new(path_buf.clone())); + config.set_current_workspace(name.clone())?; + + // Save config + config.save_to_file(&config_path) + .context("Failed to save config")?; + + output::success(&format!("Initialized workspace \"{}\" at {}", name, path_buf.display())); + output::success("Created default list \"My Tasks\""); + output::success(&format!("Set \"{}\" as current workspace", name)); + + Ok(()) +} diff --git a/crates/onyx-cli/src/commands/list.rs b/crates/onyx-cli/src/commands/list.rs new file mode 100644 index 0000000..ee56160 --- /dev/null +++ b/crates/onyx-cli/src/commands/list.rs @@ -0,0 +1,91 @@ +use anyhow::{Context, Result}; +use colored::*; +use onyx_core::{Task, TaskStatus}; +use crate::output; +use crate::commands::get_repository; + +fn print_tasks(tasks: &[Task]) { + if tasks.is_empty() { + output::item("No tasks"); + return; + } + for task in tasks { + let checkbox = if task.status == TaskStatus::Completed { "[✓]".green() } else { "[ ]".normal() }; + let due_str = task.due_date.map(|d| format!(" (due: {})", d.format("%Y-%m-%d")).yellow().to_string()).unwrap_or_default(); + output::item(&format!("{} {}{} {}", checkbox, task.title, due_str, task.id.to_string().dimmed())); + } +} + +pub fn create(name: String, workspace: Option) -> Result<()> { + let (mut repo, _workspace_name) = get_repository(workspace)?; + + repo.create_list(name.clone()) + .context("Failed to create list")?; + + output::success(&format!("Created list \"{}\"", name)); + + Ok(()) +} + +pub fn show(list_name: Option, workspace: Option) -> Result<()> { + let (repo, _workspace_name) = get_repository(workspace)?; + + let lists = repo.get_lists() + .context("Failed to get lists")?; + + if lists.is_empty() { + output::info("No lists found. Create one with 'onyx list create '"); + return Ok(()); + } + + // If a specific list is requested, show only that one + if let Some(name) = list_name { + let list = lists.iter() + .find(|l| l.title == name) + .ok_or_else(|| anyhow::anyhow!("List '{}' not found", name))?; + + output::header(&format!("{} ({})", list.title, format!("{} tasks", list.tasks.len()).dimmed())); + print_tasks(&list.tasks); + } else { + // Show all lists + for list in &lists { + output::header(&format!("{} ({})", list.title, format!("{} tasks", list.tasks.len()).dimmed())); + print_tasks(&list.tasks); + output::blank(); + } + } + + Ok(()) +} + +pub fn delete(name: String, workspace: Option) -> Result<()> { + let (mut repo, _workspace_name) = get_repository(workspace)?; + + let lists = repo.get_lists() + .context("Failed to get lists")?; + + let list = lists.iter() + .find(|l| l.title == name) + .ok_or_else(|| anyhow::anyhow!("List '{}' not found", name))?; + + // Confirm + output::warning(&format!("This will delete list \"{}\" and all its tasks", name)); + print!("Continue? (y/n): "); + use std::io::{self, Write}; + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if input.trim().to_lowercase() != "y" { + output::info("Cancelled"); + return Ok(()); + } + + repo.delete_list(list.id) + .context("Failed to delete list")?; + + output::success(&format!("Deleted list \"{}\"", name)); + + Ok(()) +} diff --git a/crates/onyx-cli/src/commands/mod.rs b/crates/onyx-cli/src/commands/mod.rs new file mode 100644 index 0000000..c681294 --- /dev/null +++ b/crates/onyx-cli/src/commands/mod.rs @@ -0,0 +1,43 @@ +pub mod init; +pub mod workspace; +pub mod list; +pub mod task; +pub mod group; +pub mod sync; + +use onyx_core::{AppConfig, TaskRepository}; +use anyhow::{Context, Result}; +use std::path::PathBuf; + +pub fn get_config_path() -> PathBuf { + AppConfig::get_config_path() +} + +pub fn load_config() -> Result { + let path = get_config_path(); + AppConfig::load_from_file(&path).context("Failed to load config") +} + +pub fn save_config(config: &AppConfig) -> Result<()> { + let path = get_config_path(); + config.save_to_file(&path).context("Failed to save config") +} + +pub fn get_repository(workspace_name: Option) -> Result<(TaskRepository, String)> { + let config = load_config()?; + + let (name, workspace_config) = if let Some(name) = workspace_name { + let workspace_config = config.get_workspace(&name) + .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))?; + (name, workspace_config.clone()) + } else { + let (name, workspace_config) = config.get_current_workspace() + .context("No workspace set. Use 'onyx init' to create one.")?; + (name.clone(), workspace_config.clone()) + }; + + let repo = TaskRepository::new(workspace_config.path.clone()) + .context(format!("Failed to open workspace '{}'", name))?; + + Ok((repo, name)) +} diff --git a/crates/onyx-cli/src/commands/sync.rs b/crates/onyx-cli/src/commands/sync.rs new file mode 100644 index 0000000..234062d --- /dev/null +++ b/crates/onyx-cli/src/commands/sync.rs @@ -0,0 +1,229 @@ +use anyhow::{Context, Result}; +use colored::Colorize; +use onyx_core::sync::{SyncMode, sync_workspace, get_sync_status}; +use onyx_core::webdav::{WebDavClient, store_credentials, load_credentials}; +use crate::output; +use super::{load_config, save_config}; + +/// Run sync setup: prompt for URL, username, password, test connection, store credentials. +pub fn setup(workspace_name: Option) -> Result<()> { + let mut config = load_config()?; + + let (name, workspace) = if let Some(name) = workspace_name { + let ws = config.get_workspace(&name) + .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))? + .clone(); + (name, ws) + } else { + let (n, ws) = config.get_current_workspace() + .context("No workspace set. Use 'onyx init' to create one.")?; + (n.clone(), ws.clone()) + }; + + // Prompt for WebDAV URL + output::header(&format!("WebDAV sync setup for workspace \"{}\"", name.green())); + output::blank(); + + let url = prompt("WebDAV URL: ")?; + if url.is_empty() { + output::error("URL cannot be empty"); + return Ok(()); + } + + let username = prompt("Username: ")?; + let password = rpassword::read_password_from_tty(Some("Password: ")) + .context("Failed to read password")?; + + // Test connection + output::blank(); + output::info("Testing connection..."); + + let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; + let client = WebDavClient::new(&url, &username, &password); + + match rt.block_on(client.test_connection()) { + Ok(()) => { + output::success("Connection successful!"); + } + Err(e) => { + output::error(&format!("Connection failed: {}", e)); + return Ok(()); + } + } + + // Store credentials in keychain + let domain = extract_domain(&url); + match store_credentials(&domain, &username, &password) { + Ok(()) => output::info("Credentials stored in system keychain"), + Err(e) => { + output::warning(&format!( + "Could not store in keychain ({}). Set ONYX_WEBDAV_USER and ONYX_WEBDAV_PASS env vars instead.", + e + )); + } + } + + // Update workspace config with WebDAV URL + let mut ws = workspace; + ws.webdav_url = Some(url); + config.add_workspace(name, ws); + save_config(&config)?; + + output::success("Sync setup complete. Run 'onyx sync' to sync."); + Ok(()) +} + +/// Execute a sync operation. +pub fn execute(mode: SyncMode, workspace_name: Option) -> Result<()> { + let config = load_config()?; + + let (name, workspace) = if let Some(name) = workspace_name { + let ws = config.get_workspace(&name) + .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))? + .clone(); + (name, ws) + } else { + let (n, ws) = config.get_current_workspace() + .context("No workspace set. Use 'onyx init' to create one.")?; + (n.clone(), ws.clone()) + }; + + let url = workspace.webdav_url.as_ref() + .ok_or_else(|| anyhow::anyhow!( + "No WebDAV URL configured for workspace '{}'. Run 'onyx sync --setup' first.", name + ))?; + + let domain = extract_domain(url); + let (username, password) = load_credentials(&domain) + .context("Failed to load credentials")?; + + let mode_str = match mode { + SyncMode::Full => "Syncing", + SyncMode::Push => "Pushing", + SyncMode::Pull => "Pulling", + }; + output::info(&format!("{} workspace \"{}\"...", mode_str, name.green())); + + let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; + let result = rt.block_on(sync_workspace( + &workspace.path, + url, + &username, + &password, + mode, + Some(Box::new(|msg: &str| { println!("{}", msg); })), + )).context("Sync failed")?; + + // Print summary + let mut parts = Vec::new(); + if result.uploaded > 0 { parts.push(format!("{} uploaded", result.uploaded)); } + if result.downloaded > 0 { parts.push(format!("{} downloaded", result.downloaded)); } + if result.deleted_local > 0 { parts.push(format!("{} deleted locally", result.deleted_local)); } + if result.deleted_remote > 0 { parts.push(format!("{} deleted remotely", result.deleted_remote)); } + if result.conflicts > 0 { parts.push(format!("{} conflicts", result.conflicts)); } + + if parts.is_empty() { + output::success("Already in sync, nothing to do."); + } else { + let summary = parts.join(", "); + if result.errors.is_empty() { + output::success(&format!("Sync complete: {}", summary)); + } else { + output::warning(&format!("Sync complete with errors: {}", summary)); + for err in &result.errors { + output::error(err); + } + } + } + + Ok(()) +} + +/// Show sync status for a workspace. +pub fn status(workspace_name: Option, all: bool) -> Result<()> { + let config = load_config()?; + + if all { + // Show status for all workspaces that have sync configured + let mut found_any = false; + let mut names: Vec<_> = config.workspaces.keys().cloned().collect(); + names.sort(); + for name in names { + let ws = config.get_workspace(&name).unwrap(); + if ws.webdav_url.is_some() { + found_any = true; + print_workspace_status(&name, &ws.path, ws.webdav_url.as_deref())?; + output::blank(); + } + } + if !found_any { + output::info("No workspaces have sync configured. Run 'onyx sync --setup' to set up."); + } + return Ok(()); + } + + let (name, workspace) = if let Some(name) = workspace_name { + let ws = config.get_workspace(&name) + .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))? + .clone(); + (name, ws) + } else { + let (n, ws) = config.get_current_workspace() + .context("No workspace set.")?; + (n.clone(), ws.clone()) + }; + + print_workspace_status(&name, &workspace.path, workspace.webdav_url.as_deref())?; + Ok(()) +} + +fn print_workspace_status(name: &str, path: &std::path::Path, webdav_url: Option<&str>) -> Result<()> { + output::header(&format!("Workspace: {}", name.green())); + + if let Some(url) = webdav_url { + output::detail("WebDAV URL", url); + } else { + output::detail("WebDAV", &"not configured".dimmed().to_string()); + return Ok(()); + } + + let info = get_sync_status(path)?; + + if let Some(last) = info.last_sync { + output::detail("Last sync", &last.format("%Y-%m-%d %H:%M:%S UTC").to_string()); + } else { + output::detail("Last sync", &"never".dimmed().to_string()); + } + + output::detail("Tracked files", &info.tracked_files.to_string()); + output::detail("Pending changes", &info.pending_changes.to_string()); + if info.queued_operations > 0 { + output::detail("Queued operations", &format!("{}", info.queued_operations).yellow().to_string()); + } + + Ok(()) +} + +/// Extract domain from a URL for credential storage. +fn extract_domain(url: &str) -> String { + url.split("://") + .nth(1) + .unwrap_or(url) + .split('/') + .next() + .unwrap_or(url) + .split(':') + .next() + .unwrap_or(url) + .to_string() +} + +/// Prompt the user for text input. +fn prompt(message: &str) -> Result { + use std::io::Write; + print!("{}", message); + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + Ok(input.trim().to_string()) +} diff --git a/crates/onyx-cli/src/commands/task.rs b/crates/onyx-cli/src/commands/task.rs new file mode 100644 index 0000000..16ad54d --- /dev/null +++ b/crates/onyx-cli/src/commands/task.rs @@ -0,0 +1,222 @@ +use anyhow::{Context, Result}; +use onyx_core::Task; +use chrono::{DateTime, Utc}; +use uuid::Uuid; +use crate::output; +use crate::commands::get_repository; + +pub fn add(title: String, list_name: Option, due_str: Option, workspace: Option) -> Result<()> { + let (mut repo, _workspace_name) = get_repository(workspace)?; + + // Get lists + let lists = repo.get_lists() + .context("Failed to get lists")?; + + if lists.is_empty() { + anyhow::bail!("No lists found. Create one with 'onyx list create '"); + } + + // Find the target list + let list = if let Some(name) = list_name { + lists.iter() + .find(|l| l.title == name) + .ok_or_else(|| anyhow::anyhow!("List '{}' not found", name))? + } else { + // Use the first list + &lists[0] + }; + + // Create task + let mut task = Task::new(title.clone()); + + // Parse due date if provided + if let Some(due_str) = due_str { + let due_date = parse_due_date(&due_str)?; + task.due_date = Some(due_date); + } + + // Save task + repo.create_task(list.id, task.clone()) + .context("Failed to create task")?; + + let due_info = if let Some(due) = task.due_date { + format!("\n Due: {}", due.format("%Y-%m-%d")) + } else { + String::new() + }; + + output::success(&format!("Created task \"{}\" ({}){}", title, task.id, due_info)); + + Ok(()) +} + +pub fn complete(task_id_str: String, workspace: Option) -> Result<()> { + let (mut repo, _workspace_name) = get_repository(workspace)?; + + let task_id = Uuid::parse_str(&task_id_str) + .context("Invalid task ID")?; + + // Find the task across all lists + let lists = repo.get_lists()?; + let mut found = false; + + for list in lists { + if let Some(mut task) = list.tasks.iter().find(|t| t.id == task_id).cloned() { + task.complete(); + repo.update_task(list.id, task.clone()) + .context("Failed to update task")?; + + output::success(&format!("Completed task \"{}\"", task.title)); + found = true; + break; + } + } + + if !found { + anyhow::bail!("Task not found: {}", task_id_str); + } + + Ok(()) +} + +pub fn delete(task_id_str: String, workspace: Option) -> Result<()> { + let (mut repo, _workspace_name) = get_repository(workspace)?; + + let task_id = Uuid::parse_str(&task_id_str) + .context("Invalid task ID")?; + + // Find the task across all lists + let lists = repo.get_lists()?; + let mut found = false; + + for list in lists { + if let Some(task) = list.tasks.iter().find(|t| t.id == task_id) { + let title = task.title.clone(); + + output::warning(&format!("This will delete task \"{}\"", title)); + print!("Continue? (y/n): "); + use std::io::{self, Write}; + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + if input.trim().to_lowercase() != "y" { + output::info("Cancelled"); + return Ok(()); + } + + repo.delete_task(list.id, task_id) + .context("Failed to delete task")?; + + output::success(&format!("Deleted task \"{}\"", title)); + found = true; + break; + } + } + + if !found { + anyhow::bail!("Task not found: {}", task_id_str); + } + + Ok(()) +} + +pub fn edit(task_id_str: String, workspace: Option) -> Result<()> { + let (mut repo, _workspace_name) = get_repository(workspace)?; + + let task_id = Uuid::parse_str(&task_id_str) + .context("Invalid task ID")?; + + // Find the task across all lists + let lists = repo.get_lists()?; + let mut task_list_id = None; + let mut task_to_edit = None; + + for list in lists { + if let Some(task) = list.tasks.iter().find(|t| t.id == task_id).cloned() { + task_list_id = Some(list.id); + task_to_edit = Some(task); + break; + } + } + + let (list_id, task) = match (task_list_id, task_to_edit) { + (Some(lid), Some(t)) => (lid, t), + _ => anyhow::bail!("Task not found: {}", task_id_str), + }; + + // Create temporary file with task content + let temp_dir = std::env::temp_dir(); + let temp_file = temp_dir.join(format!("onyx-{}.md", task.id)); + + // Write current task content to temp file + let content = format!("# {}\n\n{}", task.title, task.description); + std::fs::write(&temp_file, content)?; + + // Get editor from environment + let editor = std::env::var("EDITOR").unwrap_or_else(|_| { + if cfg!(windows) { + "notepad".to_string() + } else { + "nano".to_string() + } + }); + + // Open editor + let status = std::process::Command::new(&editor) + .arg(&temp_file) + .status() + .context(format!("Failed to open editor: {}", editor))?; + + if !status.success() { + anyhow::bail!("Editor exited with non-zero status"); + } + + // Read updated content + let updated_content = std::fs::read_to_string(&temp_file)?; + + // Parse the content + let lines: Vec<&str> = updated_content.lines().collect(); + let (title, description) = if !lines.is_empty() && lines[0].starts_with("# ") { + let title = lines[0].trim_start_matches("# ").trim().to_string(); + let description = if lines.len() > 2 { + lines[2..].join("\n").trim().to_string() + } else { + String::new() + }; + (title, description) + } else { + (task.title.clone(), updated_content.trim().to_string()) + }; + + // Update task + let mut updated_task = task.clone(); + updated_task.title = title; + updated_task.description = description; + updated_task.updated_at = Utc::now(); + + repo.update_task(list_id, updated_task.clone()) + .context("Failed to update task")?; + + // Clean up temp file + std::fs::remove_file(&temp_file).ok(); + + output::success(&format!("Updated task \"{}\"", updated_task.title)); + + Ok(()) +} + +fn parse_due_date(s: &str) -> Result> { + // Try parsing as date only (YYYY-MM-DD) + if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") { + let naive_datetime = naive_date.and_hms_opt(0, 0, 0) + .ok_or_else(|| anyhow::anyhow!("Invalid date"))?; + return Ok(DateTime::from_naive_utc_and_offset(naive_datetime, Utc)); + } + + // Try parsing as full datetime (ISO 8601) + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Ok(dt.with_timezone(&Utc)); + } + + anyhow::bail!("Invalid date format. Use YYYY-MM-DD or ISO 8601 format (YYYY-MM-DDTHH:MM:SS)") +} diff --git a/crates/onyx-cli/src/commands/workspace.rs b/crates/onyx-cli/src/commands/workspace.rs new file mode 100644 index 0000000..4467752 --- /dev/null +++ b/crates/onyx-cli/src/commands/workspace.rs @@ -0,0 +1,209 @@ +use anyhow::{Context, Result}; +use onyx_core::{TaskRepository, WorkspaceConfig}; +use std::path::PathBuf; +use colored::*; +use crate::output; +use crate::commands::{load_config, save_config}; + +pub fn add(name: String, path: String) -> Result<()> { + let path_buf = PathBuf::from(path); + let path_buf = if path_buf.is_relative() { + std::env::current_dir()?.join(path_buf) + } else { + path_buf + }; + + // Initialize the repository + let mut repo = TaskRepository::init(path_buf.clone()) + .context("Failed to initialize tasks folder")?; + + // Create default list if it doesn't exist + let lists = repo.get_lists().context("Failed to get lists")?; + if !lists.iter().any(|l| l.title == "My Tasks") { + repo.create_list("My Tasks".to_string()) + .context("Failed to create default list")?; + } + + // Load config + let mut config = load_config()?; + + // Check if workspace already exists + if config.get_workspace(&name).is_some() { + anyhow::bail!("Workspace '{}' already exists", name); + } + + // Add workspace + config.add_workspace(name.clone(), WorkspaceConfig::new(path_buf.clone())); + + // Save config + save_config(&config)?; + + output::success(&format!("Added workspace \"{}\" at {}", name, path_buf.display())); + output::success("Created default list \"My Tasks\""); + + Ok(()) +} + +pub fn list() -> Result<()> { + let config = load_config()?; + + if config.workspaces.is_empty() { + output::info("No workspaces configured. Use 'onyx init' to create one."); + return Ok(()); + } + + let current = config.current_workspace.as_deref(); + + let mut workspaces: Vec<_> = config.workspaces.iter().collect(); + workspaces.sort_by(|a, b| a.0.cmp(b.0)); + + for (name, workspace_config) in workspaces { + let marker = if Some(name.as_str()) == current { + " (current)".green() + } else { + "".normal() + }; + output::item(&format!("{}: {}{}", name, workspace_config.path.display(), marker)); + } + + Ok(()) +} + +pub fn switch(name: String) -> Result<()> { + let mut config = load_config()?; + + // Verify workspace exists + if config.get_workspace(&name).is_none() { + anyhow::bail!("Workspace '{}' not found", name); + } + + config.set_current_workspace(name.clone())?; + save_config(&config)?; + + output::success(&format!("Switched to workspace \"{}\"", name)); + + Ok(()) +} + +pub fn remove(name: String) -> Result<()> { + let mut config = load_config()?; + + // Verify workspace exists + if config.get_workspace(&name).is_none() { + anyhow::bail!("Workspace '{}' not found", name); + } + + // Confirm + output::warning("This will delete workspace config (files remain on disk)"); + print!("Continue? (y/n): "); + use std::io::{self, Write}; + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if input.trim().to_lowercase() != "y" { + output::info("Cancelled"); + return Ok(()); + } + + config.remove_workspace(&name); + save_config(&config)?; + + output::success(&format!("Removed workspace \"{}\"", name)); + + Ok(()) +} + +pub fn retarget(name: String, path: String) -> Result<()> { + let path_buf = PathBuf::from(path); + let path_buf = if path_buf.is_relative() { + std::env::current_dir()?.join(path_buf) + } else { + path_buf + }; + + let mut config = load_config()?; + + // Verify workspace exists + if config.get_workspace(&name).is_none() { + anyhow::bail!("Workspace '{}' not found", name); + } + + // Update path + config.add_workspace(name.clone(), WorkspaceConfig::new(path_buf.clone())); + save_config(&config)?; + + output::success(&format!("Workspace \"{}\" now points to {}", name, path_buf.display())); + + Ok(()) +} + +pub fn migrate(name: String, new_path: String) -> Result<()> { + let new_path_buf = PathBuf::from(new_path); + let new_path_buf = if new_path_buf.is_relative() { + std::env::current_dir()?.join(new_path_buf) + } else { + new_path_buf + }; + + let mut config = load_config()?; + + // Get current workspace config + let old_path = config.get_workspace(&name) + .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", name))? + .path.clone(); + + // Confirm + output::warning(&format!("This will move all files from {} to {}", old_path.display(), new_path_buf.display())); + print!("Continue? (y/n): "); + use std::io::{self, Write}; + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if input.trim().to_lowercase() != "y" { + output::info("Cancelled"); + return Ok(()); + } + + // Create destination directory + std::fs::create_dir_all(&new_path_buf)?; + + // Move files + output::info("Moving files..."); + let entries = std::fs::read_dir(&old_path)?; + let mut count = 0; + + for entry in entries { + let entry = entry?; + let file_name = entry.file_name(); + let dest = new_path_buf.join(&file_name); + + if entry.path().is_dir() { + let mut options = fs_extra::dir::CopyOptions::new(); + options.copy_inside = true; + fs_extra::dir::move_dir(entry.path(), &new_path_buf, &options)?; + output::item(&format!("Moved {}/", file_name.to_string_lossy())); + } else { + std::fs::rename(entry.path(), dest)?; + output::item(&format!("Moved {}", file_name.to_string_lossy())); + } + count += 1; + } + + // Remove old directory if empty + if old_path.read_dir()?.next().is_none() { + std::fs::remove_dir(&old_path)?; + } + + // Update config + config.add_workspace(name.clone(), WorkspaceConfig::new(new_path_buf.clone())); + save_config(&config)?; + + output::success(&format!("Migrated {} items to {}", count, new_path_buf.display())); + output::success(&format!("Workspace \"{}\" now points to {}", name, new_path_buf.display())); + + Ok(()) +} diff --git a/crates/onyx-cli/src/main.rs b/crates/onyx-cli/src/main.rs new file mode 100644 index 0000000..7b1e3a8 --- /dev/null +++ b/crates/onyx-cli/src/main.rs @@ -0,0 +1,277 @@ +mod commands; +mod output; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use commands::*; + +#[derive(Parser)] +#[command(name = "onyx")] +#[command(about = "A local-first, cross-platform tasks application", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Initialize a new workspace + Init { + /// Path to store tasks + path: String, + /// Name of the workspace + #[arg(short, long)] + name: String, + }, + + /// Manage workspaces + #[command(subcommand)] + Workspace(WorkspaceCommands), + + /// Manage task lists + #[command(subcommand)] + List(ListCommands), + + /// Add a new task + Add { + /// Task title + title: String, + /// List to add task to + #[arg(short, long)] + list: Option, + /// Due date (ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS) + #[arg(short, long)] + due: Option, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, + + /// Mark a task as complete + Complete { + /// Task ID + task_id: String, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, + + /// Delete a task + Delete { + /// Task ID + task_id: String, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, + + /// Edit a task + Edit { + /// Task ID + task_id: String, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, + + /// Toggle group-by-due-date for a list + #[command(subcommand)] + Group(GroupCommands), + + /// Sync workspace with WebDAV server + Sync { + /// Run initial setup (URL, credentials) + #[arg(long)] + setup: bool, + /// Push-only sync (upload local changes) + #[arg(long, conflicts_with_all = ["pull", "setup", "status"])] + push: bool, + /// Pull-only sync (download remote changes) + #[arg(long, conflicts_with_all = ["push", "setup", "status"])] + pull: bool, + /// Show sync status + #[arg(long, conflicts_with_all = ["push", "pull", "setup"])] + status: bool, + /// Show status for all workspaces (with --status) + #[arg(long, requires = "status")] + all: bool, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, +} + +#[derive(Subcommand)] +enum WorkspaceCommands { + /// Add a new workspace + Add { + /// Name of the workspace + name: String, + /// Path to store tasks + path: String, + }, + + /// List all workspaces + List, + + /// Switch to a different workspace + Switch { + /// Name of the workspace + name: String, + }, + + /// Remove a workspace + Remove { + /// Name of the workspace + name: String, + }, + + /// Update workspace path without moving files + Retarget { + /// Name of the workspace + name: String, + /// New path + path: String, + }, + + /// Move workspace files to a new location + Migrate { + /// Name of the workspace + name: String, + /// New path + path: String, + }, +} + +#[derive(Subcommand)] +enum ListCommands { + /// Create a new task list + Create { + /// Name of the list + name: String, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, + + /// Show all tasks (or tasks in a specific list) + Show { + /// Name of the list to show + #[arg(short, long)] + list: Option, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, + + /// Delete a task list + Delete { + /// Name of the list to delete + name: String, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, +} + +#[derive(Subcommand)] +enum GroupCommands { + /// Enable group-by-due-date for a list + Enable { + /// Name of the list + #[arg(short, long)] + list: String, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, + + /// Disable group-by-due-date for a list + Disable { + /// Name of the list + #[arg(short, long)] + list: String, + /// Workspace to use + #[arg(short, long)] + workspace: Option, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + match cli.command { + Commands::Init { path, name } => { + init::execute(path, name)?; + } + Commands::Workspace(cmd) => match cmd { + WorkspaceCommands::Add { name, path } => { + workspace::add(name, path)?; + } + WorkspaceCommands::List => { + workspace::list()?; + } + WorkspaceCommands::Switch { name } => { + workspace::switch(name)?; + } + WorkspaceCommands::Remove { name } => { + workspace::remove(name)?; + } + WorkspaceCommands::Retarget { name, path } => { + workspace::retarget(name, path)?; + } + WorkspaceCommands::Migrate { name, path } => { + workspace::migrate(name, path)?; + } + }, + Commands::List(cmd) => match cmd { + ListCommands::Create { name, workspace } => { + list::create(name, workspace)?; + } + ListCommands::Show { list, workspace } => { + list::show(list, workspace)?; + } + ListCommands::Delete { name, workspace } => { + list::delete(name, workspace)?; + } + }, + Commands::Add { title, list, due, workspace } => { + task::add(title, list, due, workspace)?; + } + Commands::Complete { task_id, workspace } => { + task::complete(task_id, workspace)?; + } + Commands::Delete { task_id, workspace } => { + task::delete(task_id, workspace)?; + } + Commands::Edit { task_id, workspace } => { + task::edit(task_id, workspace)?; + } + Commands::Group(cmd) => match cmd { + GroupCommands::Enable { list, workspace } => { + group::enable(list, workspace)?; + } + GroupCommands::Disable { list, workspace } => { + group::disable(list, workspace)?; + } + }, + Commands::Sync { setup, push, pull, status, all, workspace } => { + if setup { + sync::setup(workspace)?; + } else if status { + sync::status(workspace, all)?; + } else { + let mode = if push { + onyx_core::sync::SyncMode::Push + } else if pull { + onyx_core::sync::SyncMode::Pull + } else { + onyx_core::sync::SyncMode::Full + }; + sync::execute(mode, workspace)?; + } + }, + } + + Ok(()) +} diff --git a/crates/onyx-cli/src/output.rs b/crates/onyx-cli/src/output.rs new file mode 100644 index 0000000..dd49031 --- /dev/null +++ b/crates/onyx-cli/src/output.rs @@ -0,0 +1,33 @@ +use colored::*; + +pub fn success(message: &str) { + println!("{} {}", "✓".green(), message); +} + +pub fn error(message: &str) { + eprintln!("{} {}", "✗".red(), message); +} + +pub fn warning(message: &str) { + println!("{} {}", "⚠".yellow(), message); +} + +pub fn info(message: &str) { + println!("{} {}", "ℹ".blue(), message); +} + +pub fn header(message: &str) { + println!("{}", message.bold()); +} + +pub fn detail(label: &str, value: &str) { + println!(" {}: {}", label, value); +} + +pub fn item(message: &str) { + println!(" {}", message); +} + +pub fn blank() { + println!(); +}