diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 98d4a8c..d9b9ecd 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -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}; diff --git a/crates/heph-core/src/sqlite/nodes.rs b/crates/heph-core/src/sqlite/nodes.rs index 9dbd5db..19e098b 100644 --- a/crates/heph-core/src/sqlite/nodes.rs +++ b/crates/heph-core/src/sqlite/nodes.rs @@ -281,6 +281,16 @@ pub(super) fn get(conn: &Connection, id: &str) -> Result> { /// 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 { + 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()); diff --git a/crates/heph-core/src/wikilink.rs b/crates/heph-core/src/wikilink.rs new file mode 100644 index 0000000..5a01d07 --- /dev/null +++ b/crates/heph-core/src/wikilink.rs @@ -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`, +/// 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 { + 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 { + 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 { + 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 { + 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), ""); + } +} diff --git a/crates/heph-core/tests/wikilinks.rs b/crates/heph-core/tests/wikilinks.rs new file mode 100644 index 0000000..a56fa3e --- /dev/null +++ b/crates/heph-core/tests/wikilinks.rs @@ -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())); +} diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index d2613de..461d882 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -261,6 +261,20 @@ fn subject_task(store: &dyn Store, node: &Node) -> Result, 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, 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 { 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" => { diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 40f59dd..9426258 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -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(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 9d1b55a..5730820 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 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 (``) 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 (``) 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. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 5c5f514..c3b9fa8 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -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 (``) 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**.