diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 577f49c..c4ec1b1 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -289,6 +289,10 @@ impl Store for LocalStore { nodes::list(&self.conn, &self.owner_id, kind) } + fn list_linkable_nodes(&self) -> Result> { + nodes::list_linkable(&self.conn, &self.owner_id) + } + 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 19e098b..515be5d 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -407,6 +407,23 @@ pub(super) fn list(conn: &Connection, owner: &str, kind: Option) -> Re Ok(rows.collect::>>()?) } +/// First-class wiki-link targets (tech-spec §8.4): non-tombstoned nodes, minus +/// the internal noise — `tag` nodes, and the `doc` nodes that are a task's +/// **canonical-context** or **log** attachment (you link the task, not its +/// auto-created body/log). Title-sorted, for the `[[` picker. +pub(super) fn list_linkable(conn: &Connection, owner: &str) -> Result> { + let sql = format!( + "SELECT {COLUMNS} FROM nodes + WHERE owner_id = ?1 AND tombstoned = 0 AND kind != 'tag' + AND id NOT IN (SELECT dst_id FROM links + WHERE type IN ('canonical-context', 'log-of') AND tombstoned = 0) + ORDER BY title COLLATE NOCASE" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = 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 52d459f..1ef715c 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -46,6 +46,11 @@ pub trait Store { /// (tech-spec §6 `node.list`). fn list_nodes(&self, kind: Option) -> Result>; + /// First-class wiki-link targets (tech-spec §8.4): non-tombstoned nodes + /// minus the internal noise — `tag` nodes and the `doc`s that are a task's + /// canonical-context or log attachment. Backs the `[[` picker. + fn list_linkable_nodes(&self) -> 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/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 2d997cf..be74c2a 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -216,6 +216,10 @@ impl Store for RemoteStore { self.call_as("node.list", json!({ "kind": kind.map(|k| k.as_str()) })) } + fn list_linkable_nodes(&self) -> Result> { + self.call_as("node.linkable", json!({})) + } + 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 8f0a77a..7a91008 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -324,6 +324,7 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result json!(store.list_linkable_nodes()?), "node.update" => { let p: UpdateParams = parse(params)?; json!(store.update_node(&p.id, p.title, p.body)?) diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 9426258..1e751cd 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -274,6 +274,28 @@ fn tag_add_list_remove_over_socket() { ); } +#[test] +fn node_linkable_excludes_context_docs_logs_and_tags() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + + // task.create → a task node + a same-titled canonical-context doc. + let task = c.call("task.create", json!({ "title": "Fix roof" })).unwrap(); + let task_id = task["node_id"].as_str().unwrap().to_string(); + // a standalone doc, a tag node, and a log doc (first append). + c.call("node.create", json!({ "kind": "doc", "title": "Notes" })).unwrap(); + c.call("tag.add", json!({ "node_id": task_id, "tag": "house" })).unwrap(); + c.call("log.append", json!({ "task_id": task_id, "text": "started" })).unwrap(); + + // Only first-class targets: the task and the standalone doc — not the + // context doc, the log doc, or the tag (5 nodes total ⇒ 2 linkable). + let nodes = c.call("node.linkable", json!({})).unwrap(); + let arr = nodes.as_array().unwrap(); + assert_eq!(arr.len(), 2, "expected just the task + standalone doc:\n{arr:#?}"); + assert!(arr.iter().any(|n| n["title"] == "Fix roof" && n["kind"] == "task")); + assert!(arr.iter().any(|n| n["title"] == "Notes" && n["kind"] == "doc")); +} + #[test] fn wikilinks_expand_on_read_and_collapse_on_write_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 25ac84c..8e433a1 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,7 +28,7 @@ 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. -- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to pick a node and insert a canonical `[[NODEID]]` link. With Telescope it's a **live fuzzy filter over all your nodes with a preview pane** (the node's body, or a task's context doc) — type to narrow, Enter to insert, `` to create a doc named what you've typed; without Telescope it falls back to a search-then-select prompt. Following such a link (``) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. Legacy `[[Name]]` links keep working, and **`heph migrate-links`** rewrites them to the canonical id form in one pass when you're ready (idempotent). +- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to pick a node and insert a canonical `[[NODEID]]` link. With Telescope it's a **live fuzzy filter over your linkable nodes with a preview pane** (the node's body, or a task's context doc) — the list shows first-class targets only (a task appears once; its internal context/log docs and tag nodes are hidden, via the new `node.linkable` query) — type to narrow, Enter to insert, `` to create a doc named what you've typed; without Telescope it falls back to a search-then-select prompt. Following such a link (``) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. Legacy `[[Name]]` links keep working, and **`heph migrate-links`** rewrites them to the canonical id form in one pass when you're ready (idempotent). - Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Inline **`#hashtags`** typed in the body are also added as tags on save (a `# heading` doesn't count) and are rendered in **italics** so they stand out. Link-follow and promotion are unaffected (they're content-relative, not line-absolute). - Frontmatter projection (§8.3): a node can now be fetched with an editable **YAML frontmatter** block prepended — `node.get {frontmatter: true}` renders `id`/`kind`/`title`/`tags`, and for a task (or its context doc) the owning task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` ref. Dates are local `YYYY-MM-DD`. On write, the store **strips and ignores** any leading frontmatter (conservatively — a real `---` hrule in prose survives) before the CRDT diff, so frontmatter never persists and an unchanged read→write is a no-op; a naive editor can't corrupt metadata. This is the read/write groundwork for editing a node's metadata as frontmatter in heph.nvim (the diff-into-RPCs layer is next). - 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 2dcc357..26e7576 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -334,7 +334,7 @@ Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, > **Status: id-addressed links + the `[[` picker are built**; read-expansion/conceal display and the legacy migration are next. Historically bodies stored the human text `[[Title]]` and links materialized by resolving name→id at write time, which is ambiguous (a task and its canonical-context doc share a title — hence the resolution hack in §6/`links::resolve_id`). The fix: **the body stores the canonical node id**, and **no name-addressed link ever enters the DB**. - ✅ **Resolution is id-first:** `links::resolve_id` checks for an exact live node **id** before alias/title, so `[[NODEID]]` resolves to its node (and a like-named node can't shadow it). Legacy `[[Name]]` links still resolve by name until the migration runs; the canonical-context exclusion hack therefore stays for now (removed once name-resolution is retired). -- ✅ **`heph.nvim` authoring:** typing `[[` (or `:Heph link`) opens a node picker and inserts `[[NODEID|Name]]` (labelled → readable + conceal-ready; collapses to bare on save). With **Telescope** it's a **live fuzzy filter over every node** (`node.list`) with a **preview pane** (the node's body, or — for a task — its canonical-context doc, fetched via RPC) — type to narrow, `` inserts, **`` creates a doc named the current prompt** (a miss flows straight into "make it"; mirrors obsidian.nvim's `new` mapping). Without Telescope it falls back to a `search`-then-`vim.ui.select` prompt with a "+ Create" entry. Follow (``) resolves the id directly. +- ✅ **`heph.nvim` authoring:** typing `[[` (or `:Heph link`) opens a node picker and inserts `[[NODEID|Name]]` (labelled → readable + conceal-ready; collapses to bare on save). With **Telescope** it's a **live fuzzy filter over the linkable nodes** (`node.linkable` — non-tombstoned nodes minus `tag`s and the `doc`s that are a task's canonical-context/log attachment, so a task appears once, not twice) with a **preview pane** (the node's body, or — for a task — its canonical-context doc, fetched via RPC) — type to narrow, `` inserts, **`` creates a doc named the current prompt** (a miss flows straight into "make it"; mirrors obsidian.nvim's `new` mapping). Without Telescope it falls back to a `search`-then-`vim.ui.select` prompt with a "+ Create" entry. Follow (``) resolves the id directly. - **At rest (target):** `[[NODEID]]`, or `[[NODEID|custom text]]` when the author wrote explicit display text. The id before the `|` is the target. - ✅ **Projection (same philosophy as §8.3):** `heph-core::wikilink` (pure, injected id→title) — `node.get` **expands** a bare `[[NODEID]]` → `[[NODEID|Current Name]]` (every read, so the nvim buffer *and* the TUI preview are readable), and `update_node` **collapses** a `|text` equal to the target's current name back to bare before the CRDT diff (a custom label is preserved as an override). Transform order: read = expand links → prepend frontmatter; write = strip frontmatter → collapse links → store. An unchanged read→write round-trips to the canonical bare id. *(`heph export` still emits raw ids — a later polish.)* - ✅ **`heph.nvim` display:** `conceal.lua` hides the `[[id|` prefix and the `]]` suffix with conceal extmarks (refreshed on edit), leaving the label as a styled `HephLink` hyperlink; `conceallevel=2` + empty `concealcursor` reveal the raw link on the cursor's line so it stays editable. diff --git a/heph.nvim/lua/heph/link.lua b/heph.nvim/lua/heph/link.lua index 059ec09..3ae89e0 100644 --- a/heph.nvim/lua/heph/link.lua +++ b/heph.nvim/lua/heph/link.lua @@ -117,7 +117,8 @@ local function telescope_insert() local action_state = require("telescope.actions.state") local previewers = require("telescope.previewers") - local nodes = rpc.call("node.list", vim.empty_dict()) or {} + -- First-class targets only (excludes tags + tasks' context/log docs, §8.4). + local nodes = rpc.call("node.linkable", vim.empty_dict()) or {} pickers .new({}, { prompt_title = "Link to node ( = create from prompt)",