diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 48323c9..6371ba0 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,5 +28,6 @@ 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. +- 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). 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/heph-nvim.md b/docs/reference/heph-nvim.md index 99da295..cbfa0dd 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -79,6 +79,14 @@ edits, else adding the `wiki` link directly). | `:Heph promote [attention]` | Promote the `- [ ]` line under the cursor to a committed task | | `:Heph log ` | Append a breadcrumb to the current task's log | +A node buffer opens with an editable **YAML frontmatter** block on top (`id`, +`kind`, `title`, `tags`, and — for a task or its context doc — the task's +`state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` +ref). On `:w`, `frontmatter.lua` diffs the block against what was rendered and +issues the right RPC per changed field (rename, `set_attention`, `set_schedule`, +`set_project`, `tag.add`/`tag.remove`); the store strips the block so it never +enters the body (tech-spec §8.3). A buffer with no block edits no metadata. + "Current task" is resolved from the buffer: a `task` node, or a canonical-context doc whose owning task is followed via its `canonical-context` backlink. The `next`/`list` views render the titled rows the daemon returns (`list` enriched to diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 7fe03b9..e8a23dc 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -300,14 +300,14 @@ Project-subtree resolution needs the **parent-project links** ([[design]] §6.2. > **Future — chores as a first-class feature (noted, not scheduled).** The `Chores` view here is an interim project-scoped filter. The intent is to make **chores a first-class concept** with their own **do-date / recurrence semantics** (distinct from regular tasks), retiring the `#Chores` / `#Camano Chores` *projects* (and the Camano split) entirely — chores would be a task flag/kind, not a project you scope to. When that lands, the `Chores` view becomes "tasks where `is_chore`," and `Schedule` (timed routines) is reconsidered alongside it. See [[design]] §6.2.1. -## 8.3 Frontmatter as an edit surface (backend built; nvim diff layer next) +## 8.3 Frontmatter as an edit surface (built) -> **Status: backend built** (the projection); the `heph.nvim` diff layer is next. When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) is visible and editable as a YAML frontmatter block — without that metadata ever becoming a second, drifting source of truth in the body. +> **Status: built** (inline `#hashtags` on save are a small deferred follow-up). When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) is visible and editable as a YAML frontmatter block atop the body — without that metadata ever becoming a second, drifting source of truth in the body. The resolving principle is a **two-layer split** that keeps the store safe against *any* client while making `heph.nvim` a rich editor: - ✅ **The store is dumb and safe.** Frontmatter is a **projection generated on read** and **stripped + silently ignored on write**. `heph-core::frontmatter::strip(body)` runs inside `update_node` **before** the `yrs` CRDT diff (conservative — it only removes a leading `---` block whose first line is a YAML key, so a leading `---` thematic break in prose survives; idempotent). The **render** side lives in `hephd` (`hephd::frontmatter::render`, since formatting `do_date`/`late_on` for humans needs the local timezone via `datespec::fmt_iso`); `node.get {frontmatter: true}` prepends it. Invariants: the at-rest body and the CRDT doc **never** contain frontmatter; an unchanged read→write round-trips to a no-op. Because inbound frontmatter is always discarded, a naive editor (or the future web UI) **cannot corrupt metadata** — at worst it sends stale frontmatter and the store drops it; the canonical block regenerates on the next read. -- ⏳ **`heph.nvim` is the smart client.** On `BufWriteCmd` it diffs the buffer's frontmatter against the canonical block it was handed and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule`, `project` → **`set_project`** (§8.1), `tags` → `tag.add`/`tag.remove` (§14 tags). Then it strips the frontmatter and sends the body. The frontmatter is thus a genuine declarative edit surface, but the translation lives in the client, not core. +- ✅ **`heph.nvim` is the smart client.** `node.lua` reads with `frontmatter: true` (the buffer opens with the block on top) and caches the rendered block; on `BufWriteCmd`, `frontmatter.lua` parses the buffer's block, **diffs it against the cached one**, and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule` (dates parsed `YYYY-MM-DD` → local-midnight ms; a removed line clears via `null`), `project` → **`set_project`** (resolved by name, §8.1), `tags` → `tag.add`/`tag.remove` (§14 tags); a mistyped `state` surfaces the daemon's validation error. Then it sends the (frontmatter-less) body. A buffer with **no** block touches no metadata — only deleting individual fields edits them — so removing the whole block can't accidentally wipe tags. (Body-position features — `[[link]]` follow, promote — are content-relative, so the prepended block doesn't disturb them.) **Remaining:** inline `#hashtags` detected on save. **The schema** (a curated, *editable* subset — not the full export snapshot). `id`/`kind` are read-only; `title` and `tags` edit the opened node; when the node **is** a task or backs one (its canonical-context doc), the owning task's scalars are rendered and a read-only `task:` id says where those edits route: @@ -468,7 +468,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **(b) sort toggle `s` — DONE:** **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with dimmed **`──── Name ────` separators** riding atop each group's first task (the cursor only lands on real tasks). View filtering always runs **before** the sort. (`skip` moved to `S` to free `s`.) - ✅ **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) — backend DONE, nvim next:** ✅ 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; inbound frontmatter is always stripped (safe vs any client). ⏳ Remaining: the `heph.nvim` diff-on-`BufWriteCmd` → structured RPCs (`title`→rename, `attention`→set_attention, dates→set_schedule, `project`→set_project, `tags`→tag.add/remove) + inline `#hashtags` on save. +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). **Deferred follow-up:** inline `#hashtags` detected 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. 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]]). diff --git a/heph.nvim/lua/heph/frontmatter.lua b/heph.nvim/lua/heph/frontmatter.lua new file mode 100644 index 0000000..de567be --- /dev/null +++ b/heph.nvim/lua/heph/frontmatter.lua @@ -0,0 +1,172 @@ +--- Frontmatter as an edit surface (tech-spec §8.3). The daemon renders an +--- editable YAML block atop a node's body on read (`node.get {frontmatter}`) +--- and strips it on write; this module is the **smart-client** half: parse the +--- buffer's frontmatter and translate each changed field into the right +--- structured RPC (title→rename, attention→set_attention, dates→set_schedule, +--- project→set_project, tags→tag.add/remove). The body itself rides `node.update`. +local rpc = require("heph.rpc") + +local M = {} + +local function trim(s) + return (s:gsub("^%s+", ""):gsub("%s+$", "")) +end + +--- Unquote a YAML scalar (`"…"` with `\"`/`\\` escapes), else return it trimmed. +local function unquote(s) + s = trim(s) + local inner = s:match('^"(.*)"$') + if inner then + return (inner:gsub('\\"', '"'):gsub("\\\\", "\\")) + end + return s +end + +--- Parse a YAML flow sequence `[a, b]` (or `[]`) into a list of scalars. +local function parse_flow_seq(s) + local inner = s:match("^%[(.*)%]$") + if not inner then + return {} + end + local out = {} + for item in (inner .. ","):gmatch("(.-),") do + item = trim(item) + if item ~= "" then + out[#out + 1] = unquote(item) + end + end + return out +end + +--- Parse a leading `---` frontmatter block. Returns `(fm, body)` where `fm` is a +--- table of string fields plus `tags` (a list), or `(nil, text)` when there is +--- no conforming block (so a frontmatter-less buffer never looks like metadata). +function M.parse(text) + if text:sub(1, 4) ~= "---\n" then + return nil, text + end + local lines = vim.split(text, "\n", { plain = true }) + local close + for i = 2, #lines do + if lines[i] == "---" then + close = i + break + end + end + if not close then + return nil, text + end + local fm = { tags = {} } + for j = 2, close - 1 do + local key, val = lines[j]:match("^([%w_%-]+):%s*(.*)$") + if key == "tags" then + fm.tags = parse_flow_seq(val) + elseif key then + fm[key] = unquote(val) + end + end + local body = table.concat({ unpack(lines, close + 1) }, "\n") + return fm, body +end + +--- A `YYYY-MM-DD` string → epoch ms at **local midnight** (matching the +--- daemon's `datespec`). Returns `nil, err` on a malformed date. +local function date_to_ms(s) + local y, m, d = tostring(s):match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$") + if not y then + return nil, "expected a YYYY-MM-DD date, got " .. tostring(s) + end + return os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d), hour = 0, min = 0, sec = 0 }) * 1000 +end + +--- Set difference of two scalar lists → (added, removed). +local function set_diff(old, new) + local oldset, newset = {}, {} + for _, x in ipairs(old or {}) do + oldset[x] = true + end + for _, x in ipairs(new or {}) do + newset[x] = true + end + local added, removed = {}, {} + for x in pairs(newset) do + if not oldset[x] then + added[#added + 1] = x + end + end + for x in pairs(oldset) do + if not newset[x] then + removed[#removed + 1] = x + end + end + return added, removed +end + +--- Apply the difference between `canonical` (rendered on read) and the buffer's +--- `fm` by issuing the matching RPCs. `node_id` is the opened node (title/tags +--- target); task scalars route to the owning task (`fm.task`). Raises on any +--- RPC error (e.g. a mistyped `state` or an unknown `project`). +function M.apply(node_id, canonical, fm) + canonical = canonical or {} + + -- Tags on the opened node. + local added, removed = set_diff(canonical.tags, fm.tags) + for _, t in ipairs(added) do + rpc.call("tag.add", { node_id = node_id, tag = t }) + end + for _, t in ipairs(removed) do + rpc.call("tag.remove", { node_id = node_id, tag = t }) + end + + -- Task scalars route to the owning task, if this node is or backs one. + local task = fm.task or canonical.task + if task then + if fm.attention and fm.attention ~= canonical.attention then + rpc.call("task.set_attention", { id = task, attention = fm.attention }) + end + + -- Schedule (double-option): a present value sets, a removed line clears. + local patch, changed = { id = task }, false + local function sched(field) + if fm[field] == canonical[field] then + return + end + changed = true + if fm[field] == nil or fm[field] == "" then + patch[field] = vim.NIL -- cleared + elseif field == "recurrence" then + patch[field] = fm[field] -- a raw RRULE + else + local ms, err = date_to_ms(fm[field]) + if not ms then + error("heph: " .. err) + end + patch[field] = ms + end + end + sched("do_date") + sched("late_on") + sched("recurrence") + if changed then + rpc.call("task.set_schedule", patch) + end + + if fm.state and fm.state ~= canonical.state then + rpc.call("task.set_state", { id = task, state = fm.state }) -- a typo → rpc error + end + + if fm.project ~= canonical.project then + local pid = vim.NIL -- removed / blank → unfile + if fm.project and fm.project ~= "" then + local node = rpc.call("node.resolve", { title = fm.project }) + if not node then + error("heph: no node named '" .. fm.project .. "' to file under") + end + pid = node.id + end + rpc.call("task.set_project", { id = task, project_id = pid }) + end + end +end + +return M diff --git a/heph.nvim/lua/heph/node.lua b/heph.nvim/lua/heph/node.lua index 05a812c..0d4be1c 100644 --- a/heph.nvim/lua/heph/node.lua +++ b/heph.nvim/lua/heph/node.lua @@ -1,26 +1,34 @@ --- Buffer-backed nodes (tech-spec §8): a node's markdown body is edited in a ---- real buffer named `heph://node/`. `:e` loads it via `node.get`; `:w` ---- saves the whole buffer back via `node.update` (the backend CRDT-diffs the ---- whole-buffer text, so sending the full body is correct and idempotent). +--- real buffer named `heph://node/`. `:e` loads it via `node.get` +--- (with the editable YAML **frontmatter** block prepended, §8.3); `:w` diffs +--- the frontmatter into structured RPCs, then saves the body via `node.update` +--- (the backend CRDT-diffs the whole body, and strips any frontmatter echoed +--- back — so sending the stripped body is correct and idempotent). local rpc = require("heph.rpc") local util = require("heph.util") +local frontmatter = require("heph.frontmatter") local M = {} ---- `BufReadCmd` handler for `heph://node/`: load the body into the buffer. +-- buf -> the frontmatter table rendered on read, so `write` can diff against it. +M._canonical = {} + +--- `BufReadCmd` handler for `heph://node/`: load frontmatter + body. function M.read(buf, uri) local _, id = util.parse_uri(uri) if not id then error("heph: not a node uri: " .. tostring(uri)) end - local node = rpc.call("node.get", { id = id }) - local body = (node and node.body) or "" + local node = rpc.call("node.get", { id = id, frontmatter = true }) + local text = (node and node.body) or "" + local fm = frontmatter.parse(text) -- `plain` split keeps a trailing "" element for a trailing newline, so the - -- body round-trips exactly through `table.concat` on write. - vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(body, "\n", { plain = true })) + -- text round-trips exactly through `table.concat` on write. + vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(text, "\n", { plain = true })) vim.b[buf].heph_node_id = id vim.b[buf].heph_node_kind = (node and node.kind) or "doc" + M._canonical[buf] = fm or {} vim.bo[buf].buftype = "acwrite" -- written via BufWriteCmd, not to a file vim.bo[buf].filetype = "markdown" vim.bo[buf].fileformat = "unix" @@ -28,14 +36,32 @@ function M.read(buf, uri) require("heph.link").attach(buf) end ---- `BufWriteCmd` handler: persist the whole buffer as the node body. +--- `BufWriteCmd` handler: route frontmatter edits to RPCs, then save the body. function M.write(buf, _uri) local id = vim.b[buf].heph_node_id if not id then error("heph: buffer has no heph node id") end - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - rpc.call("node.update", { id = id, body = table.concat(lines, "\n") }) + local text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") + local fm, body = frontmatter.parse(text) + + local params = { id = id } + if fm then + -- A frontmatter block is present: translate its edits to RPCs. (Absent + -- block ⇒ the user removed it; treat the whole buffer as body, touch no + -- metadata.) The backend also strips defensively, so `body` is what stores. + local canonical = M._canonical[buf] or {} + frontmatter.apply(id, canonical, fm) + if fm.title and fm.title ~= canonical.title then + params.title = fm.title + end + params.body = body + M._canonical[buf] = fm + else + params.body = text + end + + rpc.call("node.update", params) vim.bo[buf].modified = false end diff --git a/heph.nvim/tests/e2e/follow_link_spec.lua b/heph.nvim/tests/e2e/follow_link_spec.lua index 2079ace..1e0b51b 100644 --- a/heph.nvim/tests/e2e/follow_link_spec.lua +++ b/heph.nvim/tests/e2e/follow_link_spec.lua @@ -16,11 +16,10 @@ describe("follow link", function() local a = h.create_doc("A", "see [[B]] here") local buf = h.open(a.id) - local line = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] - local open_at = line:find("%[%[B") -- start of "[[B" - assert.is_truthy(open_at) + local lnum, col = h.find(buf, "%[%[B") -- the body line, below the frontmatter + assert.is_truthy(lnum) -- Put the cursor on the target inside the brackets. - vim.api.nvim_win_set_cursor(0, { 1, open_at + 1 }) + vim.api.nvim_win_set_cursor(0, { lnum, col + 2 }) require("heph.link").follow() @@ -32,8 +31,8 @@ describe("follow link", function() it("creates the target doc when following an unresolved [[link]]", function() local a = h.create_doc("Daily", "see [[New Topic]]") local buf = h.open(a.id) - local at = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1]:find("%[%[New") - vim.api.nvim_win_set_cursor(0, { 1, at + 1 }) -- inside [[New Topic]] + local lnum, col = h.find(buf, "%[%[New") + vim.api.nvim_win_set_cursor(0, { lnum, col + 2 }) -- inside [[New Topic]] require("heph.link").follow() diff --git a/heph.nvim/tests/e2e/frontmatter_spec.lua b/heph.nvim/tests/e2e/frontmatter_spec.lua new file mode 100644 index 0000000..496bd30 --- /dev/null +++ b/heph.nvim/tests/e2e/frontmatter_spec.lua @@ -0,0 +1,85 @@ +-- Frontmatter edit surface (tech-spec §8.3): a node buffer opens with an +-- editable YAML block on top, and saving routes each changed field to the right +-- structured RPC (rename / set_attention / set_schedule / set_project / +-- tag.add). The body itself still round-trips through node.update. + +local h = require("e2e.helpers") + +-- Replace `key: …` in the frontmatter `lines`, or insert it before the closing +-- `---` fence when the key isn't present yet. +local function set_field(lines, key, value) + for i, line in ipairs(lines) do + if line:match("^" .. key .. ":") then + lines[i] = key .. ": " .. value + return + end + end + local fences = 0 + for i, line in ipairs(lines) do + if line == "---" then + fences = fences + 1 + if fences == 2 then + table.insert(lines, i, key .. ": " .. value) + return + end + end + end +end + +describe("frontmatter edit surface", function() + local ctx + before_each(function() + ctx = h.start() + end) + after_each(function() + h.stop(ctx) + end) + + it("renders an editable block atop a node body", function() + local doc = h.create_doc("Roof", "# Roof\n\nnotes") + local buf = h.open(doc.id) + local first = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] + assert.are.equal("---", first, "buffer should open with a frontmatter fence") + assert.is_truthy(h.find(buf, "^title: Roof"), "title not in frontmatter") + assert.is_truthy(h.find(buf, "# Roof"), "body content missing below frontmatter") + end) + + it("routes frontmatter edits to structured RPCs on save", function() + local proj = ctx.q:call("node.create", { kind = "project", title = "Camano" }) + local task = ctx.q:call("task.create", { title = "Fix roof", attention = "red" }) + local ctxid + for _, l in ipairs(ctx.q:call("links.outgoing", { id = task.node_id })) do + if l.link_type == "canonical-context" then + ctxid = l.dst_id + end + end + + local buf = h.open(ctxid) + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + set_field(lines, "title", "Fix the roof") -- rename the (doc) node + set_field(lines, "attention", "blue") -- → task.set_attention + set_field(lines, "tags", "[roofing]") -- → tag.add on the doc + set_field(lines, "project", "Camano") -- → task.set_project + set_field(lines, "do_date", "2026-06-10") -- → task.set_schedule + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + h.save(buf) + + -- The task picked up attention, project, and a do-date. + local t = ctx.q:call("task.get", { id = task.node_id }) + assert.are.equal("blue", t.attention) + assert.is_truthy(t.do_date, "do_date should be set") + local filed = false + for _, l in ipairs(ctx.q:call("links.outgoing", { id = task.node_id })) do + if l.link_type == "in-project" and l.dst_id == proj.id then + filed = true + end + end + assert.is_true(filed, "task should be filed under Camano") + + -- The opened node was renamed and tagged. + assert.are.equal("Fix the roof", ctx.q:call("node.get", { id = ctxid }).title) + local tags = ctx.q:call("tag.list", { node_id = ctxid }) + assert.are.equal(1, #tags) + assert.are.equal("roofing", tags[1]) + end) +end) diff --git a/heph.nvim/tests/e2e/helpers.lua b/heph.nvim/tests/e2e/helpers.lua index f1a2dcb..f69c2be 100644 --- a/heph.nvim/tests/e2e/helpers.lua +++ b/heph.nvim/tests/e2e/helpers.lua @@ -178,6 +178,19 @@ function M.open(id) return vim.api.nvim_get_current_buf() end +--- The (1-based line, 0-based byte col) of the first match of Lua `pattern` in +--- `buf`, or nil. Lets specs target body content by what it says rather than an +--- absolute line number — node buffers now carry a frontmatter block on top +--- (tech-spec §8.3), so the body no longer starts at line 1. +function M.find(buf, pattern) + for i, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do + local s = line:find(pattern) + if s then + return i, s - 1 + end + end +end + function M.set_lines(buf, lines) vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) end diff --git a/heph.nvim/tests/e2e/promote_spec.lua b/heph.nvim/tests/e2e/promote_spec.lua index df7e625..203075f 100644 --- a/heph.nvim/tests/e2e/promote_spec.lua +++ b/heph.nvim/tests/e2e/promote_spec.lua @@ -15,7 +15,8 @@ describe("promote context item", function() it("mints a task, links the source line, and surfaces it in next", function() local container = h.create_doc("Errands", "- [ ] call plumber") local buf = h.open(container.id) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) -- on the context-item line + local lnum = h.find(buf, "call plumber") -- the context-item line, below frontmatter + vim.api.nvim_win_set_cursor(0, { lnum, 0 }) local task = require("heph.task").promote_under_cursor({ attention = "orange" }) assert.is_truthy(task.node_id) @@ -32,7 +33,7 @@ describe("promote context item", function() -- The source line became a wiki-link to the task (persisted + in-buffer). assert.are.equal("- [[call plumber]]", ctx.q:call("node.get", { id = container.id }).body) local reloaded = vim.api.nvim_get_current_buf() - assert.are.equal("- [[call plumber]]", vim.api.nvim_buf_get_lines(reloaded, 0, 1, false)[1]) + assert.is_truthy(h.find(reloaded, "%- %[%[call plumber%]%]"), "rewritten link line missing from buffer") -- ...and the container backlinks the task. local linked = false