diff --git a/crates/ork-cli/src/main.rs b/crates/ork-cli/src/main.rs index 22f619f..d24ca4c 100644 --- a/crates/ork-cli/src/main.rs +++ b/crates/ork-cli/src/main.rs @@ -3,8 +3,9 @@ //! `ork` is only a 9P client: it connects to a running `ork-server` and //! operates on its namespace. All org-mode intelligence lives in the server. //! The base verbs (ls/read/write/rdwr) speak raw 9P; the friendly verbs -//! (docs/sections/todos/agenda/get/set/add/mkdoc/reload) are thin compositions -//! of those same 9P operations against the exposed namespace. +//! (docs/sections/todos/agenda/get/set/add/mkdoc/reload/tag/prop/todo/tree) +//! are thin compositions of those same 9P operations against the exposed +//! namespace. //! //! Address resolution: `--addr` flag, then `$ORK_ADDR`, then //! `tcp://localhost:5640`. Accepts `tcp://host:port` or `unix:///path/to/sock`. @@ -127,6 +128,50 @@ enum Commands { /// Ask the server to rescan org files from disk Reload, + + /// Add or remove a single tag on a section + Tag { + /// Document name + doc: String, + /// Section id + id: String, + /// Tag to toggle on + tag: String, + /// Remove the tag instead of adding it + #[arg(long, short)] + remove: bool, + }, + + /// Get, set, or delete a single property on a section + Prop { + /// Document name + doc: String, + /// Section id + id: String, + /// Property key + key: String, + /// New value; omit to show the current value, use --delete to remove + value: Option, + /// Delete the property + #[arg(long, short)] + delete: bool, + }, + + /// Set or cycle a section's TODO keyword + Todo { + /// Document name + doc: String, + /// Section id + id: String, + /// New keyword; omit to cycle to the next state + state: Option, + }, + + /// Print a document's section tree + Tree { + /// Document name + doc: String, + }, } /// Section fields that may be written via `set`. @@ -144,6 +189,12 @@ const GET_FIELDS: &[&str] = &[ /// Entries under a document dir that are not sections. const DOC_FILES: &[&str] = &["raw", "body", "meta", "new"]; +/// Field files under a section dir (everything that is not a child section). +const SECTION_FILES: &[&str] = &[ + "headline", "title", "keyword", "priority", "tags", "level", + "scheduled", "deadline", "closed", "properties", "body", "new", +]; + /// Resolve the server address: explicit --addr, then $ORK_ADDR, then default. fn resolve_addr(addr: Option<&str>) -> String { addr.map(str::to_string) @@ -301,6 +352,153 @@ fn cmd_reload(client: &mut Client) -> io::Result<()> { Ok(()) } +/// Confirm a section exists (used to give a clear error before editing). +fn require_section(client: &mut Client, doc: &str, id: &str) -> io::Result<()> { + read_str(client, &format!("/{}/{}/headline", doc, id)) + .map(|_| ()) + .map_err(|_| io::Error::new(io::ErrorKind::NotFound, + format!("section '{}' not found in '{}'", id, doc))) +} + +/// `tag [--remove]` — toggle one tag; tags file is colon-joined. +fn cmd_tag(client: &mut Client, doc: &str, id: &str, tag: &str, remove: bool) -> io::Result<()> { + require_section(client, doc, id)?; + let path = format!("/{}/{}/tags", doc, id); + let current = read_str(client, &path)?; + let mut tags: Vec = current + .split(':') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + if remove { + tags.retain(|t| t != tag); + } else if !tags.iter().any(|t| t == tag) { + tags.push(tag.to_string()); + } + client.write(&path, tags.join(":").as_bytes())?; + Ok(()) +} + +/// `prop [value] [--delete]` — get/set/delete one property. +/// The properties file is a set of KEY=value lines. +fn cmd_prop( + client: &mut Client, + doc: &str, + id: &str, + key: &str, + value: Option<&str>, + delete: bool, +) -> io::Result<()> { + require_section(client, doc, id)?; + let path = format!("/{}/{}/properties", doc, id); + let current = read_str(client, &path)?; + let mut props: Vec<(String, String)> = current + .lines() + .filter_map(|l| l.split_once('=')) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + match (value, delete) { + (None, false) => { + // Show the current value. + match props.iter().find(|(k, _)| k == key) { + Some((_, v)) => { + let mut out = io::stdout().lock(); + writeln!(out, "{}", v)?; + } + None => { + return Err(io::Error::new(io::ErrorKind::NotFound, + format!("property '{}' not set", key))); + } + } + return Ok(()); + } + (_, true) => { + props.retain(|(k, _)| k != key); + } + (Some(v), false) => { + if let Some(entry) = props.iter_mut().find(|(k, _)| k == key) { + entry.1 = v.to_string(); + } else { + props.push((key.to_string(), v.to_string())); + } + } + } + + let body: String = props + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join("\n"); + client.write(&path, body.as_bytes())?; + Ok(()) +} + +/// `todo [state]` — set the keyword, or cycle to the next state. +/// Cycling follows the sequence in /keywords ("TODO NEXT ... | DONE ..."). +fn cmd_todo(client: &mut Client, doc: &str, id: &str, state: Option<&str>) -> io::Result<()> { + require_section(client, doc, id)?; + let kw_path = format!("/{}/{}/keyword", doc, id); + let next = match state { + Some(s) => s.to_string(), + None => { + // Build the ordered keyword list, dropping the "|" separator. + let spec = read_str(client, "/keywords")?; + let seq: Vec = spec + .split_whitespace() + .filter(|w| *w != "|") + .map(str::to_string) + .collect(); + if seq.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "no keywords configured")); + } + let current = read_str(client, &kw_path)?; + // Advance to the next state, wrapping to the first (and from empty). + match seq.iter().position(|k| *k == current) { + Some(i) => seq[(i + 1) % seq.len()].clone(), + None => seq[0].clone(), + } + } + }; + client.write(&kw_path, next.as_bytes())?; + let mut out = io::stdout().lock(); + writeln!(out, "{}", next)?; + Ok(()) +} + +/// `tree ` — print the document's section outline, recursing into children. +fn cmd_tree(client: &mut Client, doc: &str) -> io::Result<()> { + let mut out = io::stdout().lock(); + print_tree(client, doc, &format!("/{}", doc), 0, &mut out) +} + +/// Recursively print sections under `base`, indenting by `depth`. +fn print_tree( + client: &mut Client, + doc: &str, + base: &str, + depth: usize, + out: &mut impl Write, +) -> io::Result<()> { + let entries = client.list(base)?; + // Section subdirs are entries that are not this node's own files. + let skip: &[&str] = if depth == 0 { + DOC_FILES + } else { + SECTION_FILES + }; + for entry in entries { + if skip.contains(&entry.as_str()) { + continue; + } + let child = format!("{}/{}", base, entry); + let headline = read_str(client, &format!("{}/headline", child)).unwrap_or_default(); + writeln!(out, "{}{}", " ".repeat(depth), headline)?; + print_tree(client, doc, &child, depth + 1, out)?; + } + Ok(()) +} + fn run(cli: Cli) -> io::Result<()> { let addr = resolve_addr(cli.addr.as_deref()); let mut client = Client::connect(&addr) @@ -345,6 +543,16 @@ fn run(cli: Cli) -> io::Result<()> { } Commands::Mkdoc { name } => cmd_mkdoc(&mut client, &name)?, Commands::Reload => cmd_reload(&mut client)?, + Commands::Tag { doc, id, tag, remove } => { + cmd_tag(&mut client, &doc, &id, &tag, remove)? + } + Commands::Prop { doc, id, key, value, delete } => { + cmd_prop(&mut client, &doc, &id, &key, value.as_deref(), delete)? + } + Commands::Todo { doc, id, state } => { + cmd_todo(&mut client, &doc, &id, state.as_deref())? + } + Commands::Tree { doc } => cmd_tree(&mut client, &doc)?, } Ok(())