feat(detail): parse and validate editable frontmatter on save (#163)

Problem: the detail buffer rendered metadata as read-only virtual text
overlays. Users could not edit status, priority, category, due, or
recurrence from the detail view.

Solution: render frontmatter as real `Key: value` text lines highlighted
via extmarks. On `:w`, `parse_detail_frontmatter()` validates every
field (status, priority bounds, `resolve_date`, `recur.validate`) and
aborts with `log.error()` on any invalid input. Removing a line clears
the field; editing the `# title` updates the description.
This commit is contained in:
Barrett Ruth 2026-03-13 11:06:45 -04:00 committed by Barrett Ruth
parent 32e1175736
commit e8ebe154a1
2 changed files with 585 additions and 67 deletions

View file

@ -824,59 +824,46 @@ function M.detail_task_id()
return _detail_task_id
end
---@param bufnr integer
local VALID_STATUSES = {
pending = true,
done = true,
wip = true,
blocked = true,
cancelled = true,
}
---@param task pending.Task
---@return nil
local function apply_detail_extmarks(bufnr, task)
vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1)
local icons = config.get().icons
local parts = {}
local status_label = task.status or 'pending'
local icon_char = icons[status_label] or icons.pending
table.insert(parts, { 'Status: [' .. icon_char .. '] ' .. status_label, 'PendingDetailMeta' })
if task.priority and task.priority > 0 then
table.insert(parts, { ' ', 'Normal' })
table.insert(
parts,
{ 'Priority: ' .. string.rep(icons.priority, task.priority), 'PendingDetailMeta' }
)
end
local line2 = {}
---@return string[]
local function build_detail_frontmatter(task)
local lines = {}
table.insert(lines, 'Status: ' .. (task.status or 'pending'))
table.insert(lines, 'Priority: ' .. (task.priority or 0))
if task.category then
table.insert(line2, { 'Category: ' .. task.category, 'PendingDetailMeta' })
table.insert(lines, 'Category: ' .. task.category)
end
if task.due then
if #line2 > 0 then
table.insert(line2, { ' ', 'Normal' })
end
local due_label = task.due
local y, mo, d = task.due:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)')
if y then
local t = os.time({
year = tonumber(y) --[[@as integer]],
month = tonumber(mo) --[[@as integer]],
day = tonumber(d) --[[@as integer]],
})
due_label = os.date(config.get().date_format, t) --[[@as string]]
end
table.insert(line2, { 'Due: ' .. due_label, 'PendingDetailMeta' })
table.insert(lines, 'Due: ' .. task.due)
end
if task.recur then
if #line2 > 0 then
table.insert(line2, { ' ', 'Normal' })
local recur_val = task.recur
if task.recur_mode == 'completion' then
recur_val = '!' .. recur_val
end
table.insert(line2, { 'Recur: ' .. task.recur, 'PendingDetailMeta' })
table.insert(lines, 'Recur: ' .. recur_val)
end
if #parts > 0 then
vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 1, 0, {
virt_text = parts,
virt_text_pos = 'overlay',
})
end
if #line2 > 0 then
vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 2, 0, {
virt_text = line2,
virt_text_pos = 'overlay',
return lines
end
---@param bufnr integer
---@param sep_row integer
---@return nil
local function apply_detail_extmarks(bufnr, sep_row)
vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1)
for i = 1, sep_row - 1 do
vim.api.nvim_buf_set_extmark(bufnr, ns_detail, i, 0, {
end_row = i,
end_col = #(vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] or ''),
hl_group = 'PendingDetailMeta',
})
end
end
@ -909,12 +896,12 @@ function M.open_detail(task_id)
vim.bo[bufnr].filetype = 'markdown'
vim.bo[bufnr].swapfile = false
local lines = {
'# ' .. task.description,
'',
'',
DETAIL_SEPARATOR,
}
local lines = { '# ' .. task.description }
local fm = build_detail_frontmatter(task)
for _, fl in ipairs(fm) do
table.insert(lines, fl)
end
table.insert(lines, DETAIL_SEPARATOR)
local notes = task.notes or ''
if notes ~= '' then
for note_line in (notes .. '\n'):gmatch('(.-)\n') do
@ -927,7 +914,8 @@ function M.open_detail(task_id)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.bo[bufnr].modified = false
apply_detail_extmarks(bufnr, task)
local sep_row = #fm + 1
apply_detail_extmarks(bufnr, sep_row)
local winid = task_winid
if winid and vim.api.nvim_win_is_valid(winid) then
@ -941,8 +929,7 @@ function M.open_detail(task_id)
_detail_bufnr = bufnr
_detail_task_id = task_id
local separator_row = 3
local cursor_row = separator_row + 2
local cursor_row = sep_row + 2
local total = vim.api.nvim_buf_line_count(bufnr)
if cursor_row > total then
cursor_row = total
@ -969,6 +956,124 @@ function M.close_detail()
end
end
---@param lines string[]
---@return integer? sep_row
---@return pending.DetailFields? fields
---@return string? err
local function parse_detail_frontmatter(lines)
local parse = require('pending.parse')
local recur = require('pending.recur')
local cfg = config.get()
local sep_row = nil
for i, line in ipairs(lines) do
if line == DETAIL_SEPARATOR then
sep_row = i
break
end
end
if not sep_row then
return nil, nil, 'missing separator (---)'
end
local desc = lines[1] and lines[1]:match('^# (.+)$')
if not desc or desc:match('^%s*$') then
return nil, nil, 'missing or empty title (first line must be # <title>)'
end
---@class pending.DetailFields
---@field description string
---@field status pending.TaskStatus
---@field priority integer
---@field category? string|userdata
---@field due? string|userdata
---@field recur? string|userdata
---@field recur_mode? pending.RecurMode|userdata
local fields = {
description = desc,
status = 'pending',
priority = 0,
category = vim.NIL,
due = vim.NIL,
recur = vim.NIL,
recur_mode = vim.NIL,
}
local seen = {} ---@type table<string, boolean>
for i = 2, sep_row - 1 do
local line = lines[i]
if line:match('^%s*$') then
goto continue
end
local key, val = line:match('^(%S+):%s*(.*)$')
if not key then
return nil, nil, 'invalid frontmatter line: ' .. line
end
key = key:lower()
if seen[key] then
return nil, nil, 'duplicate field: ' .. key
end
seen[key] = true
if key == 'status' then
val = val:lower()
if not VALID_STATUSES[val] then
return nil, nil, 'invalid status: ' .. val
end
fields.status = val --[[@as pending.TaskStatus]]
elseif key == 'priority' then
local n = tonumber(val)
if not n or n ~= math.floor(n) or n < 0 then
return nil, nil, 'invalid priority: ' .. val .. ' (must be integer >= 0)'
end
local max = cfg.max_priority or 3
if n > max then
return nil, nil, 'invalid priority: ' .. val .. ' (max is ' .. max .. ')'
end
fields.priority = n --[[@as integer]]
elseif key == 'category' then
if val == '' then
return nil, nil, 'empty category value'
end
fields.category = val
elseif key == 'due' then
if val == '' then
return nil, nil, 'empty due value (remove the line to clear)'
end
local resolved = parse.resolve_date(val)
if resolved then
fields.due = resolved
elseif
val:match('^%d%d%d%d%-%d%d%-%d%d$') or val:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$')
then
fields.due = val
else
return nil, nil, 'invalid due date: ' .. val
end
elseif key == 'recur' then
if val == '' then
return nil, nil, 'empty recur value (remove the line to clear)'
end
local raw_spec = val
local rec_mode = nil
if raw_spec:sub(1, 1) == '!' then
rec_mode = 'completion'
raw_spec = raw_spec:sub(2)
end
if not recur.validate(raw_spec) then
return nil, nil, 'invalid recurrence: ' .. val
end
fields.recur = raw_spec
fields.recur_mode = rec_mode or vim.NIL
else
return nil, nil, 'unknown field: ' .. key
end
::continue::
end
return sep_row, fields, nil
end
---@return nil
function M.save_detail()
if not _detail_bufnr or not _detail_task_id or not _store then
@ -983,16 +1088,16 @@ function M.save_detail()
local lines = vim.api.nvim_buf_get_lines(_detail_bufnr, 0, -1, false)
local sep_row = nil
for i, line in ipairs(lines) do
if line == DETAIL_SEPARATOR then
sep_row = i
break
end
local sep_row, fields, err = parse_detail_frontmatter(lines)
if err then
log.error(err)
return
end
---@cast sep_row integer
---@cast fields pending.DetailFields
local notes_text = ''
if sep_row and sep_row < #lines then
if sep_row < #lines then
local note_lines = {}
for i = sep_row + 1, #lines do
table.insert(note_lines, lines[i])
@ -1001,18 +1106,29 @@ function M.save_detail()
notes_text = notes_text:gsub('%s+$', '')
end
local update = {
description = fields.description,
status = fields.status,
priority = fields.priority,
category = fields.category,
due = fields.due,
recur = fields.recur,
recur_mode = fields.recur_mode,
}
if notes_text == '' then
_store:update(_detail_task_id, { notes = vim.NIL })
update.notes = vim.NIL
else
_store:update(_detail_task_id, { notes = notes_text })
update.notes = notes_text
end
_store:update(_detail_task_id, update)
_store:save()
vim.bo[_detail_bufnr].modified = false
local updated = _store:get(_detail_task_id)
if updated then
apply_detail_extmarks(_detail_bufnr, updated)
end
apply_detail_extmarks(_detail_bufnr, sep_row - 1)
end
M._parse_detail_frontmatter = parse_detail_frontmatter
M._build_detail_frontmatter = build_detail_frontmatter
return M