generated from eblume/project-template
feat(cli): heph project list (+ node.list RPC)
Some checks failed
Build / validate (pull_request) Has been cancelled
Some checks failed
Build / validate (pull_request) Has been cancelled
Add a list-by-kind primitive so projects (and later tags) can be enumerated.
- core: Store::list_nodes(kind?) — owner-scoped, non-tombstoned, title-sorted;
sqlite nodes::list; LocalStore/RemoteStore impls
- rpc: node.list {kind?} dispatch
- cli: `heph project list`
- tests: core list_nodes (kind filter, case-insensitive sort, tombstone
exclusion) + cli project_list (projects only, not tasks)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
07e4d786b3
commit
f122c9e6a4
8 changed files with 101 additions and 4 deletions
|
|
@ -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<NodeKind>) -> Result<Vec<Node>> {
|
||||
nodes::list(&self.conn, &self.owner_id, kind)
|
||||
}
|
||||
|
||||
fn journal_open_or_create(&mut self, date: &str) -> Result<Node> {
|
||||
let now = self.clock.now_ms();
|
||||
nodes::open_or_create_journal(&self.conn, &self.owner_id, now, date)
|
||||
|
|
|
|||
|
|
@ -332,6 +332,23 @@ pub(super) fn search(conn: &Connection, owner: &str, query: &str) -> Result<Vec<
|
|||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
/// 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<NodeKind>) -> Result<Vec<Node>> {
|
||||
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::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
/// A node's aliases (wiki-link names), sorted. Empty until aliases are written.
|
||||
pub(super) fn aliases(conn: &Connection, id: &str) -> Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare("SELECT alias FROM aliases WHERE node_id = ?1 ORDER BY alias")?;
|
||||
|
|
|
|||
|
|
@ -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<NodeKind>) -> Result<Vec<Node>>;
|
||||
|
||||
/// 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).
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -270,6 +270,8 @@ enum ProjectAction {
|
|||
#[arg(long)]
|
||||
parent: Option<String>,
|
||||
},
|
||||
/// 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<Node> = 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" };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<NodeKind>) -> Result<Vec<Node>> {
|
||||
self.call_as("node.list", json!({ "kind": kind.map(|k| k.as_str()) }))
|
||||
}
|
||||
|
||||
fn journal_open_or_create(&mut self, date: &str) -> Result<Node> {
|
||||
self.call_as("journal.open_or_create", json!({ "date": date }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NodeKind>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateParams {
|
||||
id: String,
|
||||
|
|
@ -245,6 +251,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
|
|||
let p: ResolveParams = parse(params)?;
|
||||
json!(store.resolve_node(&p.title)?)
|
||||
}
|
||||
"node.list" => {
|
||||
let p: NodeListParams = parse(params)?;
|
||||
json!(store.list_nodes(p.kind)?)
|
||||
}
|
||||
"task.create" => {
|
||||
let p: NewTask = parse(params)?;
|
||||
json!(store.create_task(p)?)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue