--- 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 --- Whitespace-prefixed inline `#hashtags` in `body`, de-duplicated in order. --- A markdown heading (`# Title`, `## foo`) has a space after the `#`, so it --- never matches. (Scanned across the whole body, code fences included — a --- pragmatic v1 simplification.) function M.hashtags(body) local seen, out = {}, {} for tag in (" " .. (body or "")):gmatch("%s#([%w_%-]+)") do if not seen[tag] then seen[tag] = true out[#out + 1] = tag end end return out end --- Union of two scalar lists, order-stable, de-duplicated. local function union(a, b) local seen, out = {}, {} for _, list in ipairs({ a or {}, b or {} }) do for _, x in ipairs(list) do if not seen[x] then seen[x] = true out[#out + 1] = x end end end return out 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`). Inline --- `#hashtags` in `body` are unioned into the desired tag set (so they manage --- tags too); `fm.tags` is updated to that union so the caller caches it as the --- new canonical. Raises on any RPC error (a mistyped `state`, an unknown --- `project`). function M.apply(node_id, canonical, fm, body) canonical = canonical or {} -- Tags on the opened node = the frontmatter list ∪ inline #hashtags. fm.tags = union(fm.tags, M.hashtags(body)) 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