pending.nvim/lua/pending/init.lua
Barrett Ruth 85cf0d42ed 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
2026-02-26 16:34:07 -05:00

716 lines
17 KiB
Lua

local buffer = require('pending.buffer')
local diff = require('pending.diff')
local parse = require('pending.parse')
local store = require('pending.store')
---@class pending.Counts
---@field overdue integer
---@field today integer
---@field pending integer
---@field priority integer
---@field next_due? string
---@class pending.init
local M = {}
local UNDO_MAX = 20
---@type pending.Counts?
local _counts = nil
---@return nil
function M._recompute_counts()
local cfg = require('pending.config').get()
local someday = cfg.someday_date
local overdue = 0
local today = 0
local pending = 0
local priority = 0
local next_due = nil ---@type string?
local today_str = os.date('%Y-%m-%d') --[[@as string]]
for _, task in ipairs(store.active_tasks()) do
if task.status == 'pending' then
pending = pending + 1
if task.priority > 0 then
priority = priority + 1
end
if task.due and task.due ~= someday then
if parse.is_overdue(task.due) then
overdue = overdue + 1
elseif parse.is_today(task.due) then
today = today + 1
end
local date_part = task.due:match('^(%d%d%d%d%-%d%d%-%d%d)') or task.due
if date_part >= today_str and (not next_due or task.due < next_due) then
next_due = task.due
end
end
end
end
_counts = {
overdue = overdue,
today = today,
pending = pending,
priority = priority,
next_due = next_due,
}
vim.api.nvim_exec_autocmds('User', { pattern = 'PendingStatusChanged' })
end
---@return nil
local function _save_and_notify()
store.save()
M._recompute_counts()
end
---@return pending.Counts
function M.counts()
if not _counts then
store.load()
M._recompute_counts()
end
return _counts --[[@as pending.Counts]]
end
---@return string
function M.statusline()
local c = M.counts()
if c.overdue > 0 and c.today > 0 then
return c.overdue .. ' overdue, ' .. c.today .. ' today'
elseif c.overdue > 0 then
return c.overdue .. ' overdue'
elseif c.today > 0 then
return c.today .. ' today'
end
return ''
end
---@return boolean
function M.has_due()
local c = M.counts()
return c.overdue > 0 or c.today > 0
end
---@return integer bufnr
function M.open()
local bufnr = buffer.open()
M._setup_autocmds(bufnr)
M._setup_buf_mappings(bufnr)
return bufnr
end
---@param bufnr integer
---@return nil
function M._setup_autocmds(bufnr)
local group = vim.api.nvim_create_augroup('PendingBuffer', { clear = true })
vim.api.nvim_create_autocmd('BufWriteCmd', {
group = group,
buffer = bufnr,
callback = function()
M._on_write(bufnr)
end,
})
vim.api.nvim_create_autocmd('BufEnter', {
group = group,
buffer = bufnr,
callback = function()
if not vim.bo[bufnr].modified then
store.load()
buffer.render(bufnr)
end
end,
})
vim.api.nvim_create_autocmd('WinClosed', {
group = group,
callback = function(ev)
if tonumber(ev.match) == buffer.winid() then
buffer.clear_winid()
end
end,
})
end
---@param bufnr integer
---@return nil
function M._setup_buf_mappings(bufnr)
local cfg = require('pending.config').get()
local km = cfg.keymaps
local opts = { buffer = bufnr, silent = true }
---@type table<string, fun()>
local actions = {
close = function()
buffer.close()
end,
toggle = function()
M.toggle_complete()
end,
view = function()
buffer.toggle_view()
end,
priority = function()
M.toggle_priority()
end,
date = function()
M.prompt_date()
end,
undo = function()
M.undo_write()
end,
open_line = function()
buffer.open_line(false)
end,
open_line_above = function()
buffer.open_line(true)
end,
}
for name, fn in pairs(actions) do
local key = km[name]
if key and key ~= false then
vim.keymap.set('n', key --[[@as string]], fn, opts)
end
end
local textobj = require('pending.textobj')
---@type table<string, { modes: string[], fn: fun(count: integer), visual_fn?: fun(count: integer) }>
local textobjs = {
a_task = {
modes = { 'o', 'x' },
fn = textobj.a_task,
visual_fn = textobj.a_task_visual,
},
i_task = {
modes = { 'o', 'x' },
fn = textobj.i_task,
visual_fn = textobj.i_task_visual,
},
a_category = {
modes = { 'o', 'x' },
fn = textobj.a_category,
visual_fn = textobj.a_category_visual,
},
i_category = {
modes = { 'o', 'x' },
fn = textobj.i_category,
visual_fn = textobj.i_category_visual,
},
}
for name, spec in pairs(textobjs) do
local key = km[name]
if key and key ~= false then
for _, mode in ipairs(spec.modes) do
if mode == 'x' and spec.visual_fn then
vim.keymap.set(mode, key --[[@as string]], function()
spec.visual_fn(vim.v.count1)
end, opts)
else
vim.keymap.set(mode, key --[[@as string]], function()
spec.fn(vim.v.count1)
end, opts)
end
end
end
end
---@type table<string, fun(count: integer)>
local motions = {
next_header = textobj.next_header,
prev_header = textobj.prev_header,
next_task = textobj.next_task,
prev_task = textobj.prev_task,
}
for name, fn in pairs(motions) do
local key = km[name]
if cfg.debug then
vim.notify(
('[pending] mapping motion %s → %s (buf=%d)'):format(name, key or 'nil', bufnr),
vim.log.levels.INFO
)
end
if key and key ~= false then
vim.keymap.set({ 'n', 'x', 'o' }, key --[[@as string]], function()
fn(vim.v.count1)
end, opts)
end
end
end
---@param bufnr integer
---@return nil
function M._on_write(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local snapshot = store.snapshot()
local stack = store.undo_stack()
table.insert(stack, snapshot)
if #stack > UNDO_MAX then
table.remove(stack, 1)
end
diff.apply(lines)
M._recompute_counts()
buffer.render(bufnr)
end
---@return nil
function M.undo_write()
local stack = store.undo_stack()
if #stack == 0 then
vim.notify('Nothing to undo.', vim.log.levels.WARN)
return
end
local state = table.remove(stack)
store.replace_tasks(state)
_save_and_notify()
buffer.render(buffer.bufnr())
end
---@return nil
function M.toggle_complete()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
if not id then
return
end
local task = store.get(id)
if not task then
return
end
if task.status == 'done' then
store.update(id, { status = 'pending', ['end'] = vim.NIL })
else
if task.recur and task.due then
local recur = require('pending.recur')
local mode = task.recur_mode or 'scheduled'
local next_date = recur.next_due(task.due, task.recur, mode)
store.add({
description = task.description,
category = task.category,
priority = task.priority,
due = next_date,
recur = task.recur,
recur_mode = task.recur_mode,
})
end
store.update(id, { status = 'done' })
end
_save_and_notify()
buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do
if m.id == id then
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
break
end
end
end
---@return nil
function M.toggle_priority()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
if not id then
return
end
local task = store.get(id)
if not task then
return
end
local new_priority = task.priority > 0 and 0 or 1
store.update(id, { priority = new_priority })
_save_and_notify()
buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do
if m.id == id then
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
break
end
end
end
---@return nil
function M.prompt_date()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
if not id then
return
end
vim.ui.input({ prompt = 'Due date (today, +3d, fri@2pm, etc.): ' }, function(input)
if not input then
return
end
local due = input ~= '' and input or nil
if due then
local resolved = parse.resolve_date(due)
if resolved then
due = resolved
elseif
not due:match('^%d%d%d%d%-%d%d%-%d%d$')
and not due:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$')
then
vim.notify('Invalid date format. Use YYYY-MM-DD or YYYY-MM-DDThh:mm.', vim.log.levels.ERROR)
return
end
end
store.update(id, { due = due })
_save_and_notify()
buffer.render(bufnr)
end)
end
---@param text string
---@return nil
function M.add(text)
if not text or text == '' then
vim.notify('Usage: :Pending add <description>', vim.log.levels.ERROR)
return
end
store.load()
local description, metadata = parse.command_add(text)
if not description or description == '' then
vim.notify('Pending must have a description.', vim.log.levels.ERROR)
return
end
store.add({
description = description,
category = metadata.cat,
due = metadata.due,
recur = metadata.rec,
recur_mode = metadata.rec_mode,
})
_save_and_notify()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
vim.notify('Pending added: ' .. description)
end
---@return nil
function M.sync()
local ok, gcal = pcall(require, 'pending.sync.gcal')
if not ok then
vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR)
return
end
gcal.sync()
end
---@param days? integer
---@return nil
function M.archive(days)
days = days or 30
local cutoff = os.time() - (days * 86400)
local tasks = store.tasks()
local archived = 0
local kept = {}
for _, task in ipairs(tasks) do
if (task.status == 'done' or task.status == 'deleted') and task['end'] then
local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$')
if y then
local t = os.time({
year = tonumber(y) --[[@as integer]],
month = tonumber(mo) --[[@as integer]],
day = tonumber(d) --[[@as integer]],
hour = tonumber(h) --[[@as integer]],
min = tonumber(mi) --[[@as integer]],
sec = tonumber(s) --[[@as integer]],
})
if t < cutoff then
archived = archived + 1
goto skip
end
end
end
table.insert(kept, task)
::skip::
end
store.replace_tasks(kept)
_save_and_notify()
vim.notify('Archived ' .. archived .. ' tasks.')
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
end
---@return nil
function M.due()
local bufnr = buffer.bufnr()
local is_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr)
local meta = is_valid and buffer.meta() or nil
local qf_items = {}
if meta and bufnr then
for lnum, m in ipairs(meta) do
if
m.type == 'task'
and m.raw_due
and m.status ~= 'done'
and (parse.is_overdue(m.raw_due) or parse.is_today(m.raw_due))
then
local task = store.get(m.id or 0)
local label = parse.is_overdue(m.raw_due) and '[OVERDUE] ' or '[DUE] '
table.insert(qf_items, {
bufnr = bufnr,
lnum = lnum,
col = 1,
text = label .. (task and task.description or ''),
})
end
end
else
store.load()
for _, task in ipairs(store.active_tasks()) do
if
task.status == 'pending'
and task.due
and (parse.is_overdue(task.due) or parse.is_today(task.due))
then
local label = parse.is_overdue(task.due) and '[OVERDUE] ' or '[DUE] '
local text = label .. task.description
if task.category then
text = text .. ' [' .. task.category .. ']'
end
table.insert(qf_items, { text = text })
end
end
end
if #qf_items == 0 then
vim.notify('No due or overdue tasks.')
return
end
vim.fn.setqflist(qf_items, 'r')
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)
if not args or args == '' then
M.open()
return
end
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
local d = rest ~= '' and tonumber(rest) or nil
M.archive(d)
elseif cmd == 'due' then
M.due()
elseif cmd == 'undo' then
M.undo_write()
else
vim.notify('Unknown Pending subcommand: ' .. cmd, vim.log.levels.ERROR)
end
end
return M