generated from eblume/project-template
Some checks failed
Build / validate (pull_request) Failing after 5m1s
Node buffers now open with the editable YAML frontmatter block on top
(node.get {frontmatter: true}); on :w, `frontmatter.lua` parses the
block, diffs it against what was rendered, and routes each changed field
to the right RPC:
- title → node.update rename
- attention → task.set_attention
- do_date/late_on/recurrence → task.set_schedule (YYYY-MM-DD → local-ms;
a removed line clears via null)
- project → task.set_project (resolved by name)
- tags → tag.add / tag.remove
A mistyped state surfaces the daemon's validation error; a buffer with no
block edits no metadata (deleting the block can't wipe tags). Body rides
node.update as before (the store strips any echoed frontmatter).
Body-position features are content-relative, so the prepended block
doesn't disturb them; e2e specs that targeted absolute line 1 now locate
body lines by content via a new `h.find` helper. New frontmatter_spec
covers render + the full diff→RPC round-trip. 21 nvim e2e specs green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
172 lines
5.2 KiB
Lua
172 lines
5.2 KiB
Lua
--- 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
|