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:
parent
32e4fd0007
commit
73c4eb6cff
|
|
@ -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(())
|
||||
|
|
|
|||
Loading…
Reference in New Issue