rust_i18n::i18n!("../locales", fallback = "en");
use anyhow::Result;
use cfait::context::{AppContext, StandardContext};
use cfait::model::Task;
use cfait::storage::LocalStorage;
use cfait::store::{FilterOptions, TaskStore};
use chrono::Utc;
use std::collections::HashSet;
use std::env;
use std::path::PathBuf;
use std::sync::Arc;
async fn build_store_cli(ctx: &Arc<dyn AppContext>) -> TaskStore {
let mut store = TaskStore::new(ctx.clone());
if let Ok(locals) = cfait::storage::LocalCalendarRegistry::load(ctx.as_ref()) {
for loc in locals {
if let Ok(mut tasks) =
cfait::storage::LocalStorage::load_for_href(ctx.as_ref(), &loc.href)
{
cfait::journal::Journal::apply_to_tasks(ctx.as_ref(), &mut tasks, &loc.href);
store.insert(loc.href, tasks);
}
}
}
if let Ok(cals) = cfait::cache::Cache::load_calendars(ctx.as_ref()) {
for cal in cals {
if cal.href.starts_with("local://") {
continue;
}
if let Ok((mut tasks, _)) = cfait::cache::Cache::load(ctx.as_ref(), &cal.href) {
cfait::journal::Journal::apply_to_tasks(ctx.as_ref(), &mut tasks, &cal.href);
store.insert(cal.href, tasks);
}
}
}
store
}
fn resolve_uid(store: &TaskStore, partial: &str) -> Option<String> {
let mut matches: Vec<String> = Vec::new();
for map in store.calendars.values() {
for uid in map.keys() {
if uid.starts_with(partial) {
matches.push(uid.clone());
}
}
}
match matches.len() {
1 => Some(matches.into_iter().next().unwrap()),
0 => {
eprintln!(
"{}",
rust_i18n::t!("error_no_task_matches_uid", uid = partial)
);
None
}
_ => {
eprintln!("{}", rust_i18n::t!("error_ambiguous_uid", uid = partial));
for m in matches {
if let Some(t) = store.get_task_ref(&m) {
let short = &m[..std::cmp::min(8, m.len())];
eprintln!(" {} - {}", short, t.summary);
}
}
None
}
}
}
async fn maybe_sync(ctx: Arc<dyn AppContext>) -> Result<(), String> {
if let Ok(config) = cfait::config::Config::load_with_credentials(ctx.as_ref()) {
sync_background(ctx, config).await
} else {
Ok(())
}
}
async fn sync_background(
ctx: Arc<dyn AppContext>,
config: cfait::config::Config,
) -> Result<(), String> {
if config.url.is_empty() {
return Ok(());
}
let ctx_clone = ctx.clone();
let cfg_clone = config.clone();
let sync_future = async move {
if let Ok((client, _, _, _, _)) = cfait::client::RustyClient::connect_with_fallback(
ctx_clone.clone(),
cfg_clone,
Some("CLI"),
)
.await
{
client.sync_journal().await?;
}
Ok(())
};
match tokio::time::timeout(std::time::Duration::from_secs(10), sync_future).await {
Ok(res) => res,
Err(_) => Err(rust_i18n::t!("sync_timed_out").to_string()),
}
}
#[tokio::main]
async fn main() -> Result<()> {
let mut args: Vec<String> = env::args().collect();
let binary_name = args.first().cloned().unwrap_or_else(|| "cfait".to_string());
let mut override_root: Option<PathBuf> = None;
if let Some(pos) = args.iter().position(|arg| arg == "--root" || arg == "-r")
&& pos + 1 < args.len()
{
override_root = Some(PathBuf::from(args[pos + 1].clone()));
args.remove(pos);
args.remove(pos);
}
let ctx: Arc<dyn AppContext> = Arc::new(StandardContext::new(override_root));
cfait::config::init_locale(ctx.as_ref());
let command = args.get(1).map(|s| s.as_str()).unwrap_or("");
let is_interactive_tui = command.is_empty();
cfait::system::init_logging(ctx.as_ref(), !is_interactive_tui, None);
cfait::system::init_keyring();
if command.starts_with('-') || command == "help" {
cfait::cli::print_help(&binary_name);
return Ok(());
}
match command {
"import" => {
if args.len() < 3 {
eprintln!("{}", rust_i18n::t!("error_missing_file_path"));
eprintln!("{}", rust_i18n::t!("cli_usage_import"));
std::process::exit(1);
}
let file_path = &args[2];
let collection_id = if args.len() > 4 && args[3] == "--collection" {
Some(args[4].clone())
} else {
None
};
let ics_content = std::fs::read_to_string(file_path).unwrap_or_else(|e| {
eprintln!(
"{}",
rust_i18n::t!(
"error_reading_file",
path = file_path,
error = e.to_string()
)
);
std::process::exit(1);
});
let href = if let Some(col_id) = collection_id {
if col_id == "default" {
"local://default".to_string()
} else {
format!("local://{}", col_id)
}
} else {
"local://default".to_string()
};
match LocalStorage::import_from_ics(ctx.as_ref(), &href, &ics_content) {
Ok(count) => {
if count == 1 {
println!("{}", rust_i18n::t!("import_success", count = 1));
} else {
println!("{}", rust_i18n::t!("import_success", count = count));
}
}
Err(e) => {
eprintln!("{}", rust_i18n::t!("import_error", error = e.to_string()));
std::process::exit(1);
}
}
return Ok(());
}
"export" => {
let collection_id = if args.len() > 3 && args[2] == "--collection" {
Some(args[3].clone())
} else {
None
};
let tasks = if let Some(col_id) = collection_id {
let href = if col_id == "default" {
"local://default".to_string()
} else {
format!("local://{}", col_id)
};
LocalStorage::load_for_href(ctx.as_ref(), &href)?
} else {
LocalStorage::load_for_href(ctx.as_ref(), cfait::storage::LOCAL_CALENDAR_HREF)?
};
println!("{}", LocalStorage::to_ics_string(&tasks));
return Ok(());
}
"sync" => {
let config =
cfait::config::Config::load_with_credentials(ctx.as_ref()).unwrap_or_default();
if config.url.is_empty() {
println!("{}", rust_i18n::t!("offline_mode_configured"));
return Ok(());
}
println!("{}", rust_i18n::t!("syncing"));
match cfait::client::RustyClient::connect_with_fallback(
ctx.clone(),
config,
Some("CLI-Sync"),
)
.await
{
Ok(_) => println!("{}", rust_i18n::t!("sync_completed_successfully")),
Err(e) => {
eprintln!("{}", rust_i18n::t!("sync_error", error = e.to_string()));
std::process::exit(1);
}
}
return Ok(());
}
"daemon" => {
println!("{}", rust_i18n::t!("starting_daemon"));
loop {
let config =
cfait::config::Config::load_with_credentials(ctx.as_ref()).unwrap_or_default();
let interval = config.auto_refresh_interval_mins;
if interval == 0 {
println!("{}", rust_i18n::t!("daemon_auto_refresh_disabled"));
return Ok(());
}
if config.url.is_empty() {
println!("{}", rust_i18n::t!("daemon_offline_sleeping"));
} else {
#[cfg(not(target_os = "android"))]
match cfait::storage::DaemonLock::try_acquire_exclusive(ctx.as_ref()) {
Ok(Some(_lock)) => {
println!("{}", rust_i18n::t!("daemon_syncing"));
let _ = cfait::client::RustyClient::connect_with_fallback(
ctx.clone(),
config,
Some("CLI-Daemon"),
)
.await;
}
Ok(None) => {}
Err(e) => eprintln!(
"{}",
rust_i18n::t!("daemon_lock_failed", error = e.to_string())
),
}
}
tokio::time::sleep(tokio::time::Duration::from_secs(interval as u64 * 60)).await;
}
}
"add" | "create" => {
let input = args[2..].join(" ");
if input.trim().is_empty() {
eprintln!("{}", rust_i18n::t!("error_empty_task_description"));
std::process::exit(1);
}
let mut config =
cfait::config::Config::load_with_credentials(ctx.as_ref()).unwrap_or_default();
let def_time =
chrono::NaiveTime::parse_from_str(&config.default_reminder_time, "%H:%M").ok();
let (clean_input, new_aliases) = cfait::model::extract_inline_aliases(&input);
if !new_aliases.is_empty() {
for (k, v) in &new_aliases {
let _ = cfait::model::validate_alias_integrity(k, v, &config.tag_aliases);
config.tag_aliases.insert(k.clone(), v.clone());
}
let _ = config.save_with_credentials(ctx.as_ref());
}
let mut task = Task::new(&clean_input, &config.tag_aliases, def_time);
let mut target_href = config
.default_calendar
.clone()
.unwrap_or_else(|| cfait::storage::LOCAL_CALENDAR_HREF.to_string());
let mut all_cals = Vec::new();
if let Ok(locals) = cfait::storage::LocalCalendarRegistry::load(ctx.as_ref()) {
all_cals.extend(locals);
}
if let Ok(remotes) = cfait::cache::Cache::load_calendars(ctx.as_ref()) {
all_cals.extend(remotes);
}
let mut matched = false;
if let Some(found) = all_cals.iter().find(|c| {
c.name == target_href
|| c.href == target_href
|| c.href.ends_with(&format!("/{}/", target_href))
|| c.href.ends_with(&format!("/{}", target_href))
}) {
target_href = found.href.clone();
matched = true;
}
if !matched && !target_href.starts_with("local://") && !target_href.starts_with('/') {
eprintln!(
"{}",
rust_i18n::t!("warning_calendar_not_found", calendar = target_href)
);
target_href = "local://recovery".to_string();
}
task.calendar_href = target_href;
let store = Arc::new(tokio::sync::Mutex::new(TaskStore::new(ctx.clone())));
let client = Arc::new(tokio::sync::Mutex::new(None));
let controller = cfait::controller::TaskController::new(store, client, ctx.clone());
let uid = controller
.create_task(task)
.await
.map_err(|e| anyhow::anyhow!(e))?;
println!(
"{}",
rust_i18n::t!(
"task_added_successfully",
uid = &uid[..std::cmp::min(8, uid.len())]
)
);
if let Err(e) = maybe_sync(ctx.clone()).await {
eprintln!(
"{}",
rust_i18n::t!("warning_background_sync_failed", error = e.to_string())
);
}
return Ok(());
}
"list" | "search" => {
let mut show_all = false;
let mut query_parts: Vec<String> = Vec::new();
for arg in args.iter().skip(2) {
if arg == "--all" {
show_all = true;
} else {
query_parts.push(arg.clone());
}
}
let query = if command == "search" {
query_parts.join(" ")
} else {
String::new()
};
let config =
cfait::config::Config::load_with_credentials(ctx.as_ref()).unwrap_or_default();
let store = build_store_cli(&ctx).await;
let mut hidden: HashSet<String> = HashSet::new();
let mut hide_completed = config.hide_completed;
if !show_all {
hidden.extend(config.hidden_calendars.into_iter());
hidden.extend(config.disabled_calendars.into_iter());
} else {
hide_completed = false;
}
let cutoff_date = if show_all {
None
} else {
config
.sort_cutoff_months
.map(|m| Utc::now() + chrono::Duration::days(m as i64 * 30))
};
let selected_categories: HashSet<String> = HashSet::new();
let selected_locations: HashSet<String> = HashSet::new();
let expanded_done_groups: HashSet<String> = HashSet::new();
let expanded_tags: HashSet<String> = HashSet::new();
let expanded_locations: HashSet<String> = HashSet::new();
let res = store.filter(FilterOptions {
active_cal_href: None,
hidden_calendars: &hidden,
selected_categories: &selected_categories,
selected_locations: &selected_locations,
match_all_categories: false,
search_term: &query,
hide_completed_global: hide_completed,
hide_fully_completed_tags: !show_all && config.hide_fully_completed_tags,
hide_aliases_in_sidebar: config.hide_aliases_in_sidebar,
cutoff_date,
min_duration: None,
max_duration: None,
include_unset_duration: true,
urgent_days: config.urgent_days_horizon,
urgent_prio: config.urgent_priority_threshold,
default_priority: config.default_priority,
start_grace_period_days: config.start_grace_period_days,
sort_standard_by_priority: config.sort_standard_by_priority,
expanded_done_groups: &expanded_done_groups,
expanded_tags: &expanded_tags,
expanded_locations: &expanded_locations,
max_done_roots: usize::MAX,
max_done_subtasks: usize::MAX,
tag_aliases: &config.tag_aliases,
});
if res.items.is_empty() {
println!("{}", rust_i18n::t!("status_no_tasks_found"));
return Ok(());
}
for item in res.items {
if let cfait::store::TaskListItem::Task(t) = item {
let symbol = t.checkbox_symbol();
let indent = " ".repeat(t.depth);
let smart = t.to_smart_string();
let summary_escaped = cfait::model::parser::escape_summary(&t.summary);
let metadata = smart.replacen(&summary_escaped, "", 1).trim().to_string();
let uid_short = &t.uid[..std::cmp::min(8, t.uid.len())];
let meta_str = if metadata.is_empty() {
String::new()
} else {
format!(" {}", metadata)
};
println!(
"{}{} {}{} [{}]",
indent, symbol, t.summary, meta_str, uid_short
);
}
}
return Ok(());
}
"view" | "show" => {
let store = build_store_cli(&ctx).await;
let partial = args.get(2).map(|s| s.as_str()).unwrap_or("");
let uid = resolve_uid(&store, partial)
.ok_or_else(|| anyhow::anyhow!(rust_i18n::t!("error_uid_required")))?;
let t = store
.get_task_ref(&uid)
.ok_or_else(|| anyhow::anyhow!(rust_i18n::t!("error_task_not_found")))?;
println!("{}: {}", rust_i18n::t!("cli_view_summary"), t.summary);
println!(
"{}: {:?} {}",
rust_i18n::t!("cli_view_status"),
t.status,
t.checkbox_symbol()
);
println!("{}: {}", rust_i18n::t!("cli_view_uid"), t.uid);
if let Some(d) = &t.due {
println!(
"{}: {}",
rust_i18n::t!("cli_view_due"),
d.format_smart()
);
}
if !t.categories.is_empty() {
println!(
"{}: {}",
rust_i18n::t!("cli_view_tags"),
t.categories.join(", ")
);
}
if let Some(l) = &t.location {
println!("{}: {}", rust_i18n::t!("cli_view_location"), l);
}
if !t.description.is_empty() {
println!(
"\n{}:\n{}",
rust_i18n::t!("cli_view_description"),
t.description
);
}
return Ok(());
}
"start" | "pause" | "toggle" | "done" | "complete" => {
let store = build_store_cli(&ctx).await;
let partial_uid = args.get(2).cloned().unwrap_or_default();
if partial_uid.is_empty() {
eprintln!("{}", rust_i18n::t!("error_missing_uid"));
std::process::exit(1);
}
let full_uid = match resolve_uid(&store, &partial_uid) {
Some(uid) => uid,
None => std::process::exit(1),
};
let config =
cfait::config::Config::load_with_credentials(ctx.as_ref()).unwrap_or_default();
let store_arc = Arc::new(tokio::sync::Mutex::new(store));
let client_arc = Arc::new(tokio::sync::Mutex::new(None));
let controller =
cfait::controller::TaskController::new(store_arc, client_arc, ctx.clone());
match command {
"start" => {
let mut store_lock = controller.store.lock().await;
let intent = cfait::model::AppIntent::StartTask {
uid: full_uid.clone(),
};
let actions = store_lock.apply_task_intent(&intent, &config);
drop(store_lock);
controller
.persist_changes(actions)
.await
.map_err(|e| anyhow::anyhow!(e))?;
println!("{}", rust_i18n::t!("task_started", uid = partial_uid));
}
"pause" => {
let mut store_lock = controller.store.lock().await;
let intent = cfait::model::AppIntent::PauseTask {
uid: full_uid.clone(),
};
let actions = store_lock.apply_task_intent(&intent, &config);
drop(store_lock);
controller
.persist_changes(actions)
.await
.map_err(|e| anyhow::anyhow!(e))?;
println!("{}", rust_i18n::t!("task_paused", uid = partial_uid));
}
_ => {
let mut store_lock = controller.store.lock().await;
let intent = cfait::model::AppIntent::ToggleTask {
uid: full_uid.clone(),
};
let actions = store_lock.apply_task_intent(&intent, &config);
drop(store_lock);
controller
.persist_changes(actions)
.await
.map_err(|e| anyhow::anyhow!(e))?;
println!("{}", rust_i18n::t!("task_toggled", uid = partial_uid));
}
}
if let Err(e) = maybe_sync(ctx.clone()).await {
eprintln!(
"{}",
rust_i18n::t!("warning_background_sync_failed", error = e.to_string())
);
}
return Ok(());
}
"collection" => {
if args.len() < 4 {
eprintln!("Usage: {} collection [create|edit] ...", binary_name);
std::process::exit(1);
}
let sub = &args[2];
let config =
cfait::config::Config::load_with_credentials(ctx.as_ref()).unwrap_or_default();
let client = match cfait::client::RustyClient::new(
ctx.clone(),
&config.url,
&config.username,
&config.password,
config.allow_insecure_certs,
Some("CLI"),
) {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to initialize client: {}", e);
std::process::exit(1);
}
};
match sub.as_str() {
"create" => {
let name = &args[3];
let mut color = None;
if args.len() >= 6 && args[4] == "--color" {
color = Some(args[5].as_str());
}
match client.create_calendar(name, color).await {
Ok(href) => println!("Created collection: {}", href),
Err(e) => {
eprintln!("Error creating collection: {}", e);
std::process::exit(1);
}
}
}
"edit" => {
let href = &args[3];
let mut name = None;
let mut color = None;
let mut i = 4;
while i < args.len() {
if args[i] == "--name" && i + 1 < args.len() {
name = Some(args[i + 1].as_str());
i += 2;
} else if args[i] == "--color" && i + 1 < args.len() {
color = Some(args[i + 1].as_str());
i += 2;
} else {
i += 1;
}
}
if let Some(n) = name {
match client.update_calendar(href, n, color).await {
Ok(_) => println!("Updated collection: {}", href),
Err(e) => {
eprintln!("Error updating collection: {}", e);
std::process::exit(1);
}
}
} else {
eprintln!("--name is required when editing");
std::process::exit(1);
}
}
_ => {
eprintln!("Unknown collection command");
std::process::exit(1);
}
}
return Ok(());
}
"delete" | "rm" => {
let partial_uid = args.get(2).cloned().unwrap_or_default();
if partial_uid.is_empty() {
eprintln!("{}", rust_i18n::t!("error_missing_uid"));
std::process::exit(1);
}
let store = build_store_cli(&ctx).await;
let full_uid = match resolve_uid(&store, &partial_uid) {
Some(uid) => uid,
None => std::process::exit(1),
};
let config =
cfait::config::Config::load_with_credentials(ctx.as_ref()).unwrap_or_default();
let store_arc = Arc::new(tokio::sync::Mutex::new(store));
let client_arc = Arc::new(tokio::sync::Mutex::new(None));
let controller =
cfait::controller::TaskController::new(store_arc, client_arc, ctx.clone());
let actions = {
let mut store_lock = controller.store.lock().await;
let intent = cfait::model::AppIntent::DeleteTask {
uid: full_uid.clone(),
};
store_lock.apply_task_intent(&intent, &config)
};
controller
.persist_changes(actions)
.await
.map_err(|e| anyhow::anyhow!(e))?;
println!("{}", rust_i18n::t!("task_deleted", uid = partial_uid));
if let Err(e) = maybe_sync(ctx.clone()).await {
eprintln!(
"{}",
rust_i18n::t!("warning_background_sync_failed", error = e.to_string())
);
}
return Ok(());
}
"" => {
}
_ => {
eprintln!(
"{}",
rust_i18n::t!("error_unknown_command", command = command)
);
std::process::exit(1);
}
}
#[cfg(not(target_os = "android"))]
let _ui_lock = cfait::storage::DaemonLock::acquire_shared(ctx.as_ref())
.map_err(|e| eprintln!("Warning: Could not acquire shared UI lock: {}", e))
.ok();
cfait::tui::run(ctx).await
}