2026-06-03 11:44:39 -07:00
|
|
|
|
--- 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
|
|
|
|
|
|
|
2026-06-03 11:57:25 -07:00
|
|
|
|
--- 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
|
|
|
|
|
|
|
2026-06-03 11:44:39 -07:00
|
|
|
|
--- 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
|
2026-06-03 11:57:25 -07:00
|
|
|
|
--- 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)
|
2026-06-03 11:44:39 -07:00
|
|
|
|
canonical = canonical or {}
|
|
|
|
|
|
|
2026-06-03 11:57:25 -07:00
|
|
|
|
-- Tags on the opened node = the frontmatter list ∪ inline #hashtags.
|
|
|
|
|
|
fm.tags = union(fm.tags, M.hashtags(body))
|
2026-06-03 11:44:39 -07:00
|
|
|
|
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
|