generated from eblume/project-template
Some checks failed
Build / validate (pull_request) Failing after 4m57s
On save, whitespace-prefixed `#hashtags` in a node's body are unioned into its tag set (via `frontmatter.hashtags` + the existing tag diff), so you can tag a note by writing `#kitchen` inline. A markdown `# heading` has a space after the `#`, so it never matches. e2e covers it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
6.2 KiB
Lua
205 lines
6.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
|
||
|
||
--- 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
|