generated from eblume/project-template
Editing a task's canonical-context doc body previously meant looking up its `canonical_context_id` (e.g. via `heph list --json`) and then `heph node update <doc-id> --body`. Add a `heph context <task-id>` command that resolves the canonical-context doc from the task's outgoing links and: * prints the body with no flag, * `--body <text>` replaces it (`-` reads stdin, matching `node update`), * `--append <text>` adds a blank-line-separated paragraph. Errors clearly when the id has no canonical-context doc (e.g. a plain doc node rather than a task). Purely a client-side CLI convenience — no new RPC. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
937 lines
32 KiB
Rust
937 lines
32 KiB
Rust
//! `heph` — the CLI surface (tech-spec §1). A thin client of the local
|
|
//! `hephd`: it never touches SQLite, only the daemon socket.
|
|
//!
|
|
//! Per the three-surface model ([[design]] §4), the CLI is the **task
|
|
//! capture/scripting** surface and exposes the **complete daemon API** — every
|
|
//! RPC method has a command. Structured task fields are flags, with human dates
|
|
//! (`--do tomorrow`) and recurrence (`--recur weekly`) parsed in [`datespec`].
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
use clap::{Parser, Subcommand};
|
|
use serde_json::{json, Value};
|
|
|
|
use heph_core::{Node, RankedTask, Task};
|
|
use hephd::{datespec, default_socket_path, Client, DeviceFlow, KeyringTokenStore, TokenStore};
|
|
|
|
mod service;
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(name = "heph", version = heph_core::VERSION, about)]
|
|
struct Cli {
|
|
/// Path to the hephd unix socket (defaults to the standard runtime path).
|
|
#[arg(long, global = true)]
|
|
socket: Option<PathBuf>,
|
|
|
|
#[command(subcommand)]
|
|
command: Command,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum Command {
|
|
/// Show the Tactical "what is next?" ranking.
|
|
Next {
|
|
/// Restrict to a project node id.
|
|
#[arg(long)]
|
|
scope: Option<String>,
|
|
/// Maximum rows (red items always show).
|
|
#[arg(long, default_value_t = 5)]
|
|
limit: usize,
|
|
},
|
|
/// Create a committed task (auto-creates its canonical context doc).
|
|
Task {
|
|
/// The task title.
|
|
title: String,
|
|
/// Attention-state: white|orange|red|blue.
|
|
#[arg(short = 'a', long)]
|
|
attention: Option<String>,
|
|
/// Do-date (earliest-actionable): today|tomorrow|+3d|fri|YYYY-MM-DD.
|
|
#[arg(long)]
|
|
do_date: Option<String>,
|
|
/// Late-on (the sole urgency marker): same date forms as --do-date.
|
|
#[arg(long)]
|
|
late_on: Option<String>,
|
|
/// Project name to file it under (must already exist).
|
|
#[arg(long)]
|
|
project: Option<String>,
|
|
/// Recurrence: a preset (daily|weekly|monthly|yearly|weekdays) or NL
|
|
/// ("every 3 days", "every fri").
|
|
#[arg(long)]
|
|
recur: Option<String>,
|
|
/// A raw RFC-5545 RRULE (overrides --recur).
|
|
#[arg(long)]
|
|
rrule: Option<String>,
|
|
},
|
|
/// Enumerate the outstanding set (the Organizational survey).
|
|
List {
|
|
/// Restrict to a project node id.
|
|
#[arg(long)]
|
|
scope: Option<String>,
|
|
/// Restrict to a project by NAME (subtree-expanded). e.g. --project Hephaestus.
|
|
#[arg(long)]
|
|
project: Option<String>,
|
|
/// Only this attention-state: white|orange|red|blue.
|
|
#[arg(short = 'a', long)]
|
|
attention: Option<String>,
|
|
/// Hide on-deck (blue) items.
|
|
#[arg(long)]
|
|
no_blue: bool,
|
|
/// Print raw JSON rows (node id, canonical-context id, scalars) for scripting/agents.
|
|
#[arg(long)]
|
|
json: bool,
|
|
},
|
|
/// Run a built-in filter view (tech-spec §8.2); omit the name to list views.
|
|
View {
|
|
/// View name: tom|ondeck|chores|work|tasks. Omit to list all views.
|
|
name: Option<String>,
|
|
},
|
|
/// Mark a task done (recurring tasks roll forward).
|
|
Done {
|
|
/// Task node id.
|
|
id: String,
|
|
},
|
|
/// Mark a task dropped.
|
|
Drop {
|
|
/// Task node id.
|
|
id: String,
|
|
},
|
|
/// Skip a recurring task's current occurrence (advance without logging).
|
|
Skip {
|
|
/// Task node id.
|
|
id: String,
|
|
},
|
|
/// Set a task's attention-state.
|
|
Attention {
|
|
/// Task node id.
|
|
id: String,
|
|
/// white|orange|red|blue.
|
|
attention: String,
|
|
},
|
|
/// Reschedule a task: change do-date / late-on / recurrence (use `none` to
|
|
/// clear a field; omit to leave it unchanged). Also re-attentions / re-files.
|
|
Edit {
|
|
/// Task node id.
|
|
id: String,
|
|
/// Do-date or `none`.
|
|
#[arg(long)]
|
|
do_date: Option<String>,
|
|
/// Late-on or `none`.
|
|
#[arg(long)]
|
|
late_on: Option<String>,
|
|
/// Recurrence (preset/NL) or `none`.
|
|
#[arg(long)]
|
|
recur: Option<String>,
|
|
/// A raw RRULE or `none`.
|
|
#[arg(long)]
|
|
rrule: Option<String>,
|
|
/// Set attention: white|orange|red|blue.
|
|
#[arg(short = 'a', long)]
|
|
attention: Option<String>,
|
|
/// Re-file under a project (by name); `none` unfiles the task.
|
|
#[arg(long)]
|
|
project: Option<String>,
|
|
},
|
|
/// Promote a context-item line into a committed task.
|
|
Promote {
|
|
/// The container node id (the doc holding the `- [ ]` line).
|
|
container_id: String,
|
|
/// 1-based index of the context item to promote (document order).
|
|
item_ref: usize,
|
|
/// Attention for the new task: white|orange|red|blue.
|
|
#[arg(short = 'a', long)]
|
|
attention: Option<String>,
|
|
/// Project name to file the new task under.
|
|
#[arg(long)]
|
|
project: Option<String>,
|
|
},
|
|
/// Show a node (and, if it is a task, its scalars).
|
|
Show {
|
|
/// Node id.
|
|
id: String,
|
|
},
|
|
/// Append to a task's log (with text) or print its latest entries (without).
|
|
Log {
|
|
/// Task node id.
|
|
id: String,
|
|
/// Text to append; if omitted, the latest entries are printed.
|
|
text: Vec<String>,
|
|
/// How many entries to print (read mode).
|
|
#[arg(short = 'n', long, default_value_t = 10)]
|
|
n: usize,
|
|
},
|
|
/// Read or edit a task's canonical-context doc body **by task id** — no
|
|
/// manual `canonical_context_id` lookup. With neither flag, prints the body.
|
|
Context {
|
|
/// Task node id.
|
|
id: String,
|
|
/// Replace the body with this text (`-` reads from stdin).
|
|
#[arg(long)]
|
|
body: Option<String>,
|
|
/// Append this text to the body (separated by a blank line).
|
|
#[arg(long)]
|
|
append: Option<String>,
|
|
},
|
|
/// Working-set health — the §6.2 tensions (orange vs 6, active vs ~30, …).
|
|
Health,
|
|
/// Create a document node.
|
|
Doc {
|
|
/// The document title.
|
|
title: String,
|
|
/// Markdown body.
|
|
#[arg(long)]
|
|
body: Option<String>,
|
|
},
|
|
/// Node operations (update, tombstone).
|
|
Node {
|
|
#[command(subcommand)]
|
|
action: NodeAction,
|
|
},
|
|
/// Fetch a node by id and print it as JSON.
|
|
Get {
|
|
/// Node id.
|
|
id: String,
|
|
},
|
|
/// Resolve a wiki-link title to a node (exact, alias-then-title).
|
|
Resolve {
|
|
/// The title or alias.
|
|
title: String,
|
|
},
|
|
/// Full-text search over titles and bodies.
|
|
Search {
|
|
/// FTS5 query.
|
|
query: String,
|
|
},
|
|
/// Open (or create) the journal for an ISO date (YYYY-MM-DD).
|
|
Journal {
|
|
/// The ISO date.
|
|
date: String,
|
|
},
|
|
/// List a node's outgoing links.
|
|
Links {
|
|
/// Node id.
|
|
id: String,
|
|
},
|
|
/// List a node's backlinks.
|
|
Backlinks {
|
|
/// Node id.
|
|
id: String,
|
|
},
|
|
/// Link operations (add).
|
|
Link {
|
|
#[command(subcommand)]
|
|
action: LinkAction,
|
|
},
|
|
/// Project operations (add).
|
|
Project {
|
|
#[command(subcommand)]
|
|
action: ProjectAction,
|
|
},
|
|
/// Tag operations: add/remove a tag on a node, or list tags.
|
|
Tag {
|
|
#[command(subcommand)]
|
|
action: TagAction,
|
|
},
|
|
/// Force a sync cycle (or show sync status with --status).
|
|
Sync {
|
|
/// Show status instead of syncing.
|
|
#[arg(long)]
|
|
status: bool,
|
|
},
|
|
/// List merge conflicts, or resolve one.
|
|
Conflicts {
|
|
#[command(subcommand)]
|
|
action: Option<ConflictAction>,
|
|
},
|
|
/// Export the store to a directory tree of .md files.
|
|
Export {
|
|
/// Destination directory (created if needed).
|
|
dir: PathBuf,
|
|
},
|
|
/// One-time: rewrite legacy `[[Name]]` body links to the canonical
|
|
/// `[[node-id]]` form (tech-spec §8.4). Idempotent.
|
|
MigrateLinks,
|
|
/// Manage the hephd daemon as an OS service (launchd / systemd).
|
|
Daemon {
|
|
#[command(subcommand)]
|
|
action: service::DaemonAction,
|
|
},
|
|
/// Authenticate this device with a sync hub (OAuth 2.0 device-code flow).
|
|
Auth {
|
|
#[command(subcommand)]
|
|
action: AuthAction,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum NodeAction {
|
|
/// Update a node's title and/or body.
|
|
Update {
|
|
/// Node id.
|
|
id: String,
|
|
/// New title.
|
|
#[arg(long)]
|
|
title: Option<String>,
|
|
/// New markdown body (or `-` to read the body from stdin).
|
|
#[arg(long)]
|
|
body: Option<String>,
|
|
},
|
|
/// Tombstone (soft-delete) a node.
|
|
Rm {
|
|
/// Node id.
|
|
id: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum LinkAction {
|
|
/// Add a typed link between two nodes.
|
|
Add {
|
|
/// Source node id.
|
|
src: String,
|
|
/// Destination node id.
|
|
dst: String,
|
|
/// Link type: blocks|parent|tagged|in-project|context-of|…
|
|
link_type: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum ProjectAction {
|
|
/// Create a project node (optionally under a parent project).
|
|
Add {
|
|
/// Project name.
|
|
name: String,
|
|
/// Parent project name (creates a `parent` link).
|
|
#[arg(long)]
|
|
parent: Option<String>,
|
|
},
|
|
/// List all projects.
|
|
List,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum TagAction {
|
|
/// Tag a node.
|
|
Add {
|
|
/// Node id to tag.
|
|
node: String,
|
|
/// Tag name.
|
|
tag: String,
|
|
},
|
|
/// Remove a tag from a node.
|
|
Rm {
|
|
/// Node id.
|
|
node: String,
|
|
/// Tag name.
|
|
tag: String,
|
|
},
|
|
/// List a node's tags, or every tag in the store (with no node).
|
|
List {
|
|
/// Node id (omit to list all tags).
|
|
node: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum ConflictAction {
|
|
/// Resolve a conflict by choosing the local or remote value.
|
|
Resolve {
|
|
/// Conflict id.
|
|
id: String,
|
|
/// `local` or `remote`.
|
|
choice: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum AuthAction {
|
|
/// Log in via the device-code flow; caches the bearer token for hub sync.
|
|
Login {
|
|
/// Hub/server URL this token is for (keys the credential store entry).
|
|
#[arg(long)]
|
|
hub_url: String,
|
|
/// OIDC issuer, e.g. https://authentik.ops.eblu.me/application/o/heph/.
|
|
#[arg(long)]
|
|
issuer: String,
|
|
/// OIDC client id this device authenticates as.
|
|
#[arg(long)]
|
|
client_id: String,
|
|
/// Scopes to request (`offline_access` yields a refresh token).
|
|
#[arg(long, default_value = "openid offline_access")]
|
|
scope: String,
|
|
},
|
|
/// Forget the cached token for a hub.
|
|
Logout {
|
|
/// Hub/server URL whose cached token to remove.
|
|
#[arg(long)]
|
|
hub_url: String,
|
|
},
|
|
}
|
|
|
|
/// Run the device-code flow (or clear a token) — no daemon needed.
|
|
fn run_auth(action: AuthAction) -> Result<()> {
|
|
match action {
|
|
AuthAction::Login {
|
|
hub_url,
|
|
issuer,
|
|
client_id,
|
|
scope,
|
|
} => {
|
|
let flow = DeviceFlow::discover(&issuer, &client_id)?;
|
|
let auth = flow.start(&scope)?;
|
|
let uri = auth
|
|
.verification_uri_complete
|
|
.as_deref()
|
|
.unwrap_or(&auth.verification_uri);
|
|
println!(
|
|
"To authorize hephaestus, visit:\n {uri}\nand enter code: {}\n\nWaiting…",
|
|
auth.user_code
|
|
);
|
|
let token = flow.poll(&auth, std::thread::sleep)?;
|
|
KeyringTokenStore::new(hub_url.as_str()).save(&token)?;
|
|
println!("Logged in. Token cached for {hub_url}.");
|
|
}
|
|
AuthAction::Logout { hub_url } => {
|
|
KeyringTokenStore::new(hub_url.as_str()).clear()?;
|
|
println!("Logged out of {hub_url}.");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn main() -> Result<()> {
|
|
let cli = Cli::parse();
|
|
|
|
// `daemon` manages the OS service; it must not connect to a daemon.
|
|
if let Command::Daemon { action } = &cli.command {
|
|
return service::run(action);
|
|
}
|
|
// `auth` runs locally (device-code flow + keyring); it needs no daemon.
|
|
if let Command::Auth { action } = cli.command {
|
|
return run_auth(action);
|
|
}
|
|
|
|
let socket = cli.socket.unwrap_or_else(default_socket_path);
|
|
let mut client = Client::connect(&socket)?;
|
|
|
|
match cli.command {
|
|
Command::Next { scope, limit } => {
|
|
let result = client.call("next", json!({ "scope": scope, "limit": limit }))?;
|
|
print_rows(result)?;
|
|
}
|
|
Command::Task {
|
|
title,
|
|
attention,
|
|
do_date,
|
|
late_on,
|
|
project,
|
|
recur,
|
|
rrule,
|
|
} => {
|
|
let recurrence = recurrence_value(recur.as_deref(), rrule.as_deref())?;
|
|
let project_id = resolve_project(&mut client, project.as_deref())?;
|
|
let result = client.call(
|
|
"task.create",
|
|
json!({
|
|
"title": title,
|
|
"attention": attention,
|
|
"do_date": opt_date_ms(do_date.as_deref())?,
|
|
"late_on": opt_date_ms(late_on.as_deref())?,
|
|
"project_id": project_id,
|
|
"recurrence": recurrence,
|
|
}),
|
|
)?;
|
|
let task: Task = serde_json::from_value(result)?;
|
|
println!("Created task {} \"{title}\"", task.node_id);
|
|
}
|
|
Command::List {
|
|
scope,
|
|
project,
|
|
attention,
|
|
no_blue,
|
|
json,
|
|
} => {
|
|
// `list` takes a ListFilter (tech-spec §8.2). Map the flags: a single
|
|
// `--scope` id or `--project` NAME (resolved + subtree-expanded by the
|
|
// daemon), a single `--attention` whitelist, and `--no-blue`.
|
|
let mut filter = json!({});
|
|
if let Some(s) = scope {
|
|
filter["scope"] = json!([s]);
|
|
}
|
|
if let Some(p) = project {
|
|
filter["project"] = json!(p);
|
|
}
|
|
if let Some(a) = attention {
|
|
filter["attention_in"] = json!([a]);
|
|
}
|
|
if no_blue {
|
|
filter["attention_not"] = json!(["blue"]);
|
|
}
|
|
let result = client.call("list", filter)?;
|
|
if json {
|
|
println!("{}", serde_json::to_string_pretty(&result)?);
|
|
} else {
|
|
print_rows(result)?;
|
|
}
|
|
}
|
|
Command::View { name } => match name {
|
|
Some(name) => {
|
|
let result = client.call("view", json!({ "name": name }))?;
|
|
print_rows(result)?;
|
|
}
|
|
None => {
|
|
println!("Available views (heph view <name>):");
|
|
for v in heph_core::BUILTIN_VIEWS {
|
|
println!(" {:<8} {}", v.name, v.title);
|
|
}
|
|
}
|
|
},
|
|
Command::Done { id } => {
|
|
set_state(&mut client, &id, "done")?;
|
|
}
|
|
Command::Drop { id } => {
|
|
set_state(&mut client, &id, "dropped")?;
|
|
}
|
|
Command::Skip { id } => {
|
|
client.call("task.skip", json!({ "id": id }))?;
|
|
println!("Skipped occurrence of {id}");
|
|
}
|
|
Command::Attention { id, attention } => {
|
|
client.call(
|
|
"task.set_attention",
|
|
json!({ "id": id, "attention": attention }),
|
|
)?;
|
|
println!("{id} attention → {attention}");
|
|
}
|
|
Command::Edit {
|
|
id,
|
|
do_date,
|
|
late_on,
|
|
recur,
|
|
rrule,
|
|
attention,
|
|
project,
|
|
} => {
|
|
let mut patch = serde_json::Map::new();
|
|
patch.insert("id".into(), json!(id));
|
|
if let Some(v) = sched_date(do_date.as_deref())? {
|
|
patch.insert("do_date".into(), v);
|
|
}
|
|
if let Some(v) = sched_date(late_on.as_deref())? {
|
|
patch.insert("late_on".into(), v);
|
|
}
|
|
let recur_spec = recur.or(rrule);
|
|
if let Some(v) = sched_recurrence(recur_spec.as_deref())? {
|
|
patch.insert("recurrence".into(), v);
|
|
}
|
|
if patch.len() > 1 {
|
|
client.call("task.set_schedule", Value::Object(patch))?;
|
|
}
|
|
if let Some(a) = attention {
|
|
client.call("task.set_attention", json!({ "id": id, "attention": a }))?;
|
|
}
|
|
if let Some(spec) = project.as_deref() {
|
|
// Re-file (or unfile with `none`) via the move-to-project path,
|
|
// which tombstones the old `in-project` link rather than piling
|
|
// a second one on top of it.
|
|
let project_id = match spec {
|
|
"none" | "clear" => None,
|
|
name => resolve_project(&mut client, Some(name))?,
|
|
};
|
|
client.call(
|
|
"task.set_project",
|
|
json!({ "id": id, "project_id": project_id }),
|
|
)?;
|
|
}
|
|
println!("Edited task {id}");
|
|
}
|
|
Command::Promote {
|
|
container_id,
|
|
item_ref,
|
|
attention,
|
|
project,
|
|
} => {
|
|
let project_id = resolve_project(&mut client, project.as_deref())?;
|
|
let result = client.call(
|
|
"task.promote",
|
|
json!({
|
|
"container_id": container_id,
|
|
"item_ref": item_ref,
|
|
"attention": attention,
|
|
"project": project_id,
|
|
}),
|
|
)?;
|
|
let task: Task = serde_json::from_value(result)?;
|
|
println!("Promoted item {item_ref} → task {}", task.node_id);
|
|
}
|
|
Command::Show { id } => {
|
|
let node = client.call("node.get", json!({ "id": id }))?;
|
|
println!("{}", serde_json::to_string_pretty(&node)?);
|
|
if node.get("kind").and_then(Value::as_str) == Some("task") {
|
|
let task = client.call("task.get", json!({ "id": id }))?;
|
|
println!("task: {}", serde_json::to_string_pretty(&task)?);
|
|
}
|
|
}
|
|
Command::Log { id, text, n } => {
|
|
if text.is_empty() {
|
|
let entries = client.call("log.tail", json!({ "task_id": id, "n": n }))?;
|
|
let entries: Vec<String> = serde_json::from_value(entries)?;
|
|
if entries.is_empty() {
|
|
println!("(no log entries)");
|
|
}
|
|
for e in &entries {
|
|
println!("{e}");
|
|
}
|
|
} else {
|
|
let line = text.join(" ");
|
|
client.call("log.append", json!({ "task_id": id, "text": line }))?;
|
|
println!("Logged to {id}");
|
|
}
|
|
}
|
|
Command::Context { id, body, append } => {
|
|
let doc_id = canonical_context_id(&mut client, &id)?;
|
|
match (body, append) {
|
|
(Some(_), Some(_)) => bail!("pass only one of --body / --append"),
|
|
(Some(body), None) => {
|
|
let body = read_body_arg(Some(body))?;
|
|
client.call("node.update", json!({ "id": doc_id, "body": body }))?;
|
|
println!("Set context of {id}");
|
|
}
|
|
(None, Some(text)) => {
|
|
let current = context_body(&mut client, &doc_id)?;
|
|
let combined = if current.trim().is_empty() {
|
|
text
|
|
} else {
|
|
format!("{}\n\n{text}", current.trim_end())
|
|
};
|
|
client.call("node.update", json!({ "id": doc_id, "body": combined }))?;
|
|
println!("Appended to context of {id}");
|
|
}
|
|
(None, None) => {
|
|
let current = context_body(&mut client, &doc_id)?;
|
|
if current.trim().is_empty() {
|
|
println!("(empty context)");
|
|
} else {
|
|
println!("{}", current.trim_end());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Command::Health => {
|
|
let h = client.call("health", json!({}))?;
|
|
println!(
|
|
"orange {} active {} on-deck {} conflicts {} sync {}",
|
|
num(&h, "orange_count"),
|
|
num(&h, "active_count"),
|
|
num(&h, "on_deck_count"),
|
|
num(&h, "conflict_count"),
|
|
h.get("sync_status").and_then(Value::as_str).unwrap_or("?"),
|
|
);
|
|
}
|
|
Command::Doc { title, body } => {
|
|
let result = client.call(
|
|
"node.create",
|
|
json!({ "kind": "doc", "title": title, "body": body }),
|
|
)?;
|
|
let node: Node = serde_json::from_value(result)?;
|
|
println!("Created doc {} \"{}\"", node.id, node.title);
|
|
}
|
|
Command::Node { action } => match action {
|
|
NodeAction::Update { id, title, body } => {
|
|
let body = read_body_arg(body)?;
|
|
let result = client.call(
|
|
"node.update",
|
|
json!({ "id": id, "title": title, "body": body }),
|
|
)?;
|
|
let node: Node = serde_json::from_value(result)?;
|
|
println!("Updated {} \"{}\"", node.id, node.title);
|
|
}
|
|
NodeAction::Rm { id } => {
|
|
client.call("node.tombstone", json!({ "id": id }))?;
|
|
println!("Tombstoned {id}");
|
|
}
|
|
},
|
|
Command::Get { id } => {
|
|
let result = client.call("node.get", json!({ "id": id }))?;
|
|
println!("{}", serde_json::to_string_pretty(&result)?);
|
|
}
|
|
Command::Resolve { title } => {
|
|
let result = client.call("node.resolve", json!({ "title": title }))?;
|
|
if result.is_null() {
|
|
println!("(no match)");
|
|
} else {
|
|
let node: Node = serde_json::from_value(result)?;
|
|
println!("{} [{}] {}", node.id, node.kind.as_str(), node.title);
|
|
}
|
|
}
|
|
Command::Search { query } => {
|
|
let result = client.call("search", json!({ "query": query }))?;
|
|
let nodes: Vec<Node> = serde_json::from_value(result)?;
|
|
if nodes.is_empty() {
|
|
println!("No matches.");
|
|
}
|
|
for n in &nodes {
|
|
println!("{} [{}] {}", n.id, n.kind.as_str(), n.title);
|
|
}
|
|
}
|
|
Command::Journal { date } => {
|
|
let result = client.call("journal.open_or_create", json!({ "date": date }))?;
|
|
let node: Node = serde_json::from_value(result)?;
|
|
println!("Journal {} ({})", node.title, node.id);
|
|
}
|
|
Command::Links { id } => {
|
|
print_links(client.call("links.outgoing", json!({ "id": id }))?);
|
|
}
|
|
Command::Backlinks { id } => {
|
|
print_links(client.call("links.backlinks", json!({ "id": id }))?);
|
|
}
|
|
Command::Link { action } => match action {
|
|
LinkAction::Add {
|
|
src,
|
|
dst,
|
|
link_type,
|
|
} => {
|
|
client.call(
|
|
"links.add",
|
|
json!({ "src": src, "dst": dst, "link_type": link_type }),
|
|
)?;
|
|
println!("Linked {src} -[{link_type}]-> {dst}");
|
|
}
|
|
},
|
|
Command::Project { action } => match action {
|
|
ProjectAction::Add { name, parent } => {
|
|
let result =
|
|
client.call("node.create", json!({ "kind": "project", "title": name }))?;
|
|
let node: Node = serde_json::from_value(result)?;
|
|
if let Some(parent) = parent {
|
|
let pid = resolve_project(&mut client, Some(&parent))?
|
|
.context("parent project not found")?;
|
|
client.call(
|
|
"links.add",
|
|
json!({ "src": node.id, "dst": pid, "link_type": "parent" }),
|
|
)?;
|
|
}
|
|
println!("Created project {} \"{}\"", node.id, node.title);
|
|
}
|
|
ProjectAction::List => {
|
|
let result = client.call("node.list", json!({ "kind": "project" }))?;
|
|
let nodes: Vec<Node> = serde_json::from_value(result)?;
|
|
if nodes.is_empty() {
|
|
println!("No projects.");
|
|
}
|
|
for n in &nodes {
|
|
println!("{} {}", n.id, n.title);
|
|
}
|
|
}
|
|
},
|
|
Command::Tag { action } => match action {
|
|
TagAction::Add { node, tag } => {
|
|
let result = client.call("tag.add", json!({ "node_id": node, "tag": tag }))?;
|
|
let t: Node = serde_json::from_value(result)?;
|
|
println!("{node} tagged #{}", t.title);
|
|
}
|
|
TagAction::Rm { node, tag } => {
|
|
client.call("tag.remove", json!({ "node_id": node, "tag": tag }))?;
|
|
println!("{node} untagged #{tag}");
|
|
}
|
|
TagAction::List { node } => {
|
|
let tags: Vec<String> = match node {
|
|
Some(node) => {
|
|
let result = client.call("tag.list", json!({ "node_id": node }))?;
|
|
serde_json::from_value(result)?
|
|
}
|
|
None => {
|
|
let result = client.call("node.list", json!({ "kind": "tag" }))?;
|
|
let nodes: Vec<Node> = serde_json::from_value(result)?;
|
|
nodes.into_iter().map(|n| n.title).collect()
|
|
}
|
|
};
|
|
if tags.is_empty() {
|
|
println!("(no tags)");
|
|
}
|
|
for t in &tags {
|
|
println!("#{t}");
|
|
}
|
|
}
|
|
},
|
|
Command::Sync { status } => {
|
|
let method = if status { "sync.status" } else { "sync.now" };
|
|
let result = client.call(method, json!({}))?;
|
|
println!("{}", serde_json::to_string_pretty(&result)?);
|
|
}
|
|
Command::Conflicts { action } => match action {
|
|
None => {
|
|
let result = client.call("conflicts.list", json!({}))?;
|
|
let arr = result.as_array().cloned().unwrap_or_default();
|
|
if arr.is_empty() {
|
|
println!("No conflicts.");
|
|
}
|
|
for c in &arr {
|
|
println!("{}", serde_json::to_string(c)?);
|
|
}
|
|
}
|
|
Some(ConflictAction::Resolve { id, choice }) => {
|
|
client.call("conflicts.resolve", json!({ "id": id, "choice": choice }))?;
|
|
println!("Resolved {id} → {choice}");
|
|
}
|
|
},
|
|
Command::Export { dir } => {
|
|
let path = dir
|
|
.to_str()
|
|
.context("export path is not valid UTF-8")?
|
|
.to_string();
|
|
let result = client.call("export", json!({ "path": path }))?;
|
|
let count = result.get("count").and_then(Value::as_u64).unwrap_or(0);
|
|
println!("Exported {count} nodes to {}", dir.display());
|
|
}
|
|
Command::MigrateLinks => {
|
|
let result = client.call("migrate.wikilinks", json!({}))?;
|
|
let n = result.as_u64().unwrap_or(0);
|
|
println!("Rewrote legacy [[Name]] links to [[id]] in {n} node(s).");
|
|
}
|
|
Command::Auth { .. } => unreachable!("auth is handled before connecting"),
|
|
Command::Daemon { .. } => unreachable!("daemon is handled before connecting"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Parse an optional human date into epoch-ms JSON (for `task.create`).
|
|
fn opt_date_ms(spec: Option<&str>) -> Result<Option<i64>> {
|
|
spec.map(datespec::parse_date_ms).transpose()
|
|
}
|
|
|
|
/// A `task.set_schedule` date field value: `Some(null)` to clear (`"none"`),
|
|
/// `Some(<ms>)` to set, `None` to omit (leave unchanged).
|
|
fn sched_date(spec: Option<&str>) -> Result<Option<Value>> {
|
|
match spec {
|
|
None => Ok(None),
|
|
Some("none") => Ok(Some(Value::Null)),
|
|
Some(s) => Ok(Some(json!(datespec::parse_date_ms(s)?))),
|
|
}
|
|
}
|
|
|
|
/// A `task.set_schedule` recurrence value, same tri-state as [`sched_date`].
|
|
fn sched_recurrence(spec: Option<&str>) -> Result<Option<Value>> {
|
|
match spec {
|
|
None => Ok(None),
|
|
Some("none") => Ok(Some(Value::Null)),
|
|
Some(s) => Ok(Some(json!(datespec::parse_recurrence(s)?))),
|
|
}
|
|
}
|
|
|
|
/// Recurrence for `task.create`: a raw `--rrule` wins, else parse `--recur`.
|
|
fn recurrence_value(recur: Option<&str>, rrule: Option<&str>) -> Result<Option<String>> {
|
|
if let Some(raw) = rrule {
|
|
return Ok(Some(raw.to_string()));
|
|
}
|
|
recur.map(datespec::parse_recurrence).transpose()
|
|
}
|
|
|
|
/// Resolve a project **name** to its node id, erroring if no project matches.
|
|
/// Matching is case-insensitive and prefix-fuzzy when unambiguous (server-side,
|
|
/// shared with `heph list --project`); an exact title always wins outright.
|
|
fn resolve_project(client: &mut Client, name: Option<&str>) -> Result<Option<String>> {
|
|
let Some(name) = name else { return Ok(None) };
|
|
let result = client.call("project.resolve", json!({ "name": name }))?;
|
|
if result.is_null() {
|
|
bail!("no project named {name:?} (create it with: heph project add {name:?})");
|
|
}
|
|
let node: Node = serde_json::from_value(result)?;
|
|
Ok(Some(node.id))
|
|
}
|
|
|
|
/// Resolve a task's canonical-context doc id by walking its outgoing links.
|
|
/// Errors if the node has no such doc (e.g. it isn't a task).
|
|
fn canonical_context_id(client: &mut Client, task_id: &str) -> Result<String> {
|
|
let links = client.call("links.outgoing", json!({ "id": task_id }))?;
|
|
let dst = links
|
|
.as_array()
|
|
.into_iter()
|
|
.flatten()
|
|
.find(|l| l.get("link_type").and_then(Value::as_str) == Some("canonical-context"))
|
|
.and_then(|l| l.get("dst_id").and_then(Value::as_str));
|
|
match dst {
|
|
Some(id) => Ok(id.to_string()),
|
|
None => bail!("{task_id} has no canonical-context doc (is it a task?)"),
|
|
}
|
|
}
|
|
|
|
/// Fetch a context doc's current body (empty string when unset).
|
|
fn context_body(client: &mut Client, doc_id: &str) -> Result<String> {
|
|
let node = client.call("node.get", json!({ "id": doc_id }))?;
|
|
Ok(node
|
|
.get("body")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_string())
|
|
}
|
|
|
|
/// `--body -` reads the body from stdin; otherwise pass it through.
|
|
fn read_body_arg(body: Option<String>) -> Result<Option<String>> {
|
|
match body.as_deref() {
|
|
Some("-") => {
|
|
use std::io::Read;
|
|
let mut s = String::new();
|
|
std::io::stdin().read_to_string(&mut s)?;
|
|
Ok(Some(s))
|
|
}
|
|
_ => Ok(body),
|
|
}
|
|
}
|
|
|
|
fn set_state(client: &mut Client, id: &str, state: &str) -> Result<()> {
|
|
client.call("task.set_state", json!({ "id": id, "state": state }))?;
|
|
println!("{id} → {state}");
|
|
Ok(())
|
|
}
|
|
|
|
fn num(v: &Value, key: &str) -> u64 {
|
|
v.get(key).and_then(Value::as_u64).unwrap_or(0)
|
|
}
|
|
|
|
/// Print a list of `RankedTask` rows (shared by `next` and `list`).
|
|
fn print_rows(result: Value) -> Result<()> {
|
|
let tasks: Vec<RankedTask> = serde_json::from_value(result)?;
|
|
if tasks.is_empty() {
|
|
println!("Nothing actionable right now.");
|
|
}
|
|
for t in &tasks {
|
|
println!("{}", format_row(t));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn print_links(result: Value) {
|
|
let arr = result.as_array().cloned().unwrap_or_default();
|
|
if arr.is_empty() {
|
|
println!("(no links)");
|
|
}
|
|
for l in &arr {
|
|
let ty = l.get("link_type").and_then(Value::as_str).unwrap_or("?");
|
|
let src = l.get("src_id").and_then(Value::as_str).unwrap_or("?");
|
|
let dst = l.get("dst_id").and_then(Value::as_str).unwrap_or("?");
|
|
println!("{src} -[{ty}]-> {dst}");
|
|
}
|
|
}
|
|
|
|
/// One concise Tactical row: attention tag, title, and human do/late context.
|
|
fn format_row(t: &RankedTask) -> String {
|
|
let tag = t
|
|
.attention
|
|
.map(|a| format!("[{}]", serde_json::to_value(a).unwrap().as_str().unwrap()))
|
|
.unwrap_or_else(|| "[ ]".to_string());
|
|
let mut extra = Vec::new();
|
|
if let Some(d) = t.do_date {
|
|
extra.push(format!("do:{}", datespec::fmt_date(d)));
|
|
}
|
|
if let Some(l) = t.late_on {
|
|
extra.push(format!("late:{}", datespec::fmt_date(l)));
|
|
}
|
|
let suffix = if extra.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" ({})", extra.join(", "))
|
|
};
|
|
format!("{tag} {}{suffix} {}", t.title, t.node_id)
|
|
}
|