diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs index ab20b15..0783e16 100644 --- a/crates/heph-core/src/sqlite/links.rs +++ b/crates/heph-core/src/sqlite/links.rs @@ -169,6 +169,15 @@ pub(super) fn sync_wiki_links( /// first, then an exact title. `None` if nothing matches. Shared by `wiki` /// link materialization and the `node.resolve` surface (tech-spec §5, §6). pub(super) fn resolve_id(conn: &Connection, owner: &str, target: &str) -> Result> { + // 1. An exact **node id** — the canonical at-rest link form (`[[NODEID]]`, + // tech-spec §8.4). Checked first so id-addressed links never collide with a + // like-named node. (Legacy `[[Name]]` links still resolve below, until the + // one-time migration rewrites them.) + if let Some(node) = super::nodes::get(conn, target)? { + if node.owner_id == owner && !node.tombstoned { + return Ok(Some(node.id)); + } + } let by_alias: Option = conn .query_row( "SELECT n.id FROM aliases a JOIN nodes n ON n.id = a.node_id diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index c889cb5..40f59dd 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -95,6 +95,13 @@ fn node_resolve_is_exact_not_fuzzy_over_socket() { let got = c.call("node.resolve", json!({ "title": "Roof" })).unwrap(); assert_eq!(got["id"], target_id); + // A node id resolves to itself — the canonical `[[NODEID]]` link form (§8.4), + // checked ahead of name resolution. + let by_id = c + .call("node.resolve", json!({ "title": target_id })) + .unwrap(); + assert_eq!(by_id["id"], target_id); + // An unresolved link is JSON null, not an error (tech-spec §5). let missing = c .call("node.resolve", json!({ "title": "Nonexistent" })) diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 451405b..9d1b55a 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,6 +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.) - 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 201142e..5c5f514 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -329,15 +329,16 @@ Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, **Inline `#hashtags`** are a **`heph.nvim` feature**, not core extraction (for now): the plugin detects them on save and routes them through the same `tag.add`/`tag.remove` path. (Core-side hashtag extraction can come later, e.g. for the zk import.) -## 8.4 Wiki-links by node id (planned) +## 8.4 Wiki-links by node id (authoring built; display + migration next) -> **Status: planned** (§14 roadmap, 2026-06-03). Today bodies store the human text `[[Title]]` and links are 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**. +> **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**. -- **At rest:** `[[NODEID]]`, or `[[NODEID|custom text]]` when the author wrote explicit display text. Extraction reads the id directly — no resolution, no ambiguity — and the canonical-context exclusion hack in `resolve_id` is **removed**. -- **Projection (same philosophy as §8.3):** on **read**, `heph-core` expands a bare `[[NODEID]]` → `[[NODEID|Current Name]]`, so buffers, `heph export`, and any dumb reader show readable, always-fresh link text. On **write**, a `|text` that equals the target's current name **collapses back** to bare `[[NODEID]]`; a `|text` that differs is preserved as a real override. Needs an **id→name batch resolve** RPC for the expansion. -- **`heph.nvim` authoring:** typing `[[` triggers a picker (reuse `picker.lua` / Telescope, **no new dependency**) that searches titles via the `search` RPC and inserts `[[NODEID]]`; a **"Create new: «typed»"** entry mints a `doc` and inserts its id. -- **`heph.nvim` display:** a completed link is **concealed** to its name (or `|text`), rendered as a styled hyperlink (extmark `conceal` + inline virtual text), and revealed in raw form when the cursor is on it. -- **Migration:** a **one-time fixup script** rewrites existing `[[Title]]` bodies to `[[NODEID]]` (resolve→id; flag the unresolvable). No special care is warranted — there is no critical data in the store yet — and a first-class migrations feature stays **deferred**. +- ✅ **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. +- ⏳ **`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**. ## 9. Testing strategy (TDD, layered) @@ -469,7 +470,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **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) — 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) — DONE:** the projection — `heph-core::frontmatter::strip` (conservative, runs in `update_node` before the CRDT diff) + `hephd::frontmatter::render` (local-tz dates via `datespec::fmt_iso`) behind `node.get {frontmatter: true}`; a task's context-doc surfaces the owning task's scalars + a `task:` ref; round-trip is a no-op and inbound frontmatter is always stripped (safe vs any client). And the `heph.nvim` smart client (`frontmatter.lua`): the buffer opens with the editable block, and `BufWriteCmd` diffs it → `title`→rename / `attention`→set_attention / dates→set_schedule / `project`→set_project / `tags`→tag.add·remove (a no-block buffer touches no metadata), and inline `#hashtags` in the body are unioned into the tag set on save. -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. +5. ◐ **Wiki-links by node id (§8.4) — authoring DONE, display + migration next:** ✅ id-first resolution (`[[NODEID]]` resolves ahead of name; legacy `[[Name]]` still works) + the `heph.nvim` `[[` picker (`search` → insert `[[NODEID]]`, "+ Create" mints a doc) + id-direct follow. ⏳ Remaining: read-expansion/write-collapse projection (`[[ID]]`⟷`[[ID|Name]]`, needs an id→name batch RPC), conceal display, and the one-time `[[Title]]`→`[[ID]]` migration (then retire name-resolution + the canonical-context hack). 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). 7. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]). 8. ⏳ **Adoption refinement + multi-tenant (§13) — before v1 done, low priority:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension. diff --git a/heph.nvim/lua/heph/command.lua b/heph.nvim/lua/heph/command.lua index 774c58e..620ebb9 100644 --- a/heph.nvim/lua/heph/command.lua +++ b/heph.nvim/lua/heph/command.lua @@ -25,6 +25,9 @@ M.subs = { follow = function() require("heph.link").follow() end, + link = function() + require("heph.link").insert() + end, open = function(args) if args[1] then require("heph.node").open(args[1]) diff --git a/heph.nvim/lua/heph/link.lua b/heph.nvim/lua/heph/link.lua index 7b8e963..bbff461 100644 --- a/heph.nvim/lua/heph/link.lua +++ b/heph.nvim/lua/heph/link.lua @@ -68,12 +68,48 @@ function M.follow() require("heph.node").open(node.id) end ---- Attach the buffer-local `` follow keymap and inline-`#hashtag` +--- Pick a node (by full-text search) and insert a canonical `[[NODEID]]` link at +--- the cursor — the authoring path for wiki-links-by-id (§8.4); a node id is the +--- only thing that ever enters a stored link, so there's no name ambiguity. A +--- "Create" entry mints a new doc named after the query. No-op if cancelled. +function M.insert() + vim.ui.input({ prompt = "Link to: " }, function(query) + if not query or query == "" then + return + end + local items = {} + for _, hit in ipairs(rpc.call("search", { query = query }) or {}) do + items[#items + 1] = hit + end + items[#items + 1] = { __create = true, title = query } + require("heph.picker").select(items, { + prompt = "Link to", + format = function(it) + if it.__create then + return "+ Create new doc: " .. it.title + end + return it.title .. " [" .. (it.kind or "node") .. "]" + end, + }, function(choice) + if not choice then + return + end + local id = choice.__create and rpc.call("node.create", { kind = "doc", title = choice.title }).id or choice.id + vim.api.nvim_put({ "[[" .. id .. "]]" }, "c", true, true) + end) + end) +end + +--- Attach the buffer-local follow/insert keymaps and inline-`#hashtag` --- highlighting (only on heph:// buffers). function M.attach(buf) vim.keymap.set("n", "", function() M.follow() end, { buffer = buf, desc = "heph: follow [[link]]" }) + -- Typing `[[` opens the node picker (Obsidian-style), inserting `[[NODEID]]`. + vim.keymap.set("i", "[[", function() + M.insert() + end, { buffer = buf, desc = "heph: insert [[link]]" }) -- Render inline #hashtags in italics so they stand out — matching the -- save-time tag detection (whitespace-prefixed `#word`, never a `# heading`). diff --git a/heph.nvim/tests/e2e/link_insert_spec.lua b/heph.nvim/tests/e2e/link_insert_spec.lua new file mode 100644 index 0000000..9a5a8ef --- /dev/null +++ b/heph.nvim/tests/e2e/link_insert_spec.lua @@ -0,0 +1,73 @@ +-- Wiki-links by node id (§8.4): the `[[` picker searches nodes and inserts a +-- canonical `[[NODEID]]` link; a "Create" entry mints a new doc. The inserted +-- id resolves on follow and materializes as a `wiki` link on save. + +local h = require("e2e.helpers") + +describe("link insert picker", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + -- Drive `link.insert` with a stubbed query + choice. + local function with_picker(query, pick, fn) + vim.g.heph_force_ui_select = true + local oi, os = vim.ui.input, vim.ui.select + vim.ui.input = function(_o, cb) + cb(query) + end + vim.ui.select = function(items, _o, cb) + cb(pick(items)) + end + local ok, err = pcall(fn) + vim.ui.input, vim.ui.select, vim.g.heph_force_ui_select = oi, os, nil + assert.is_true(ok, tostring(err)) + end + + it("inserts a canonical [[NODEID]] for a searched node and materializes the link", function() + local target = h.create_doc("Roofing", "the roofing doc") + local src = h.create_doc("Daily", "") + local buf = h.open(src.id) + -- Put the cursor on an empty body line below the frontmatter. + vim.api.nvim_buf_set_lines(buf, -1, -1, false, { "" }) + vim.api.nvim_win_set_cursor(0, { vim.api.nvim_buf_line_count(buf), 0 }) + + with_picker("roofing", function(items) + return items[1] -- the search hit (FTS matches the "roofing" token) + end, function() + require("heph.link").insert() + end) + + -- The buffer now carries `[[]]`, and saving materializes the link. + assert.is_truthy(h.find(buf, "%[%[" .. target.id), "[[id]] not inserted") + h.save(buf) + local linked = false + for _, l in ipairs(ctx.q:call("links.backlinks", { id = target.id })) do + if l.src_id == src.id and l.link_type == "wiki" then + linked = true + end + end + assert.is_true(linked, "expected a wiki link from src to the picked node") + end) + + it("creates a new doc when the Create entry is chosen", function() + local src = h.create_doc("Notes", "") + local buf = h.open(src.id) + vim.api.nvim_buf_set_lines(buf, -1, -1, false, { "" }) + vim.api.nvim_win_set_cursor(0, { vim.api.nvim_buf_line_count(buf), 0 }) + + with_picker("Brand New Topic", function(items) + return items[#items] -- the "+ Create" sentinel is last + end, function() + require("heph.link").insert() + end) + + local created = ctx.q:call("node.resolve", { title = "Brand New Topic" }) + assert.is_truthy(created, "create entry should mint the doc") + assert.is_truthy(h.find(buf, "%[%[" .. created.id), "[[new id]] not inserted") + end) +end)