rename onyx-cli crate (formerly bevy-tasks-cli)
This commit is contained in:
parent
9e204ef818
commit
27363c8424
22
crates/onyx-cli/Cargo.toml
Normal file
22
crates/onyx-cli/Cargo.toml
Normal file
|
|
@ -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"
|
||||||
39
crates/onyx-cli/src/commands/group.rs
Normal file
39
crates/onyx-cli/src/commands/group.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use crate::output;
|
||||||
|
use crate::commands::get_repository;
|
||||||
|
|
||||||
|
pub fn enable(list_name: String, workspace: Option<String>) -> 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<String>) -> 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(())
|
||||||
|
}
|
||||||
43
crates/onyx-cli/src/commands/init.rs
Normal file
43
crates/onyx-cli/src/commands/init.rs
Normal file
|
|
@ -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(())
|
||||||
|
}
|
||||||
91
crates/onyx-cli/src/commands/list.rs
Normal file
91
crates/onyx-cli/src/commands/list.rs
Normal file
|
|
@ -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<String>) -> 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<String>, workspace: Option<String>) -> 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 <name>'");
|
||||||
|
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<String>) -> 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(())
|
||||||
|
}
|
||||||
43
crates/onyx-cli/src/commands/mod.rs
Normal file
43
crates/onyx-cli/src/commands/mod.rs
Normal file
|
|
@ -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<AppConfig> {
|
||||||
|
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<String>) -> 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))
|
||||||
|
}
|
||||||
229
crates/onyx-cli/src/commands/sync.rs
Normal file
229
crates/onyx-cli/src/commands/sync.rs
Normal file
|
|
@ -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<String>) -> 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<String>) -> 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<String>, 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<String> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
222
crates/onyx-cli/src/commands/task.rs
Normal file
222
crates/onyx-cli/src/commands/task.rs
Normal file
|
|
@ -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<String>, due_str: Option<String>, workspace: Option<String>) -> 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 <name>'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<String>) -> 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<String>) -> 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<String>) -> 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<DateTime<Utc>> {
|
||||||
|
// 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)")
|
||||||
|
}
|
||||||
209
crates/onyx-cli/src/commands/workspace.rs
Normal file
209
crates/onyx-cli/src/commands/workspace.rs
Normal file
|
|
@ -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(())
|
||||||
|
}
|
||||||
277
crates/onyx-cli/src/main.rs
Normal file
277
crates/onyx-cli/src/main.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
/// Due date (ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)
|
||||||
|
#[arg(short, long)]
|
||||||
|
due: Option<String>,
|
||||||
|
/// Workspace to use
|
||||||
|
#[arg(short, long)]
|
||||||
|
workspace: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Mark a task as complete
|
||||||
|
Complete {
|
||||||
|
/// Task ID
|
||||||
|
task_id: String,
|
||||||
|
/// Workspace to use
|
||||||
|
#[arg(short, long)]
|
||||||
|
workspace: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Delete a task
|
||||||
|
Delete {
|
||||||
|
/// Task ID
|
||||||
|
task_id: String,
|
||||||
|
/// Workspace to use
|
||||||
|
#[arg(short, long)]
|
||||||
|
workspace: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Edit a task
|
||||||
|
Edit {
|
||||||
|
/// Task ID
|
||||||
|
task_id: String,
|
||||||
|
/// Workspace to use
|
||||||
|
#[arg(short, long)]
|
||||||
|
workspace: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show all tasks (or tasks in a specific list)
|
||||||
|
Show {
|
||||||
|
/// Name of the list to show
|
||||||
|
#[arg(short, long)]
|
||||||
|
list: Option<String>,
|
||||||
|
/// Workspace to use
|
||||||
|
#[arg(short, long)]
|
||||||
|
workspace: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Delete a task list
|
||||||
|
Delete {
|
||||||
|
/// Name of the list to delete
|
||||||
|
name: String,
|
||||||
|
/// Workspace to use
|
||||||
|
#[arg(short, long)]
|
||||||
|
workspace: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
33
crates/onyx-cli/src/output.rs
Normal file
33
crates/onyx-cli/src/output.rs
Normal file
|
|
@ -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!();
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue