feat(core,hephd): wiki-link expand-on-read / collapse-on-write (§8.4)

Keep canonical `[[NODEID]]` links readable without storing names. New pure
`heph-core::wikilink` (injected id→title): `expand` turns a bare `[[id]]`
into `[[id|Current Name]]`, `collapse` turns a name-matching `[[id|text]]`
back to bare (a custom label is preserved as an override).

- `node.get` expands on every read (nvim buffer + TUI preview both
  readable), then prepends frontmatter when asked.
- `update_node` strips frontmatter, then collapses links, then CRDT-diffs
  — so neither projection ever persists and an unchanged read→write is a
  no-op to the bare id.

Tests: wikilink unit (expand/collapse/round-trip), a heph-core collapse
+ materialize-by-id integration test, and a socket expand→collapse
round-trip. `heph export` still emits raw ids (later polish).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 12:32:24 -07:00
commit ef2081fd8b
8 changed files with 254 additions and 18 deletions

View file

@ -22,6 +22,7 @@ pub mod ranking;
pub mod recurrence;
pub mod sqlite;
pub mod store;
pub mod wikilink;
pub use clock::{Clock, FixedClock};
pub use error::{Error, Result};

View file

@ -281,6 +281,16 @@ pub(super) fn get(conn: &Connection, id: &str) -> Result<Option<Node>> {
/// Update a node's title and/or body. A body change re-runs extraction and
/// reconciles this node's `wiki` links (tech-spec §5).
/// The title of node `id` if it's a live node owned by `owner` — the id→title
/// lookup behind wiki-link collapse (§8.4).
fn title_if_live(conn: &Connection, owner: &str, id: &str) -> Option<String> {
get(conn, id)
.ok()
.flatten()
.filter(|n| !n.tombstoned && n.owner_id == owner)
.map(|n| n.title)
}
pub(super) fn update(
conn: &mut Connection,
owner: &str,
@ -294,9 +304,14 @@ pub(super) fn update(
if let Some(t) = title {
node.title = t;
}
// Frontmatter is a read-only projection (§8.3): strip any leading block a
// client echoes back so it never enters the stored body or the text CRDT.
let body = body.map(|b| crate::frontmatter::strip(&b).to_string());
// Two display projections are undone before the body is stored (§8.3, §8.4):
// strip any echoed-back frontmatter block, then collapse `[[id|name]]` links
// whose label still equals the target's name back to the canonical bare id.
// Both run before the text CRDT diff so neither ever enters the stored body.
let body = body.map(|b| {
let stripped = crate::frontmatter::strip(&b);
crate::wikilink::collapse(stripped, |id| title_if_live(conn, owner, id))
});
let body_changed = match body {
Some(b) => {
let changed = node.body.as_deref() != Some(b.as_str());

View file

@ -0,0 +1,120 @@
//! Wiki-link display projection (tech-spec §8.4). At rest a link is
//! `[[NODEID]]` (or `[[NODEID|custom text]]` when the author gave explicit
//! display text). For readability we **expand** a bare id to
//! `[[NODEID|Current Name]]` on read, and **collapse** a `|text` that still
//! equals the target's current name back to bare on write — so a rename keeps
//! display text fresh while at-rest stays canonical, and an unchanged
//! read→write round-trips. Pure: the id→title lookup is injected (the store
//! wiring lives in the SQLite layer / daemon).
/// Rewrite each `[[…]]` span via `f(target, display) -> Option<replacement>`,
/// where `display` is `None` for a bare `[[target]]` and the text after the
/// first `|` otherwise (both trimmed). `f` returning `None` leaves the span
/// verbatim. Text outside links is preserved exactly.
fn rewrite_spans(body: &str, f: impl Fn(&str, Option<&str>) -> Option<String>) -> String {
let mut out = String::with_capacity(body.len());
let mut rest = body;
while let Some(open) = rest.find("[[") {
out.push_str(&rest[..open]);
let after = &rest[open + 2..];
let Some(close) = after.find("]]") else {
// No closing fence — emit the `[[` literally and scan on.
out.push_str("[[");
rest = after;
continue;
};
let inner = &after[..close];
if inner.contains("[[") {
// A stray `[[` inside — emit the first literally, rescan from it.
out.push_str("[[");
rest = after;
continue;
}
let (target, display) = match inner.split_once('|') {
Some((t, d)) => (t.trim(), Some(d.trim())),
None => (inner.trim(), None),
};
match f(target, display) {
Some(rep) => out.push_str(&rep),
None => {
out.push_str("[[");
out.push_str(inner);
out.push_str("]]");
}
}
rest = &after[close + 2..];
}
out.push_str(rest);
out
}
/// Expand a bare `[[NODEID]]` to `[[NODEID|Current Name]]` when `title_of`
/// knows the id. Links that already carry display text, and ids `title_of`
/// doesn't resolve (e.g. legacy `[[Name]]`), are left untouched.
pub fn expand(body: &str, title_of: impl Fn(&str) -> Option<String>) -> String {
rewrite_spans(body, |target, display| {
if display.is_some() {
return None;
}
title_of(target).map(|t| format!("[[{target}|{t}]]"))
})
}
/// Collapse `[[NODEID|text]]` back to bare `[[NODEID]]` when `text` still equals
/// the target's current name (i.e. it's an auto-expanded label, not a custom
/// override). Everything else — bare ids, real overrides, legacy `[[Name]]` —
/// is left untouched.
pub fn collapse(body: &str, title_of: impl Fn(&str) -> Option<String>) -> String {
rewrite_spans(body, |target, display| match display {
Some(text) if title_of(target).as_deref() == Some(text) => Some(format!("[[{target}]]")),
_ => None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn titles() -> impl Fn(&str) -> Option<String> {
let m: HashMap<&str, &str> = [("01ID", "Roof"), ("02ID", "Garden")].into_iter().collect();
move |id: &str| m.get(id).map(|s| s.to_string())
}
#[test]
fn expand_only_bare_known_ids() {
let t = titles();
assert_eq!(expand("see [[01ID]] now", &t), "see [[01ID|Roof]] now");
// Already-labelled, unknown id, and legacy name are untouched.
assert_eq!(expand("[[01ID|Mine]]", &t), "[[01ID|Mine]]");
assert_eq!(expand("[[unknown]]", &t), "[[unknown]]");
assert_eq!(expand("[[Some Title]]", &t), "[[Some Title]]");
}
#[test]
fn collapse_only_name_matching_labels() {
let t = titles();
// Auto-label collapses; a custom override stays; bare stays.
assert_eq!(collapse("[[01ID|Roof]]", &t), "[[01ID]]");
assert_eq!(collapse("[[01ID|My roof]]", &t), "[[01ID|My roof]]");
assert_eq!(collapse("[[01ID]]", &t), "[[01ID]]");
assert_eq!(collapse("[[Some Title]]", &t), "[[Some Title]]");
}
#[test]
fn expand_then_collapse_round_trips_to_bare() {
let t = titles();
let stored = "a [[01ID]] and [[02ID|My garden]] end";
let shown = expand(stored, &t);
assert_eq!(shown, "a [[01ID|Roof]] and [[02ID|My garden]] end");
assert_eq!(collapse(&shown, &t), stored);
}
#[test]
fn preserves_surrounding_text_and_handles_unterminated() {
let t = titles();
assert_eq!(expand("no links here", &t), "no links here");
assert_eq!(expand("dangling [[01ID", &t), "dangling [[01ID");
assert_eq!(expand("", &t), "");
}
}

View file

@ -0,0 +1,39 @@
//! Wiki-link display projection (tech-spec §8.4): a body's links are stored as
//! canonical bare `[[NODEID]]`. `update_node` collapses a name-matching
//! `[[NODEID|Name]]` label back to bare (the daemon expands it again on read);
//! a custom label is preserved. `get_node` here returns the **raw stored** body
//! (expansion is a daemon-read concern), so it shows the collapsed form.
use heph_core::{FixedClock, LinkType, LocalStore, NewNode, Store};
fn store() -> LocalStore {
LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap()
}
#[test]
fn update_collapses_name_matching_labels_and_materializes_by_id() {
let mut s = store();
let target = s.create_node(NewNode::doc("Roof", "")).unwrap();
let src = s.create_node(NewNode::doc("Daily", "")).unwrap();
// The buffer the user saves carries the expanded label `[[id|Roof]]`.
let updated = s
.update_node(&src.id, None, Some(format!("see [[{}|Roof]] here", target.id)))
.unwrap();
// Stored body collapsed the auto-label back to the canonical bare id.
assert_eq!(
updated.body.as_deref(),
Some(format!("see [[{}]] here", target.id).as_str())
);
// And the `[[id]]` materialized as a wiki link by id.
assert!(s
.outgoing_links(&src.id)
.unwrap()
.iter()
.any(|l| l.link_type == LinkType::Wiki && l.dst_id == target.id));
// A custom label (≠ the target's current name) is a real override — kept.
let custom = format!("[[{}|my roof]]", target.id);
let u2 = s.update_node(&src.id, None, Some(custom.clone())).unwrap();
assert_eq!(u2.body.as_deref(), Some(custom.as_str()));
}

View file

@ -261,6 +261,20 @@ fn subject_task(store: &dyn Store, node: &Node) -> Result<Option<Task>, RpcError
Ok(None)
}
/// Expand bare `[[id]]` links in `body` to `[[id|Current Name]]` for display
/// (§8.4), looking each id up via the store (best-effort: an unknown/tombstoned
/// id or a legacy `[[Name]]` link is left untouched).
fn expand_wikilinks(store: &dyn Store, body: &str) -> String {
heph_core::wikilink::expand(body, |id| {
store
.get_node(id)
.ok()
.flatten()
.filter(|n| !n.tombstoned)
.map(|n| n.title)
})
}
/// The name of the project a task is filed under (its `in-project` link), if any.
fn project_name_of(store: &dyn Store, task_id: &str) -> Result<Option<String>, RpcError> {
for link in store.outgoing_links(task_id)? {
@ -278,21 +292,32 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
"node.get" => {
let p: GetNodeParams = parse(params)?;
match store.get_node(&p.id)? {
Some(mut node) if p.frontmatter => {
// Render the editable frontmatter projection (§8.3) from the
// node's own fields + its (owning) task, and prepend it.
let task = subject_task(store, &node)?;
let tags = store.tags_of(&node.id)?;
let project = match &task {
Some(t) => project_name_of(store, &t.node_id)?,
None => None,
};
let fm =
crate::frontmatter::render(&node, task.as_ref(), project.as_deref(), &tags);
node.body = Some(format!("{fm}{}", node.body.as_deref().unwrap_or("")));
None => Value::Null,
Some(mut node) => {
// Expand `[[id]]` → `[[id|Current Name]]` for readability
// (§8.4); the body collapses back to bare ids on write.
if let Some(b) = node.body.take() {
node.body = Some(expand_wikilinks(store, &b));
}
if p.frontmatter {
// Prepend the editable frontmatter projection (§8.3) from
// the node's own fields + its (owning) task.
let task = subject_task(store, &node)?;
let tags = store.tags_of(&node.id)?;
let project = match &task {
Some(t) => project_name_of(store, &t.node_id)?,
None => None,
};
let fm = crate::frontmatter::render(
&node,
task.as_ref(),
project.as_deref(),
&tags,
);
node.body = Some(format!("{fm}{}", node.body.as_deref().unwrap_or("")));
}
json!(node)
}
other => json!(other),
}
}
"node.create" => {

View file

@ -274,6 +274,42 @@ fn tag_add_list_remove_over_socket() {
);
}
#[test]
fn wikilinks_expand_on_read_and_collapse_on_write_over_socket() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
let target = c
.call("node.create", json!({ "kind": "doc", "title": "Roof" }))
.unwrap();
let tid = target["id"].as_str().unwrap().to_string();
let src = c
.call("node.create", json!({ "kind": "doc", "title": "Daily" }))
.unwrap();
let sid = src["id"].as_str().unwrap().to_string();
// Store a canonical bare link (as the `[[` picker inserts it).
c.call("node.update", json!({ "id": sid, "body": format!("see [[{tid}]]") }))
.unwrap();
// On read, the bare id is expanded to a readable, current-name label.
let got = c.call("node.get", json!({ "id": sid })).unwrap();
assert_eq!(got["body"], json!(format!("see [[{tid}|Roof]]")));
// Saving that expanded buffer back collapses it to the bare id again — a
// no-op round-trip — and the wiki link is materialized by id.
c.call("node.update", json!({ "id": sid, "body": format!("see [[{tid}|Roof]]") }))
.unwrap();
let again = c.call("node.get", json!({ "id": sid })).unwrap();
assert_eq!(again["body"], json!(format!("see [[{tid}|Roof]]")));
let links = c.call("links.outgoing", json!({ "id": sid })).unwrap();
assert!(links
.as_array()
.unwrap()
.iter()
.any(|l| l["link_type"] == "wiki" && l["dst_id"] == tid));
}
#[test]
fn frontmatter_renders_on_read_and_is_stripped_on_write() {
let (socket, _dir) = spawn_daemon();

View file

@ -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; `<CR>` 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 search your nodes and insert a canonical `[[NODEID]]` link, with a "+ Create new doc" entry that mints one on the spot. Following such a link (`<CR>`) jumps straight by id. (Legacy `[[Name]]` links still resolve until a one-time migration rewrites them; readable display/conceal of the ids is next.)
- 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 search your nodes and insert a canonical `[[NODEID]]` link, with a "+ Create new doc" entry that mints one on the spot. Following such a link (`<CR>`) 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. (Legacy `[[Name]]` links still resolve until a one-time migration rewrites them; in-editor conceal of the id is next.)
- 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.

View file

@ -336,7 +336,7 @@ Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`,
- ✅ **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 picker (reuses `picker.lua` / Telescope, **no new dependency**) that searches via the `search` RPC and inserts `[[NODEID]]`; a **"+ Create new doc"** entry mints a `doc` and inserts its id. Follow (`<CR>`) 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):** on **read**, expand a bare `[[NODEID]]``[[NODEID|Current Name]]` so buffers, `heph export`, and any dumb reader show readable, always-fresh link text; on **write**, a `|text` equal to the target's current name **collapses back** to bare. Needs an **id→name batch resolve** RPC.
- **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:** a completed link is **concealed** to its name (or `|text`), rendered as a styled hyperlink (extmark `conceal` + inline virtual text), revealed in raw form when the cursor is on it.
- ⏳ **Migration:** a **one-time fixup** rewrites existing `[[Title]]` bodies to `[[NODEID]]` (resolve→id; flag the unresolvable), after which name-resolution + the canonical-context hack are removed. No special care is warranted (no critical data yet); a first-class migrations feature stays **deferred**.