From 4cdf0de64c52d4ca65cb06cb4f4cc6ea683542e6 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 11:18:51 -0700 Subject: [PATCH] =?UTF-8?q?feat(core):=20tags=20=E2=80=94=20canonical=20ta?= =?UTF-8?q?g=20nodes=20+=20OR-set=20tagging=20(=C2=A74,=20=C2=A78.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tag is a `tag`-kind node with a deterministic id in (owner, name) (`tag::`, like the journal), so a name is one canonical tag shared across nodes and replicas converge with no duplicates. Tagging is an OR-set `tagged` link (mirroring in-project): - heph-core: `nodes::open_or_create_tag` (bodyless, deterministic id), `tags::{add,remove,of}`, and `Store::{add_tag,remove_tag,tags_of}`. Enumerate all tags via the existing `list_nodes(Tag)`. - hephd: `tag.add`/`tag.remove`/`tag.list` RPCs + RemoteStore forwarding. - heph: `heph tag add|rm|list` (a node's tags, or every tag). Names are trimmed; canonical case/spelling normalization is deferred to the zk import. Unblocks the `tags:` line of the frontmatter surface. Tests: core add/dedupe/remove/canonical-id/trim/missing-node + a socket add/list/enumerate/remove test. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-core/src/sqlite/mod.rs | 15 +++++ crates/heph-core/src/sqlite/nodes.rs | 40 +++++++++++++ crates/heph-core/src/sqlite/tags.rs | 73 ++++++++++++++++++++++++ crates/heph-core/src/store.rs | 16 ++++++ crates/heph-core/tests/tags.rs | 66 +++++++++++++++++++++ crates/heph/src/main.rs | 58 +++++++++++++++++++ crates/hephd/src/remote.rs | 13 +++++ crates/hephd/src/rpc.rs | 21 +++++++ crates/hephd/tests/rpc_socket.rs | 40 +++++++++++++ docs/changelog.d/v1-prototype.feature.md | 1 + docs/reference/tech-spec.md | 2 +- 11 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 crates/heph-core/src/sqlite/tags.rs create mode 100644 crates/heph-core/tests/tags.rs diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 630ba0e..01b455d 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -17,6 +17,7 @@ mod migrations; mod nodes; mod ops; mod syncstate; +mod tags; mod tasks; pub use migrations::latest_version; @@ -306,6 +307,20 @@ impl Store for LocalStore { links::backlinks(&self.conn, id) } + fn add_tag(&mut self, node_id: &str, tag: &str) -> Result { + let now = self.clock.now_ms(); + tags::add(&mut self.conn, &self.owner_id, now, node_id, tag) + } + + fn remove_tag(&mut self, node_id: &str, tag: &str) -> Result<()> { + let now = self.clock.now_ms(); + tags::remove(&mut self.conn, &self.owner_id, now, node_id, tag) + } + + fn tags_of(&self, node_id: &str) -> Result> { + tags::of(&self.conn, node_id) + } + fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> { let now = self.clock.now_ms(); let tx = self.conn.transaction()?; diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index c919e7d..3378c63 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -216,6 +216,46 @@ pub(super) fn open_or_create_journal( Ok(node) } +/// Open (creating if absent) the `tag`-kind node named `name`. Like the journal +/// (§3.1), its id is **deterministic** in `(owner, name)`, so the same name is +/// one canonical tag and independent offline taggings converge. Tags are +/// bodyless. `name` must be non-empty (callers trim first). +pub(super) fn open_or_create_tag( + conn: &Connection, + owner: &str, + now: i64, + name: &str, +) -> Result { + if name.is_empty() { + return Err(Error::Integrity("tag name must not be empty".into())); + } + let id = deterministic_id(owner, NodeKind::Tag, name); + if let Some(existing) = get(conn, &id)? { + return Ok(existing); + } + let node = Node { + id, + owner_id: owner.to_string(), + kind: NodeKind::Tag, + title: name.to_string(), + body: None, + created_at: now, + modified_at: now, + hlc: next_hlc(conn, now)?, + tombstoned: false, + }; + insert(conn, &node)?; + ops::record( + conn, + owner, + &node.hlc, + op_type::NODE_CREATE, + &node.id, + create_payload(&node, None), + )?; + Ok(node) +} + fn is_iso_date(s: &str) -> bool { let b = s.as_bytes(); b.len() == 10 diff --git a/crates/heph-core/src/sqlite/tags.rs b/crates/heph-core/src/sqlite/tags.rs new file mode 100644 index 0000000..09dbaac --- /dev/null +++ b/crates/heph-core/src/sqlite/tags.rs @@ -0,0 +1,73 @@ +//! Tag operations (tech-spec §4, §8.3). A tag is a `tag`-kind node with a +//! deterministic id (so a name is one canonical tag, §3.1); tagging is an +//! **OR-set** `tagged` link from the tagged node to the tag node — mirroring +//! `in-project`. Names are trimmed; case is preserved (a canonical +//! normalization is deferred to the zk import, [[design]]). + +use rusqlite::Connection; + +use super::{links, nodes}; +use crate::error::{Error, Result}; +use crate::model::{Link, LinkType, Node}; + +/// Tag `node_id` with `name`, creating the canonical tag node on first use. +/// Idempotent: a node already carrying the tag is left unchanged. Returns the +/// tag node. Errors if the target node is missing or the name is blank. +pub(super) fn add( + conn: &mut Connection, + owner: &str, + now: i64, + node_id: &str, + name: &str, +) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(Error::Integrity("tag name must not be empty".into())); + } + if nodes::get(conn, node_id)?.is_none() { + return Err(Error::NodeNotFound(node_id.into())); + } + + let tx = conn.transaction()?; + let tag = nodes::open_or_create_tag(&tx, owner, now, name)?; + let already = links::outgoing(&tx, node_id)? + .iter() + .any(|l: &Link| l.link_type == LinkType::Tagged && l.dst_id == tag.id); + if !already { + links::add(&tx, owner, now, node_id, &tag.id, LinkType::Tagged)?; + } + tx.commit()?; + Ok(tag) +} + +/// Untag `node_id` — tombstone its `tagged` link(s) to the `name` tag. A no-op +/// if the node isn't tagged with it. The tag node itself persists. +pub(super) fn remove( + conn: &mut Connection, + owner: &str, + now: i64, + node_id: &str, + name: &str, +) -> Result<()> { + let name = name.trim(); + let tag_id = crate::model::deterministic_id(owner, crate::model::NodeKind::Tag, name); + let tx = conn.transaction()?; + for link in links::outgoing(&tx, node_id)? { + if link.link_type == LinkType::Tagged && link.dst_id == tag_id { + links::tombstone(&tx, owner, now, &link.id)?; + } + } + tx.commit()?; + Ok(()) +} + +/// The tag names on `node_id`, sorted (tombstoned tags excluded). +pub(super) fn of(conn: &Connection, node_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT n.title FROM links l JOIN nodes n ON n.id = l.dst_id + WHERE l.src_id = ?1 AND l.type = 'tagged' AND l.tombstoned = 0 AND n.tombstoned = 0 + ORDER BY n.title", + )?; + let rows = stmt.query_map([node_id], |r| r.get::<_, String>(0))?; + Ok(rows.collect::>>()?) +} diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 5044b15..656b1cb 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -141,6 +141,22 @@ pub trait Store { /// All non-tombstoned links pointing at `id` (backlinks). fn backlinks(&self, id: &str) -> Result>; + // --- tags (tech-spec §4, §8.3) --- + + /// Tag `node_id` with `tag` (trimmed), creating the canonical `tag`-kind + /// node on first use — its id is deterministic in `(owner, name)`, so a + /// name is one shared tag and replicas converge. OR-set `tagged` link; + /// idempotent. Returns the tag node. Errors on a missing node or blank name. + fn add_tag(&mut self, node_id: &str, tag: &str) -> Result; + + /// Remove `tag` from `node_id` (tombstone the `tagged` link); a no-op if it + /// isn't tagged. The tag node itself persists. + fn remove_tag(&mut self, node_id: &str, tag: &str) -> Result<()>; + + /// The tag names on `node_id`, sorted. (Enumerate *all* tags via + /// [`Store::list_nodes`] with [`NodeKind::Tag`].) + fn tags_of(&self, node_id: &str) -> Result>; + // --- per-task log ([[design]] §6.4) --- /// Append a line to a task's append-only log (creating the log on first diff --git a/crates/heph-core/tests/tags.rs b/crates/heph-core/tests/tags.rs new file mode 100644 index 0000000..5d6d386 --- /dev/null +++ b/crates/heph-core/tests/tags.rs @@ -0,0 +1,66 @@ +//! Public-API tests for tags (tech-spec §4, §8.3): a tag is a `tag`-kind node, +//! tagging is an OR-set `tagged` link, and tag node ids are **deterministic in +//! (owner, name)** so the same name is one canonical tag (and replicas converge). + +use heph_core::{FixedClock, LocalStore, NewNode, NodeKind, Store}; + +fn store() -> LocalStore { + LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap() +} + +#[test] +fn add_lists_dedupes_and_removes_tags() { + let mut s = store(); + let doc = s.create_node(NewNode::doc("Roof notes", "")).unwrap(); + + // Add two tags; `tags_of` returns them sorted by name. + s.add_tag(&doc.id, "house").unwrap(); + s.add_tag(&doc.id, "urgent").unwrap(); + assert_eq!(s.tags_of(&doc.id).unwrap(), vec!["house", "urgent"]); + + // Re-adding a tag is idempotent — no duplicate `tagged` link. + s.add_tag(&doc.id, "house").unwrap(); + assert_eq!(s.tags_of(&doc.id).unwrap(), vec!["house", "urgent"]); + + // Removing one leaves the other. + s.remove_tag(&doc.id, "house").unwrap(); + assert_eq!(s.tags_of(&doc.id).unwrap(), vec!["urgent"]); + + // Removing a tag the node doesn't have is a harmless no-op. + s.remove_tag(&doc.id, "house").unwrap(); + assert_eq!(s.tags_of(&doc.id).unwrap(), vec!["urgent"]); +} + +#[test] +fn a_tag_name_is_one_canonical_node_shared_across_nodes() { + let mut s = store(); + let a = s.create_node(NewNode::doc("A", "")).unwrap(); + let b = s.create_node(NewNode::doc("B", "")).unwrap(); + + let ta = s.add_tag(&a.id, "shared").unwrap(); + let tb = s.add_tag(&b.id, "shared").unwrap(); + + assert_eq!(ta.id, tb.id, "same name → one canonical tag node"); + assert_eq!(ta.kind, NodeKind::Tag); + + // The tag is enumerable, and there is exactly one for the name. + let tags = s.list_nodes(Some(NodeKind::Tag)).unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].title, "shared"); +} + +#[test] +fn add_tag_trims_and_rejects_blank_or_missing_node() { + let mut s = store(); + let d = s.create_node(NewNode::doc("D", "")).unwrap(); + + // Leading/trailing whitespace is trimmed (so " house " == "house"). + s.add_tag(&d.id, " house ").unwrap(); + assert_eq!(s.tags_of(&d.id).unwrap(), vec!["house"]); + + assert!(s.add_tag(&d.id, " ").is_err(), "blank tag rejected"); + assert!( + s.add_tag("nope", "x").is_err(), + "missing target node rejected" + ); +} diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index 53de382..fef4a3c 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -209,6 +209,11 @@ enum Command { #[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. @@ -284,6 +289,29 @@ enum ProjectAction { 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, + }, +} + #[derive(Subcommand, Debug)] enum ConflictAction { /// Resolve a conflict by choosing the local or remote value. @@ -638,6 +666,36 @@ fn main() -> Result<()> { } } }, + 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 = 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 = 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!({}))?; diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 952982b..7449bc8 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -235,6 +235,19 @@ impl Store for RemoteStore { self.call_as("links.backlinks", json!({ "id": id })) } + fn add_tag(&mut self, node_id: &str, tag: &str) -> Result { + self.call_as("tag.add", json!({ "node_id": node_id, "tag": tag })) + } + + fn remove_tag(&mut self, node_id: &str, tag: &str) -> Result<()> { + self.call("tag.remove", json!({ "node_id": node_id, "tag": tag })) + .map(|_| ()) + } + + fn tags_of(&self, node_id: &str) -> Result> { + self.call_as("tag.list", json!({ "node_id": node_id })) + } + fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> { self.call("log.append", json!({ "task_id": task_id, "text": text })) .map(|_| ()) diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 00ba9af..9dbf024 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -143,6 +143,14 @@ struct SetAttentionParams { attention: Attention, } +#[derive(Deserialize)] +struct TagParams { + node_id: String, + /// The tag name. Unused by `tag.list` (which sends only `node_id`). + #[serde(default)] + tag: String, +} + #[derive(Deserialize)] struct SetProjectParams { id: String, @@ -326,6 +334,19 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result { + let p: TagParams = parse(params)?; + json!(store.add_tag(&p.node_id, &p.tag)?) + } + "tag.remove" => { + let p: TagParams = parse(params)?; + store.remove_tag(&p.node_id, &p.tag)?; + json!({ "ok": true }) + } + "tag.list" => { + let p: TagParams = parse(params)?; + json!(store.tags_of(&p.node_id)?) + } "log.append" => { let p: LogAppendParams = parse(params)?; store.log_append(&p.task_id, &p.text)?; diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index d9a188d..687f3ff 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -227,6 +227,46 @@ fn task_set_project_moves_and_unfiles_over_socket() { ); } +#[test] +fn tag_add_list_remove_over_socket() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + let doc = c + .call( + "node.create", + json!({ "kind": "doc", "title": "Roof notes" }), + ) + .unwrap(); + let id = doc["id"].as_str().unwrap().to_string(); + + // Add two tags (trimmed); tag.list returns them sorted. + c.call("tag.add", json!({ "node_id": id, "tag": "house" })) + .unwrap(); + c.call("tag.add", json!({ "node_id": id, "tag": " urgent " })) + .unwrap(); + let tags = c.call("tag.list", json!({ "node_id": id })).unwrap(); + assert_eq!(tags, json!(["house", "urgent"])); + + // The canonical tag set is enumerable via node.list kind=tag. + let all = c.call("node.list", json!({ "kind": "tag" })).unwrap(); + let names: Vec<&str> = all + .as_array() + .unwrap() + .iter() + .map(|n| n["title"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["house", "urgent"]); + + // Remove one. + c.call("tag.remove", json!({ "node_id": id, "tag": "house" })) + .unwrap(); + assert_eq!( + c.call("tag.list", json!({ "node_id": id })).unwrap(), + json!(["urgent"]) + ); +} + #[test] fn promote_context_item_over_socket() { let (socket, _dir) = spawn_daemon(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index daf4b2a..1dfe9a2 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,3 +28,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit. - `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.) - `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `` still jumps to a task's context doc. +- Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 39ce07b..0c535f8 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -451,7 +451,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **(e) scrollbar — DONE:** the task list grows a `ratatui` `Scrollbar` (tracking the selection) when content overflows; a `ListState` selection keeps the highlighted row scrolled into view. - ✅ **(b) sort toggle `s` — DONE:** **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with dimmed **`──── Name ────` separators** riding atop each group's first task (the cursor only lands on real tasks). View filtering always runs **before** the sort. (`skip` moved to `S` to free `s`.) - ✅ **nvim task-navigation polish (§8) — DONE:** `:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `` already jumps to a row's canonical-context doc (read/navigate, not field-edit). -3. ⏳ **Tags (§4, §8.3) — promoted from deferred:** `NodeKind::Tag` exists but has no machinery. Add **tag-as-node + an OR-set tag link** (mirroring `in-project`) + `tag.add`/`tag.remove` RPCs + enumeration. Prerequisite for the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import ([[design]]); the goal is **one canonical tag set** across all of heph. +3. ✅ **Tags (§4, §8.3) — DONE:** a tag is a `tag`-kind node whose id is **deterministic in `(owner, name)`** (`tag::`, like the journal), so a name is one canonical tag and replicas converge — no duplicate tag nodes. Tagging is an **OR-set `tagged` link** (mirroring `in-project`): `Store::add_tag` (get-or-create the tag node, idempotent link), `remove_tag` (tombstone the link), `tags_of` (sorted names); enumerate all tags via `list_nodes(Tag)`. RPCs `tag.add`/`tag.remove`/`tag.list` (+ RemoteStore forward); CLI `heph tag add|rm|list`. Names are trimmed, case preserved (canonical normalization deferred to the zk import). Unblocks the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import; inline `#hashtags` remain a heph.nvim concern (§8.3). 4. ⏳ **YAML frontmatter as an edit surface (§8.3) — docs-first C1:** generated-on-read, stripped-and-ignored-on-write in `heph-core`; `heph.nvim` diffs it into structured RPCs. See §8.3. 5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4. 6. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9).