feat: :Pending edit command for CLI metadata editing (#41)

* feat: :Pending edit command for CLI metadata editing

Problem: editing task metadata (due date, category, priority,
recurrence) requires opening the buffer and editing inline. No way
to make quick metadata changes from the command line.

Solution: add :Pending edit {id} [operations...] command that applies
metadata changes by numeric task ID. Supports due:<date>, cat:<name>,
rec:<pattern>, +!, -!, -due, -cat, -rec operations with full date
vocabulary and recurrence validation. Pushes to undo stack, re-renders
the buffer if open, and provides feedback messages. Tab completion for
IDs, field names, date vocabulary, categories, and recurrence patterns.
Also fixes store.update() to properly clear fields set to vim.NIL.

* ci: formt
This commit is contained in:
Barrett Ruth 2026-02-26 16:34:07 -05:00
parent cd1cd1afd4
commit 85cf0d42ed
4 changed files with 644 additions and 5 deletions

View file

@ -514,6 +514,178 @@ function M.due()
vim.cmd('copen')
end
---@param token string
---@return string|nil field
---@return any value
---@return string|nil err
local function parse_edit_token(token)
local recur = require('pending.recur')
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
if token == '+!' then
return 'priority', 1, nil
end
if token == '-!' then
return 'priority', 0, nil
end
if token == '-due' or token == '-' .. dk then
return 'due', vim.NIL, nil
end
if token == '-cat' then
return 'category', vim.NIL, nil
end
if token == '-rec' or token == '-' .. rk then
return 'recur', vim.NIL, nil
end
local due_val = token:match('^' .. vim.pesc(dk) .. ':(.+)$')
if due_val then
local resolved = parse.resolve_date(due_val)
if resolved then
return 'due', resolved, nil
end
if
due_val:match('^%d%d%d%d%-%d%d%-%d%d$') or due_val:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$')
then
return 'due', due_val, nil
end
return nil,
nil,
'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.'
end
local cat_val = token:match('^cat:(.+)$')
if cat_val then
return 'category', cat_val, nil
end
local rec_val = token:match('^' .. vim.pesc(rk) .. ':(.+)$')
if rec_val then
local raw_spec = rec_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 pattern: ' .. rec_val
end
return 'recur', { spec = raw_spec, mode = rec_mode }, nil
end
return nil,
nil,
'Unknown operation: '
.. token
.. '. Valid: '
.. dk
.. ':<date>, cat:<name>, '
.. rk
.. ':<pattern>, +!, -!, -'
.. dk
.. ', -cat, -'
.. rk
end
---@param id_str string
---@param rest string
---@return nil
function M.edit(id_str, rest)
if not id_str or id_str == '' then
vim.notify(
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]',
vim.log.levels.ERROR
)
return
end
local id = tonumber(id_str)
if not id then
vim.notify('Invalid task ID: ' .. id_str, vim.log.levels.ERROR)
return
end
store.load()
local task = store.get(id)
if not task then
vim.notify('No task with ID ' .. id .. '.', vim.log.levels.ERROR)
return
end
if not rest or rest == '' then
vim.notify(
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]',
vim.log.levels.ERROR
)
return
end
local tokens = {}
for tok in rest:gmatch('%S+') do
table.insert(tokens, tok)
end
local updates = {}
local feedback = {}
for _, tok in ipairs(tokens) do
local field, value, err = parse_edit_token(tok)
if err then
vim.notify(err, vim.log.levels.ERROR)
return
end
if field == 'recur' then
if value == vim.NIL then
updates.recur = vim.NIL
updates.recur_mode = vim.NIL
table.insert(feedback, 'recurrence removed')
else
updates.recur = value.spec
updates.recur_mode = value.mode
table.insert(feedback, 'recurrence set to ' .. value.spec)
end
elseif field == 'due' then
if value == vim.NIL then
updates.due = vim.NIL
table.insert(feedback, 'due date removed')
else
updates.due = value
table.insert(feedback, 'due date set to ' .. tostring(value))
end
elseif field == 'category' then
if value == vim.NIL then
updates.category = vim.NIL
table.insert(feedback, 'category removed')
else
updates.category = value
table.insert(feedback, 'category set to ' .. tostring(value))
end
elseif field == 'priority' then
updates.priority = value
table.insert(feedback, value == 1 and 'priority added' or 'priority removed')
end
end
local snapshot = store.snapshot()
local stack = store.undo_stack()
table.insert(stack, snapshot)
if #stack > UNDO_MAX then
table.remove(stack, 1)
end
store.update(id, updates)
store.save()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
vim.notify('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', '))
end
---@param args string
---@return nil
function M.command(args)
@ -524,6 +696,9 @@ function M.command(args)
local cmd, rest = args:match('^(%S+)%s*(.*)')
if cmd == 'add' then
M.add(rest)
elseif cmd == 'edit' then
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
M.edit(id_str, edit_rest)
elseif cmd == 'sync' then
M.sync()
elseif cmd == 'archive' then

View file

@ -293,7 +293,11 @@ function M.update(id, fields)
local now = timestamp()
for k, v in pairs(fields) do
if k ~= 'id' and k ~= 'entry' then
task[k] = v
if v == vim.NIL then
task[k] = nil
else
task[k] = v
end
end
end
task.modified = now

View file

@ -3,17 +3,173 @@ if vim.g.loaded_pending then
end
vim.g.loaded_pending = true
---@return string[]
local function edit_field_candidates()
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
return {
dk .. ':',
'cat:',
rk .. ':',
'+!',
'-!',
'-' .. dk,
'-cat',
'-' .. rk,
}
end
---@return string[]
local function edit_date_values()
return {
'today',
'tomorrow',
'yesterday',
'+1d',
'+2d',
'+3d',
'+1w',
'+2w',
'+1m',
'mon',
'tue',
'wed',
'thu',
'fri',
'sat',
'sun',
'eod',
'eow',
'eom',
'eoq',
'eoy',
'sow',
'som',
'soq',
'soy',
'later',
}
end
---@return string[]
local function edit_recur_values()
local ok, recur = pcall(require, 'pending.recur')
if not ok then
return {}
end
local result = {}
for _, s in ipairs(recur.shorthand_list()) do
table.insert(result, s)
end
for _, s in ipairs(recur.shorthand_list()) do
table.insert(result, '!' .. s)
end
return result
end
---@param lead string
---@param candidates string[]
---@return string[]
local function filter_candidates(lead, candidates)
return vim.tbl_filter(function(s)
return s:find(lead, 1, true) == 1
end, candidates)
end
---@param arg_lead string
---@param cmd_line string
---@return string[]
local function complete_edit(arg_lead, cmd_line)
local cfg = require('pending.config').get()
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
local after_edit = cmd_line:match('^Pending%s+edit%s+(.*)')
if not after_edit then
return {}
end
local parts = {}
for part in after_edit:gmatch('%S+') do
table.insert(parts, part)
end
local trailing_space = after_edit:match('%s$')
if #parts == 0 or (#parts == 1 and not trailing_space) then
local store = require('pending.store')
store.load()
local ids = {}
for _, task in ipairs(store.active_tasks()) do
table.insert(ids, tostring(task.id))
end
return filter_candidates(arg_lead, ids)
end
local prefix = arg_lead:match('^(' .. vim.pesc(dk) .. ':)(.*)$')
if prefix then
local after_colon = arg_lead:sub(#prefix + 1)
local dates = edit_date_values()
local result = {}
for _, d in ipairs(dates) do
if d:find(after_colon, 1, true) == 1 then
table.insert(result, prefix .. d)
end
end
return result
end
local rec_prefix = arg_lead:match('^(' .. vim.pesc(rk) .. ':)(.*)$')
if rec_prefix then
local after_colon = arg_lead:sub(#rec_prefix + 1)
local pats = edit_recur_values()
local result = {}
for _, p in ipairs(pats) do
if p:find(after_colon, 1, true) == 1 then
table.insert(result, rec_prefix .. p)
end
end
return result
end
local cat_prefix = arg_lead:match('^(cat:)(.*)$')
if cat_prefix then
local after_colon = arg_lead:sub(#cat_prefix + 1)
local store = require('pending.store')
store.load()
local seen = {}
local cats = {}
for _, task in ipairs(store.active_tasks()) do
if task.category and not seen[task.category] then
seen[task.category] = true
table.insert(cats, task.category)
end
end
table.sort(cats)
local result = {}
for _, c in ipairs(cats) do
if c:find(after_colon, 1, true) == 1 then
table.insert(result, cat_prefix .. c)
end
end
return result
end
return filter_candidates(arg_lead, edit_field_candidates())
end
vim.api.nvim_create_user_command('Pending', function(opts)
require('pending').command(opts.args)
end, {
bar = true,
nargs = '*',
complete = function(arg_lead, cmd_line)
local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' }
local subcmds = { 'add', 'archive', 'due', 'edit', 'sync', 'undo' }
if not cmd_line:match('^Pending%s+%S') then
return vim.tbl_filter(function(s)
return s:find(arg_lead, 1, true) == 1
end, subcmds)
return filter_candidates(arg_lead, subcmds)
end
if cmd_line:match('^Pending%s+edit') then
return complete_edit(arg_lead, cmd_line)
end
return {}
end,

304
spec/edit_spec.lua Normal file
View file

@ -0,0 +1,304 @@
require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('edit', function()
local tmpdir
local pending = require('pending')
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
store.load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
end)
it('sets due date with resolve_date vocabulary', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'due:tomorrow')
local updated = store.get(t.id)
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
assert.are.equal(expected, updated.due)
end)
it('sets due date with literal YYYY-MM-DD', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'due:2026-06-15')
local updated = store.get(t.id)
assert.are.equal('2026-06-15', updated.due)
end)
it('sets category', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'cat:Work')
local updated = store.get(t.id)
assert.are.equal('Work', updated.category)
end)
it('adds priority', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), '+!')
local updated = store.get(t.id)
assert.are.equal(1, updated.priority)
end)
it('removes priority', function()
local t = store.add({ description = 'Task one', priority = 1 })
store.save()
pending.edit(tostring(t.id), '-!')
local updated = store.get(t.id)
assert.are.equal(0, updated.priority)
end)
it('removes due date', function()
local t = store.add({ description = 'Task one', due = '2026-06-15' })
store.save()
pending.edit(tostring(t.id), '-due')
local updated = store.get(t.id)
assert.is_nil(updated.due)
end)
it('removes category', function()
local t = store.add({ description = 'Task one', category = 'Work' })
store.save()
pending.edit(tostring(t.id), '-cat')
local updated = store.get(t.id)
assert.is_nil(updated.category)
end)
it('sets recurrence', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'rec:weekly')
local updated = store.get(t.id)
assert.are.equal('weekly', updated.recur)
assert.is_nil(updated.recur_mode)
end)
it('sets completion-based recurrence', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'rec:!daily')
local updated = store.get(t.id)
assert.are.equal('daily', updated.recur)
assert.are.equal('completion', updated.recur_mode)
end)
it('removes recurrence', function()
local t = store.add({ description = 'Task one', recur = 'weekly', recur_mode = 'scheduled' })
store.save()
pending.edit(tostring(t.id), '-rec')
local updated = store.get(t.id)
assert.is_nil(updated.recur)
assert.is_nil(updated.recur_mode)
end)
it('applies multiple operations at once', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'due:today cat:Errands +!')
local updated = store.get(t.id)
assert.are.equal(os.date('%Y-%m-%d'), updated.due)
assert.are.equal('Errands', updated.category)
assert.are.equal(1, updated.priority)
end)
it('pushes to undo stack', function()
local t = store.add({ description = 'Task one' })
store.save()
local stack_before = #store.undo_stack()
pending.edit(tostring(t.id), 'cat:Work')
assert.are.equal(stack_before + 1, #store.undo_stack())
end)
it('persists changes to disk', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'cat:Work')
store.unload()
store.load()
local updated = store.get(t.id)
assert.are.equal('Work', updated.category)
end)
it('errors on unknown task ID', function()
store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit('999', 'cat:Work')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('No task with ID 999'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on invalid date', function()
local t = store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'due:notadate')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Invalid date'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on unknown operation token', function()
local t = store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'bogus')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Unknown operation'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on invalid recurrence pattern', function()
local t = store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'rec:nope')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Invalid recurrence'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors when no operations given', function()
local t = store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), '')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Usage'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors when no id given', function()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit('', '')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Usage'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('errors on non-numeric id', function()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit('abc', 'cat:Work')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Invalid task ID'))
assert.are.equal(vim.log.levels.ERROR, messages[1].level)
end)
it('shows feedback message on success', function()
local t = store.add({ description = 'Task one' })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), 'cat:Work')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('Task #' .. t.id .. ' updated'))
assert.truthy(messages[1].msg:find('category set to Work'))
end)
it('respects custom date_syntax', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', date_syntax = 'by' }
config.reset()
store.unload()
store.load()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'by:tomorrow')
local updated = store.get(t.id)
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
assert.are.equal(expected, updated.due)
end)
it('respects custom recur_syntax', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', recur_syntax = 'repeat' }
config.reset()
store.unload()
store.load()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'repeat:weekly')
local updated = store.get(t.id)
assert.are.equal('weekly', updated.recur)
end)
it('does not modify store on error', function()
local t = store.add({ description = 'Task one', category = 'Original' })
store.save()
local orig_notify = vim.notify
vim.notify = function() end
pending.edit(tostring(t.id), 'due:notadate')
vim.notify = orig_notify
local updated = store.get(t.id)
assert.are.equal('Original', updated.category)
assert.is_nil(updated.due)
end)
it('sets due date with datetime format', function()
local t = store.add({ description = 'Task one' })
store.save()
pending.edit(tostring(t.id), 'due:tomorrow@14:00')
local updated = store.get(t.id)
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
assert.are.equal(expected .. 'T14:00', updated.due)
end)
end)