Problem: the ]] motion reportedly lands one line past the header in
some environments, and ]t/[t may not override Neovim defaults. No
way to diagnose these at runtime. Also, pending://priority is a poor
buffer name for the flat ranked view.
Solution: add a debug config option (vim.g.pending = { debug = true })
that logs meta state, cursor positions, and mapping registration to
:messages at DEBUG level. Rename the buffer from pending://priority to
pending://queue. Internal view identifier stays 'priority'.
484 lines
12 KiB
Lua
484 lines
12 KiB
Lua
local buffer = require('pending.buffer')
|
|
local diff = require('pending.diff')
|
|
local parse = require('pending.parse')
|
|
local store = require('pending.store')
|
|
|
|
---@class pending.init
|
|
local M = {}
|
|
|
|
local UNDO_MAX = 20
|
|
|
|
---@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,
|
|
}
|
|
|
|
local dbg = config.get().debug
|
|
for name, fn in pairs(motions) do
|
|
local key = km[name]
|
|
if dbg then
|
|
vim.notify(
|
|
('[pending] mapping motion %s → %s (buf=%d)'):format(name, key or 'nil', bufnr),
|
|
vim.log.levels.DEBUG
|
|
)
|
|
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)
|
|
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)
|
|
store.save()
|
|
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
|
|
store.save()
|
|
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 })
|
|
store.save()
|
|
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 })
|
|
store.save()
|
|
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,
|
|
})
|
|
store.save()
|
|
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)
|
|
store.save()
|
|
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
|
|
|
|
---@param due string
|
|
---@return boolean
|
|
local function is_due_or_overdue(due)
|
|
local now = os.date('*t') --[[@as osdate]]
|
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
|
local date_part, time_part = due:match('^(.+)T(.+)$')
|
|
if not date_part then
|
|
return due <= today
|
|
end
|
|
if date_part < today then
|
|
return true
|
|
end
|
|
if date_part > today then
|
|
return false
|
|
end
|
|
local current_time = string.format('%02d:%02d', now.hour, now.min)
|
|
return time_part <= current_time
|
|
end
|
|
|
|
---@param due string
|
|
---@return boolean
|
|
local function is_overdue(due)
|
|
local now = os.date('*t') --[[@as osdate]]
|
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
|
local date_part, time_part = due:match('^(.+)T(.+)$')
|
|
if not date_part then
|
|
return due < today
|
|
end
|
|
if date_part < today then
|
|
return true
|
|
end
|
|
if date_part > today then
|
|
return false
|
|
end
|
|
local current_time = string.format('%02d:%02d', now.hour, now.min)
|
|
return time_part < current_time
|
|
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 is_due_or_overdue(m.raw_due) then
|
|
local task = store.get(m.id or 0)
|
|
local label = 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 is_due_or_overdue(task.due) then
|
|
local label = 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 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 == '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
|