hephaestus/heph.nvim/lua/heph/frontmatter.lua

205 lines
6.2 KiB
Lua
Raw Normal View History

--- 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