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:
parent
20506af849
commit
649f238683
2 changed files with 585 additions and 67 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue