generated from eblume/project-template
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:
parent
4e8f6743cf
commit
ef2081fd8b
8 changed files with 254 additions and 18 deletions
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
120
crates/heph-core/src/wikilink.rs
Normal file
120
crates/heph-core/src/wikilink.rs
Normal 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), "");
|
||||
}
|
||||
}
|
||||
39
crates/heph-core/tests/wikilinks.rs
Normal file
39
crates/heph-core/tests/wikilinks.rs
Normal 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()));
|
||||
}
|
||||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue