diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 262ebb6..45c8c9e 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -30,7 +30,7 @@ use crate::clock::Clock; use crate::error::{Error, Result}; use crate::hlc::Hlc; use crate::model::{ - Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SchedulePatch, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch, SyncCursors, Task, TaskState, }; use crate::oplog::Op; @@ -277,6 +277,10 @@ impl Store for LocalStore { nodes::search(&self.conn, &self.owner_id, query) } + fn list_nodes(&self, kind: Option) -> Result> { + nodes::list(&self.conn, &self.owner_id, kind) + } + fn journal_open_or_create(&mut self, date: &str) -> Result { let now = self.clock.now_ms(); nodes::open_or_create_journal(&self.conn, &self.owner_id, now, date) diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index ba04907..c919e7d 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -332,6 +332,23 @@ pub(super) fn search(conn: &Connection, owner: &str, query: &str) -> Result>>()?) } +/// List non-tombstoned, owner-scoped nodes, optionally filtered by `kind`, +/// ordered by title (case-insensitive). Used by surfaces to enumerate projects, +/// tags, etc. (tech-spec §6 `node.list`). +pub(super) fn list(conn: &Connection, owner: &str, kind: Option) -> Result> { + let mut sql = format!("SELECT {COLUMNS} FROM nodes WHERE owner_id = ?1 AND tombstoned = 0"); + if kind.is_some() { + sql.push_str(" AND kind = ?2"); + } + sql.push_str(" ORDER BY title COLLATE NOCASE"); + let mut stmt = conn.prepare(&sql)?; + let rows = match kind { + Some(k) => stmt.query_map((owner, k.as_str()), from_row)?, + None => stmt.query_map([owner], from_row)?, + }; + Ok(rows.collect::>>()?) +} + /// A node's aliases (wiki-link names), sorted. Empty until aliases are written. pub(super) fn aliases(conn: &Connection, id: &str) -> Result> { let mut stmt = conn.prepare("SELECT alias FROM aliases WHERE node_id = ?1 ORDER BY alias")?; diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index ddbeb1a..3a1d20c 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -6,7 +6,7 @@ use crate::error::Result; use crate::model::{ - Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, SchedulePatch, + Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch, SyncCursors, Task, TaskState, }; use crate::oplog::Op; @@ -40,6 +40,11 @@ pub trait Store { /// Tombstone (soft-delete) a node. No hard deletes (tech-spec §4.3). fn tombstone_node(&mut self, id: &str) -> Result<()>; + /// List non-tombstoned nodes (owner-scoped), optionally filtered by `kind`, + /// ordered by title. The enumeration surfaces (projects, tags) build on this + /// (tech-spec §6 `node.list`). + fn list_nodes(&self, kind: Option) -> Result>; + /// Resolve a wiki-link target (`[[title]]`) to a node, **exactly** — an /// alias match first, then an exact, owner-scoped, non-tombstoned title /// match; `None` if nothing matches (an unresolved link is allowed, §5). diff --git a/crates/heph-core/tests/search.rs b/crates/heph-core/tests/search.rs index dac586c..ab3f012 100644 --- a/crates/heph-core/tests/search.rs +++ b/crates/heph-core/tests/search.rs @@ -6,6 +6,36 @@ fn store() -> LocalStore { LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() } +#[test] +fn list_nodes_filters_by_kind_sorts_by_title_and_excludes_tombstoned() { + let mut s = store(); + let proj = |s: &mut LocalStore, t: &str| { + s.create_node(NewNode { + kind: NodeKind::Project, + title: t.into(), + body: None, + }) + .unwrap() + .id + }; + proj(&mut s, "Maintenance"); + proj(&mut s, "child"); // lowercase → tests case-insensitive sort + let gone = proj(&mut s, "Archived"); + s.create_node(NewNode::doc("Just a doc", "not a project")) + .unwrap(); + s.tombstone_node(&gone).unwrap(); + + // kind=project excludes the doc and the tombstoned project, title-sorted. + let projects = s.list_nodes(Some(NodeKind::Project)).unwrap(); + let titles: Vec<&str> = projects.iter().map(|n| n.title.as_str()).collect(); + assert_eq!(titles, vec!["child", "Maintenance"]); + + // No filter returns every non-tombstoned node (incl. the doc). + let all = s.list_nodes(None).unwrap(); + assert!(all.iter().any(|n| n.title == "Just a doc")); + assert!(!all.iter().any(|n| n.id == gone), "tombstoned excluded"); +} + #[test] fn search_matches_title_and_body() { let mut s = store(); diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 7773347..6288c72 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -270,6 +270,8 @@ enum ProjectAction { #[arg(long)] parent: Option, }, + /// List all projects. + List, } #[derive(Subcommand, Debug)] @@ -582,6 +584,16 @@ fn main() -> Result<()> { } println!("Created project {} \"{}\"", node.id, node.title); } + ProjectAction::List => { + let result = client.call("node.list", json!({ "kind": "project" }))?; + let nodes: Vec = serde_json::from_value(result)?; + if nodes.is_empty() { + println!("No projects."); + } + for n in &nodes { + println!("{} {}", n.id, n.title); + } + } }, Command::Sync { status } => { let method = if status { "sync.status" } else { "sync.now" }; diff --git a/crates/heph/tests/cli.rs b/crates/heph/tests/cli.rs index ceed77b..67ea3b0 100644 --- a/crates/heph/tests/cli.rs +++ b/crates/heph/tests/cli.rs @@ -199,6 +199,21 @@ fn project_add_then_file_a_task_under_it() { assert!(!ok, "expected failure for unknown project: {out}"); } +#[test] +fn project_list_shows_projects_only() { + let (socket, _dir) = spawn_daemon(); + heph(&socket, &["project", "add", "Maintenance"]); + heph(&socket, &["project", "add", "Coding"]); + // A task (and its context doc) must not show up in the project list. + heph(&socket, &["task", "Some task"]); + + let (out, ok) = heph(&socket, &["project", "list"]); + assert!(ok, "{out}"); + assert!(out.contains("Maintenance"), "{out}"); + assert!(out.contains("Coding"), "{out}"); + assert!(!out.contains("Some task"), "tasks are not projects: {out}"); +} + #[test] fn log_append_then_tail() { let (socket, _dir) = spawn_daemon(); diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index f4b8248..550faba 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -17,7 +17,7 @@ use serde::de::DeserializeOwned; use serde_json::{json, Value}; use heph_core::{ - Attention, Conflict, Error, Health, Link, LinkType, NewNode, NewTask, Node, Result, + Attention, Conflict, Error, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, Result, SchedulePatch, Store, SyncCursors, Task, TaskState, }; @@ -209,6 +209,10 @@ impl Store for RemoteStore { self.call_as("search", json!({ "query": query })) } + fn list_nodes(&self, kind: Option) -> Result> { + self.call_as("node.list", json!({ "kind": kind.map(|k| k.as_str()) })) + } + fn journal_open_or_create(&mut self, date: &str) -> Result { self.call_as("journal.open_or_create", json!({ "date": date })) } diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 7c2bcad..cbd8320 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -13,7 +13,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use heph_core::{Attention, LinkType, NewNode, NewTask, SchedulePatch, Store, TaskState}; +use heph_core::{Attention, LinkType, NewNode, NewTask, NodeKind, SchedulePatch, Store, TaskState}; /// A JSON-RPC request line. #[derive(Debug, Deserialize)] @@ -114,6 +114,12 @@ struct ResolveParams { title: String, } +#[derive(Deserialize)] +struct NodeListParams { + #[serde(default)] + kind: Option, +} + #[derive(Deserialize)] struct UpdateParams { id: String, @@ -245,6 +251,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + let p: NodeListParams = parse(params)?; + json!(store.list_nodes(p.kind)?) + } "task.create" => { let p: NewTask = parse(params)?; json!(store.create_task(p)?)