Add tag, prop, todo, and tree commands to ork

Four more friendly verbs, all pure compositions of the base 9P
operations against the existing namespace (no server change):

- tag <doc> <id> <tag> [--remove]: toggle a single tag, mirroring
  org-toggle-tag, instead of rewriting the whole tag list.
- prop <doc> <id> <key> [value] [--delete]: get, set, or delete one
  property, mirroring org-set-property, instead of the whole drawer.
- todo <doc> <id> [state]: set the keyword, or with no argument cycle
  to the next state following the /keywords sequence, like org-todo.
- tree <doc>: print the recursive section outline.

Each edit reads the relevant namespace file, modifies it, and writes it
back; all validate the section exists first for a clear error.
This commit is contained in:
Levi Neely 2026-09-30 21:40:39 +02:00
parent 32e4fd0007
commit 73c4eb6cff
1 changed files with 210 additions and 2 deletions

View File

@ -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<String>,
/// 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<String>,
},
/// 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 <doc> <id> <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<String> = 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 <doc> <id> <key> [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::<Vec<_>>()
.join("\n");
client.write(&path, body.as_bytes())?;
Ok(())
}
/// `todo <doc> <id> [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<String> = 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 <doc>` — 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(())