use rusqlite::Connection;
use anyhow::Result;
use horae_core::repo::tasks;
use horae_core::time;
pub struct ModifyArgs {
pub id: String,
pub text: Vec<String>,
pub title: Option<String>,
pub tags: Vec<String>,
pub untags: Vec<String>,
pub clear_tags: bool,
pub high: bool,
pub medium: bool,
pub low: bool,
pub clear_priority: bool,
pub due: Option<String>,
pub clear_due: bool,
pub start: Option<String>,
pub end: Option<String>,
pub rrule: Option<String>,
pub clear_schedule: bool,
pub status: Option<String>,
pub notes: Option<String>,
pub edit_notes: bool,
pub json: bool,
}
fn is_clear(s: &str) -> bool {
s.eq_ignore_ascii_case("none") || s.eq_ignore_ascii_case("clear")
}
fn edit_notes_interactive(initial: &str) -> Result<String> {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
let temp_path = std::env::temp_dir().join(format!(
"horae_notes_{}_{}.md",
std::process::id(),
horae_core::time::now_ms()
));
std::fs::write(&temp_path, initial)?;
let status = std::process::Command::new("sh")
.arg("-c")
.arg(format!("{} \"{}\"", editor, temp_path.display()))
.status();
let new_content = std::fs::read_to_string(&temp_path).unwrap_or_default();
let _ = std::fs::remove_file(&temp_path);
if let Err(e) = status {
anyhow::bail!("failed to launch editor: {}", e);
}
Ok(new_content)
}
pub fn run(conn: &Connection, args: ModifyArgs) -> Result<()> {
let id = tasks::resolve_id(conn, &args.id)?;
let current = tasks::get(conn, &id)?;
let text_joined = args.text.join(" ");
let quick_add = if !text_joined.trim().is_empty() {
Some(horae_core::parser::parse_quick_add(&text_joined))
} else {
None
};
let mut input = tasks::ModifyInput::default();
if let Some(t) = args.title {
input.title = Some(t);
} else if let Some(ref qa) = quick_add {
if !qa.title.is_empty() {
input.title = Some(qa.title.clone());
}
}
if args.edit_notes {
let base = args.notes.as_deref().unwrap_or(¤t.notes);
let edited = edit_notes_interactive(base)?;
input.notes = Some(edited);
} else if let Some(n) = args.notes {
input.notes = Some(n);
}
if args.clear_due {
input.due_at = Some(None);
} else if let Some(d) = args.due {
if is_clear(&d) {
input.due_at = Some(None);
} else {
let ms = time::parse_time(&d)?;
input.due_at = Some(Some(ms));
}
}
if args.clear_schedule {
input.scheduled_start_at = Some(None);
input.scheduled_end_at = Some(None);
input.rrule = Some(None);
} else {
if let Some(s) = args.start {
if is_clear(&s) {
input.scheduled_start_at = Some(None);
} else {
let ms = time::parse_time(&s)?;
input.scheduled_start_at = Some(Some(ms));
}
} else if let Some(ref qa) = quick_add {
if let Some(ref ts) = qa.time_str {
let ms = time::parse_time(ts)?;
input.scheduled_start_at = Some(Some(ms));
}
}
if let Some(e) = args.end {
if is_clear(&e) {
input.scheduled_end_at = Some(None);
} else {
let ms = time::parse_time(&e)?;
input.scheduled_end_at = Some(Some(ms));
}
}
if let Some(r) = args.rrule {
if is_clear(&r) {
input.rrule = Some(None);
} else {
let normalized = horae_core::parser::parse_rrule_shorthand(&r);
crate::commands::capture::ensure_rrule_supported(&normalized)?;
input.rrule = Some(Some(normalized));
}
} else if let Some(ref qa) = quick_add {
if let Some(ref r) = qa.rrule {
crate::commands::capture::ensure_rrule_supported(r)?;
input.rrule = Some(Some(r.clone()));
}
}
}
if let Some(s) = args.status {
let st: horae_core::model::task::Status =
s.parse().map_err(|e| anyhow::anyhow!("{}", e))?;
input.status = Some(st);
} else if quick_add.as_ref().is_some_and(|qa| qa.time_str.is_some())
&& current.status == horae_core::model::task::Status::Inbox
&& input.scheduled_start_at.is_some()
{
input.status = Some(horae_core::model::task::Status::Scheduled);
}
input.clear_tags = args.clear_tags;
for t in args.tags {
if !input.add_tags.contains(&t) {
input.add_tags.push(t);
}
}
if let Some(ref qa) = quick_add {
for t in &qa.tags {
if !input.add_tags.contains(t) {
input.add_tags.push(t.clone());
}
}
}
for u in args.untags {
if !input.remove_tags.contains(&u) {
input.remove_tags.push(u);
}
}
if args.clear_priority {
input.priority = Some(None);
} else if args.high || args.medium || args.low {
let p = if args.high {
"high"
} else if args.medium {
"medium"
} else {
"low"
};
input.priority = Some(Some(p.to_string()));
} else if let Some(ref qa) = quick_add {
if let Some(ref p) = qa.priority {
input.priority = Some(Some(p.clone()));
}
}
let updated = tasks::modify(conn, &id, &input)?;
if args.json {
let tags = horae_core::repo::tags::get_task_tags(conn, &id)?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"task": updated,
"tags": tags,
}))?
);
} else {
let short_id = &updated.id[..updated.id.len().min(8)];
println!(
"modified [{}] {} (status: {})",
short_id, updated.title, updated.status
);
let tags = horae_core::repo::tags::get_task_tags(conn, &id).unwrap_or_default();
if !tags.is_empty() {
let tags_str = tags
.iter()
.map(|t| format!("@{}", t.name))
.collect::<Vec<_>>()
.join(" ");
println!(" tags: {}", tags_str);
}
if let Some(due) = updated.due_at {
println!(" due: {}", time::format_local(Some(due)));
}
if let Some(start) = updated.scheduled_start_at {
println!(" scheduled: {}", time::format_local(Some(start)));
}
if let Some(ref rr) = updated.rrule {
println!(" rrule: {}", rr);
}
if !updated.notes.trim().is_empty() {
let first_line = updated.notes.lines().next().unwrap_or("").trim();
if updated.notes.lines().count() > 1 {
println!(" notes: {}…", first_line);
} else {
println!(" notes: {}", first_line);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use horae_core::model::task::Status;
use horae_core::repo::tasks::CaptureInput;
use horae_core::testutil::test_conn;
fn mk_task(conn: &Connection, title: &str) -> horae_core::model::task::Task {
tasks::create_capture(
conn,
&CaptureInput {
title: title.into(),
status: Status::Inbox,
tag_names: vec!["initial_tag".into()],
..Default::default()
},
)
.unwrap()
}
#[test]
fn modify_via_quick_add_tokens() {
let (_dir, conn) = test_conn();
let t = mk_task(&conn, "original");
run(
&conn,
ModifyArgs {
id: t.id.clone(),
text: vec![
"renamed".into(),
"task".into(),
"@work".into(),
"~tomorrow 10:00".into(),
"!low".into(),
],
title: None,
tags: vec![],
untags: vec![],
clear_tags: false,
high: false,
medium: false,
low: false,
clear_priority: false,
due: None,
clear_due: false,
start: None,
end: None,
rrule: None,
clear_schedule: false,
status: None,
notes: None,
edit_notes: false,
json: false,
},
)
.unwrap();
let updated = tasks::get(&conn, &t.id).unwrap();
assert_eq!(updated.title, "renamed task");
assert_eq!(updated.status, Status::Scheduled);
assert!(updated.scheduled_start_at.is_some());
assert_eq!(updated.priority.as_deref(), Some("low"));
let tags: Vec<String> = horae_core::repo::tags::get_task_tags(&conn, &t.id)
.unwrap()
.into_iter()
.map(|tg| tg.name)
.collect();
assert!(tags.contains(&"work".to_string()));
assert!(tags.contains(&"initial_tag".to_string()));
}
#[test]
fn modify_via_explicit_flags_overrides_and_clears() {
let (_dir, conn) = test_conn();
let t = mk_task(&conn, "original");
run(
&conn,
ModifyArgs {
id: t.id.clone(),
text: vec![],
title: Some("explicit title".into()),
tags: vec!["extra".into()],
untags: vec!["initial_tag".into()],
clear_tags: false,
high: true,
medium: false,
low: false,
clear_priority: false,
due: Some("tomorrow 15:00".into()),
clear_due: false,
start: None,
end: None,
rrule: None,
clear_schedule: false,
status: Some("next".into()),
notes: Some("explicit notes".into()),
edit_notes: false,
json: false,
},
)
.unwrap();
let updated = tasks::get(&conn, &t.id).unwrap();
assert_eq!(updated.title, "explicit title");
assert_eq!(updated.notes, "explicit notes");
assert_eq!(updated.status, Status::Next);
assert!(updated.due_at.is_some());
assert_eq!(updated.priority.as_deref(), Some("high"));
let tags: Vec<String> = horae_core::repo::tags::get_task_tags(&conn, &t.id)
.unwrap()
.into_iter()
.map(|tg| tg.name)
.collect();
assert!(tags.contains(&"extra".to_string()));
assert!(!tags.contains(&"initial_tag".to_string()));
run(
&conn,
ModifyArgs {
id: t.id.clone(),
text: vec![],
title: None,
tags: vec![],
untags: vec![],
clear_tags: false,
high: false,
medium: false,
low: false,
clear_priority: true,
due: None,
clear_due: true,
start: None,
end: None,
rrule: None,
clear_schedule: false,
status: None,
notes: None,
edit_notes: false,
json: false,
},
)
.unwrap();
let updated2 = tasks::get(&conn, &t.id).unwrap();
assert_eq!(updated2.due_at, None);
assert_eq!(updated2.priority, None);
let tags2: Vec<String> = horae_core::repo::tags::get_task_tags(&conn, &t.id)
.unwrap()
.into_iter()
.map(|tg| tg.name)
.collect();
assert!(tags2.contains(&"extra".to_string()));
}
}