pending.nvim/lua/pending/init.lua
Barrett Ruth fe6d7964d4 fix(init): preserve cursor column and position in mutation functions
Problem: `toggle_complete()`, `toggle_priority()`, `adjust_priority()`,
`toggle_status()`, and `move_task()` captured only the row from
`nvim_win_get_cursor` and restored the cursor to column 0 after
re-render. Additionally, `toggle_complete()` followed the toggled task
to its new sorted position at the bottom of the category, which is
disorienting when working through a list of tasks.

Solution: Capture both row and column from the cursor, and restore the
column in all five functions. For `toggle_complete()`, instead of
chasing the task ID after render, clamp the cursor to the original row
(or total lines if shorter) and advance to the nearest task line,
similar to the `]t` motion in `textobj.lua`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:08:07 -04:00

1503 lines
37 KiB
Lua

local buffer = require('pending.buffer')
local diff = require('pending.diff')
local log = require('pending.log')
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
---@type pending.Store?
local _store = nil
---@return pending.Store
local function get_store()
if not _store then
_store = store.new(store.resolve_path())
end
return _store
end
---@return pending.Store
function M.store()
return get_store()
end
---@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(get_store():active_tasks()) do
if task.status ~= 'done' and task.status ~= 'deleted' 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()
get_store():save()
M._recompute_counts()
end
---@return boolean
local function require_saved()
local bufnr = buffer.bufnr()
if bufnr and vim.bo[bufnr].modified then
log.warn('Save changes first (:w).')
return false
end
return true
end
---@return pending.Counts
function M.counts()
if not _counts then
get_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
---@param tasks pending.Task[]
---@param predicates string[]
---@return table<integer, true>
local function compute_hidden_ids(tasks, predicates)
if #predicates == 0 then
return {}
end
local hidden = {}
for _, task in ipairs(tasks) do
local visible = true
for _, pred in ipairs(predicates) do
local cat_val = pred:match('^cat:(.+)$')
if cat_val then
if task.category ~= cat_val then
visible = false
break
end
elseif pred == 'overdue' then
if not (task.status == 'pending' and task.due and parse.is_overdue(task.due)) then
visible = false
break
end
elseif pred == 'today' then
if not (task.status == 'pending' and task.due and parse.is_today(task.due)) then
visible = false
break
end
elseif pred == 'priority' then
if not (task.priority and task.priority > 0) then
visible = false
break
end
elseif pred == 'done' then
if task.status ~= 'done' then
visible = false
break
end
elseif pred == 'pending' then
if task.status ~= 'pending' then
visible = false
break
end
elseif pred == 'wip' then
if task.status ~= 'wip' then
visible = false
break
end
elseif pred == 'blocked' then
if task.status ~= 'blocked' then
visible = false
break
end
end
end
if not visible then
hidden[task.id] = true
end
end
return hidden
end
---@return integer bufnr
function M.open()
local s = get_store()
buffer.set_store(s)
local bufnr = buffer.open()
M._setup_autocmds(bufnr)
M._setup_buf_mappings(bufnr)
local forge = require('pending.forge')
forge.refresh(s)
return bufnr
end
---@param pred_str string
---@return nil
function M.filter(pred_str)
if not require_saved() then
return
end
if pred_str == 'clear' or pred_str == '' then
buffer.set_filter({}, {})
local bufnr = buffer.bufnr()
if bufnr then
buffer.render(bufnr)
end
return
end
local predicates = {}
for word in pred_str:gmatch('%S+') do
table.insert(predicates, word)
end
local tasks = get_store():active_tasks()
local hidden = compute_hidden_ids(tasks, predicates)
buffer.set_filter(predicates, hidden)
local bufnr = buffer.bufnr()
if bufnr then
buffer.render(bufnr)
end
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()
local cur_win = vim.api.nvim_get_current_win()
local tw = buffer.winid()
if tw and vim.api.nvim_win_is_valid(tw) and cur_win ~= tw then
vim.schedule(function()
local cursor = vim.api.nvim_win_is_valid(cur_win) and vim.api.nvim_win_get_cursor(cur_win)
or nil
if vim.api.nvim_win_is_valid(cur_win) and #vim.api.nvim_list_wins() > 1 then
pcall(vim.api.nvim_win_close, cur_win, false)
end
if vim.api.nvim_win_is_valid(tw) then
vim.api.nvim_set_current_win(tw)
if cursor then
pcall(vim.api.nvim_win_set_cursor, tw, cursor)
end
end
end)
return
end
if not tw or not vim.api.nvim_win_is_valid(tw) then
buffer.update_winid(cur_win)
end
if not vim.bo[bufnr].modified then
get_store():load()
buffer.render(bufnr)
end
end,
})
vim.api.nvim_create_autocmd('TextChangedI', {
group = group,
buffer = bufnr,
callback = function()
if not vim.bo[bufnr].modified then
return
end
log.debug('autocmd: TextChangedI')
for row in pairs(buffer.dirty_rows()) do
buffer.clear_inline_row(bufnr, row)
end
end,
})
vim.api.nvim_create_autocmd('TextChanged', {
group = group,
buffer = bufnr,
callback = function()
if not vim.bo[bufnr].modified then
return
end
log.debug('autocmd: TextChanged')
buffer.reapply_dirty_inline(bufnr)
end,
})
vim.api.nvim_create_autocmd('InsertLeave', {
group = group,
buffer = bufnr,
callback = function()
if vim.bo[bufnr].modified then
log.debug('autocmd: InsertLeave')
buffer.reapply_dirty_inline(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,
})
vim.api.nvim_create_autocmd('VimLeavePre', {
group = group,
callback = function()
local bnr = buffer.bufnr()
log.debug(
('VimLeavePre: bufnr=%s valid=%s'):format(
tostring(bnr),
tostring(bnr and vim.api.nvim_buf_is_valid(bnr))
)
)
if bnr and vim.api.nvim_buf_is_valid(bnr) then
buffer.persist_folds()
get_store():save()
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, nowait = true }
---@type table<string, fun()>
local actions = {
close = function()
buffer.close()
end,
toggle = function()
M.toggle_complete()
end,
view = function()
if not require_saved() then
return
end
buffer.toggle_view()
end,
priority = function()
M.toggle_priority()
end,
date = function()
M.prompt_date()
end,
category = function()
M.prompt_category()
end,
recur = function()
M.prompt_recur()
end,
move_down = function()
M.move_task('down')
end,
move_up = function()
M.move_task('up')
end,
wip = function()
M.toggle_status('wip')
end,
blocked = function()
M.toggle_status('blocked')
end,
priority_up = function()
M.increment_priority()
end,
priority_down = function()
M.decrement_priority()
end,
undo = function()
M.undo_write()
end,
filter = function()
if not require_saved() then
return
end
vim.ui.input({ prompt = 'Filter: ' }, function(input)
if input then
M.filter(input)
end
end)
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]
log.debug(('mapping motion %s → %s (buf=%d)'):format(name, key or 'nil', bufnr))
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 predicates = buffer.filter_predicates()
if lines[1] and lines[1]:match('^FILTER:') then
local pred_str = lines[1]:match('^FILTER:%s*(.*)$') or ''
predicates = {}
for word in pred_str:gmatch('%S+') do
table.insert(predicates, word)
end
lines = vim.list_slice(lines, 2)
elseif #buffer.filter_predicates() > 0 then
predicates = {}
end
local s = get_store()
local tasks = s:active_tasks()
local hidden = compute_hidden_ids(tasks, predicates)
buffer.set_filter(predicates, hidden)
local snapshot = s:snapshot()
local stack = s:undo_stack()
table.insert(stack, snapshot)
if #stack > UNDO_MAX then
table.remove(stack, 1)
end
local new_refs = diff.apply(lines, s, hidden)
M._recompute_counts()
buffer.render(bufnr)
if new_refs and #new_refs > 0 then
local forge = require('pending.forge')
forge.validate_refs(new_refs)
end
end
---@return nil
function M.undo_write()
if not require_saved() then
return
end
local s = get_store()
local stack = s:undo_stack()
if #stack == 0 then
log.warn('Nothing to undo.')
return
end
local state = table.remove(stack)
s: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
if not require_saved() then
return
end
local cursor = vim.api.nvim_win_get_cursor(0)
local row, col = cursor[1], cursor[2]
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 s = get_store()
local task = s:get(id)
if not task then
return
end
if task.status == 'done' then
s: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)
s:add({
description = task.description,
category = task.category,
priority = task.priority,
due = next_date,
recur = task.recur,
recur_mode = task.recur_mode,
})
end
s:update(id, { status = 'done' })
end
_save_and_notify()
buffer.render(bufnr)
local new_meta = buffer.meta()
local total = #new_meta
local target = math.min(row, total)
if new_meta[target] and new_meta[target].type == 'task' then
vim.api.nvim_win_set_cursor(0, { target, col })
else
for r = target, total do
if new_meta[r] and new_meta[r].type == 'task' then
vim.api.nvim_win_set_cursor(0, { r, col })
return
end
end
for r = target, 1, -1 do
if new_meta[r] and new_meta[r].type == 'task' then
vim.api.nvim_win_set_cursor(0, { r, col })
return
end
end
vim.api.nvim_win_set_cursor(0, { target, col })
end
end
---@param id_str? string
---@return nil
function M.done(id_str)
local id
if not id_str or id_str == '' then
if not require_saved() 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
log.error('Cursor is not on a task line.')
return
end
id = meta[row].id
if not id then
return
end
else
id = tonumber(id_str)
if not id then
log.error('Invalid task ID: ' .. tostring(id_str))
return
end
end
local s = get_store()
s:load()
local task = s:get(id)
if not task then
log.error('No task with ID ' .. id .. '.')
return
end
local was_done = task.status == 'done'
if was_done then
s: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)
s:add({
description = task.description,
category = task.category,
priority = task.priority,
due = next_date,
recur = task.recur,
recur_mode = task.recur_mode,
})
end
s:update(id, { status = 'done' })
end
_save_and_notify()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
log.info('Task #' .. id .. ' marked ' .. (was_done and 'pending' or 'done') .. '.')
end
---@return nil
function M.toggle_priority()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() then
return
end
local cursor = vim.api.nvim_win_get_cursor(0)
local row, col = cursor[1], cursor[2]
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 s = get_store()
local task = s:get(id)
if not task then
return
end
local max = require('pending.config').get().max_priority or 3
local new_priority = (task.priority + 1) % (max + 1)
s: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, col })
break
end
end
end
---@param delta integer
---@return nil
local function adjust_priority(delta)
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() then
return
end
local cursor = vim.api.nvim_win_get_cursor(0)
local row, col = cursor[1], cursor[2]
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 s = get_store()
local task = s:get(id)
if not task then
return
end
local max = require('pending.config').get().max_priority or 3
local new_priority = math.max(0, math.min(max, task.priority + delta))
if new_priority == task.priority then
return
end
s: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, col })
break
end
end
end
---@return nil
function M.increment_priority()
adjust_priority(1)
end
---@return nil
function M.decrement_priority()
adjust_priority(-1)
end
---@return nil
function M.prompt_date()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() 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
log.error('Invalid date format. Use YYYY-MM-DD or YYYY-MM-DDThh:mm.')
return
end
end
get_store():update(id, { due = due })
_save_and_notify()
buffer.render(bufnr)
end)
end
---@param target_status 'wip'|'blocked'
---@return nil
function M.toggle_status(target_status)
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() then
return
end
local cursor = vim.api.nvim_win_get_cursor(0)
local row, col = cursor[1], cursor[2]
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 s = get_store()
local task = s:get(id)
if not task then
return
end
if task.status == target_status then
s:update(id, { status = 'pending' })
else
s:update(id, { status = target_status })
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, col })
break
end
end
end
---@param direction 'up'|'down'
---@return nil
function M.move_task(direction)
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() then
return
end
local cursor = vim.api.nvim_win_get_cursor(0)
local row, col = cursor[1], cursor[2]
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 target_row
if direction == 'down' then
target_row = row + 1
else
target_row = row - 1
end
if not meta[target_row] or meta[target_row].type ~= 'task' then
return
end
local current_view_name = buffer.current_view_name() or 'category'
if current_view_name == 'category' then
if meta[target_row].category ~= meta[row].category then
return
end
end
local target_id = meta[target_row].id
if not target_id then
return
end
local s = get_store()
local task_a = s:get(id)
local task_b = s:get(target_id)
if not task_a or not task_b then
return
end
if task_a.order == 0 or task_b.order == 0 then
local tasks
if current_view_name == 'category' then
tasks = {}
for _, t in ipairs(s:active_tasks()) do
if t.category == task_a.category then
table.insert(tasks, t)
end
end
else
tasks = s:active_tasks()
end
table.sort(tasks, function(a, b)
if a.order ~= b.order then
return a.order < b.order
end
return a.id < b.id
end)
for i, t in ipairs(tasks) do
s:update(t.id, { order = i })
end
task_a = s:get(id)
task_b = s:get(target_id)
if not task_a or not task_b then
return
end
end
local order_a, order_b = task_a.order, task_b.order
s:update(id, { order = order_b })
s:update(target_id, { order = order_a })
_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, col })
break
end
end
end
---@return nil
function M.prompt_category()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() 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 s = get_store()
local seen = {}
local categories = {}
for _, task in ipairs(s:active_tasks()) do
if task.category and not seen[task.category] then
seen[task.category] = true
table.insert(categories, task.category)
end
end
table.sort(categories)
vim.ui.select(categories, { prompt = 'Category: ' }, function(choice)
if not choice then
return
end
s:update(id, { category = choice })
_save_and_notify()
buffer.render(bufnr)
end)
end
---@return nil
function M.prompt_recur()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() 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 = 'Recurrence (e.g. weekly, !daily): ' }, function(input)
if not input then
return
end
local s = get_store()
if input == '' then
s:update(id, { recur = vim.NIL, recur_mode = vim.NIL })
_save_and_notify()
buffer.render(bufnr)
log.info('Task #' .. id .. ': recurrence removed.')
return
end
local raw_spec = input
local rec_mode = nil
if raw_spec:sub(1, 1) == '!' then
rec_mode = 'completion'
raw_spec = raw_spec:sub(2)
end
local recur = require('pending.recur')
if not recur.validate(raw_spec) then
log.error('Invalid recurrence pattern: ' .. input)
return
end
s:update(id, { recur = raw_spec, recur_mode = rec_mode })
_save_and_notify()
buffer.render(bufnr)
log.info('Task #' .. id .. ': recurrence set to ' .. raw_spec .. '.')
end)
end
---@param text string
---@return nil
function M.add(text)
if not text or text == '' then
log.error('Usage: :Pending add <description>')
return
end
local s = get_store()
s:load()
local description, metadata = parse.command_add(text)
if not description or description == '' then
log.error('Task must have a description.')
return
end
s:add({
description = description,
category = metadata.category,
due = metadata.due,
recur = metadata.recur,
recur_mode = metadata.recur_mode,
priority = metadata.priority,
})
_save_and_notify()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
log.info('Task added: ' .. description)
end
---@class pending.SyncBackend
---@field name string
---@field auth fun(): nil
---@field push? fun(): nil
---@field pull? fun(): nil
---@field sync? fun(): nil
---@field health? fun(): nil
---@type string[]?
local _sync_backends = nil
---@type table<string, true>?
local _sync_backend_set = nil
---@return string[], table<string, true>
local function discover_backends()
if _sync_backends then
return _sync_backends, _sync_backend_set --[[@as table<string, true>]]
end
_sync_backends = {}
_sync_backend_set = {}
local paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
for _, path in ipairs(paths) do
local name = vim.fn.fnamemodify(path, ':t:r')
local ok, mod = pcall(require, 'pending.sync.' .. name)
if ok and type(mod) == 'table' and mod.name then
table.insert(_sync_backends, mod.name)
_sync_backend_set[mod.name] = true
end
end
table.sort(_sync_backends)
return _sync_backends, _sync_backend_set
end
---@param backend_name string
---@param action? string
---@return nil
local function run_sync(backend_name, action)
local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
if not ok then
log.error('Unknown sync backend: ' .. backend_name)
return
end
if not action or action == '' then
local actions = {}
for k, v in pairs(backend) do
if
type(v) == 'function'
and k:sub(1, 1) ~= '_'
and k ~= 'health'
and k ~= 'auth'
and k ~= 'auth_complete'
then
table.insert(actions, k)
end
end
table.sort(actions)
log.info(backend_name .. ' actions: ' .. table.concat(actions, ', '))
return
end
if action == 'health' or type(backend[action]) ~= 'function' then
log.error(backend_name .. ": No '" .. action .. "' action.")
return
end
backend[action]()
end
---@param msg string
---@param callback fun()
local function confirm(msg, callback)
vim.ui.input({ prompt = msg .. ' [y/N]: ' }, function(input)
if input and input:lower() == 'y' then
callback()
end
end)
end
---@param arg? string
---@return nil
function M.archive(arg)
local days
if arg and arg ~= '' then
days = parse.parse_duration_to_days(arg)
if not days then
log.error('Invalid duration: ' .. arg .. '. Use e.g. 7d, 2w, 3m, or a bare number.')
return
end
else
days = 30
end
local cutoff = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (days * 86400)) --[[@as string]]
local s = get_store()
local tasks = s:tasks()
log.debug(('archive: days=%d cutoff=%s total_tasks=%d'):format(days, cutoff, #tasks))
local count = 0
for _, task in ipairs(tasks) do
if (task.status == 'done' or task.status == 'deleted') and task['end'] then
if task['end'] < cutoff then
count = count + 1
end
end
end
if count == 0 then
log.info('No tasks to archive.')
return
end
confirm(
'Archive '
.. count
.. ' task'
.. (count == 1 and '' or 's')
.. ' completed/deleted more than '
.. days
.. 'd ago?',
function()
local kept = {}
for _, task in ipairs(tasks) do
if (task.status == 'done' or task.status == 'deleted') and task['end'] then
if task['end'] < cutoff then
goto skip
end
end
table.insert(kept, task)
::skip::
end
s:replace_tasks(kept)
_save_and_notify()
log.info('Archived ' .. count .. ' task' .. (count == 1 and '' or 's') .. '.')
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
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 = get_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
local s = get_store()
s:load()
for _, task in ipairs(s: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
log.info('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 ck = cfg.category_syntax or 'cat'
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
local bangs = token:match('^%+(!+)$')
if bangs then
local max = cfg.max_priority or 3
local level = math.min(#bangs, max)
return 'priority', level, 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 == '-' .. ck 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('^' .. vim.pesc(ck) .. ':(.+)$')
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>, '
.. ck
.. ':<name>, '
.. rk
.. ':<pattern>, +!, -!, -'
.. dk
.. ', -'
.. ck
.. ', -'
.. rk
end
---@param id_str? string
---@param rest? string
---@return nil
function M.edit(id_str, rest)
local id = id_str and tonumber(id_str)
if not id then
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if meta[row] and meta[row].type == 'task' and meta[row].id then
id = meta[row].id
if id_str and id_str ~= '' then
rest = rest and (id_str .. ' ' .. rest) or id_str
end
end
end
if not id then
if id_str and id_str ~= '' then
log.error('Invalid task ID: ' .. id_str)
else
log.error(
'Usage: :Pending edit [<id>] [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]'
)
end
return
end
end
local s = get_store()
s:load()
local task = s:get(id)
if not task then
log.error('No task with ID ' .. id .. '.')
return
end
if not rest or rest == '' then
log.error(
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]'
)
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
log.error(err)
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
if value == 0 then
table.insert(feedback, 'priority removed')
else
table.insert(feedback, 'priority set to ' .. value)
end
end
end
local snapshot = s:snapshot()
local stack = s:undo_stack()
table.insert(stack, snapshot)
if #stack > UNDO_MAX then
table.remove(stack, 1)
end
s:update(id, updates)
_save_and_notify()
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
buffer.render(bufnr)
end
log.info('Task #' .. id .. ' updated: ' .. table.concat(feedback, ', ') .. '.')
end
---@param args? string
---@return nil
function M.auth(args)
local parts = {}
for w in (args or ''):gmatch('%S+') do
table.insert(parts, w)
end
local backend_name = parts[1]
local sub_action = parts[2]
local backends_list = discover_backends()
local auth_backends = {}
for _, name in ipairs(backends_list) do
local ok, mod = pcall(require, 'pending.sync.' .. name)
if ok and type(mod.auth) == 'function' then
table.insert(auth_backends, { name = name, mod = mod })
end
end
if backend_name then
local found = false
for _, b in ipairs(auth_backends) do
if b.name == backend_name then
b.mod.auth(sub_action)
found = true
break
end
end
if not found then
log.error('No auth method for backend: ' .. backend_name)
end
elseif #auth_backends == 1 then
auth_backends[1].mod.auth()
elseif #auth_backends > 1 then
local names = {}
for _, b in ipairs(auth_backends) do
table.insert(names, b.name)
end
vim.ui.select(names, { prompt = 'Authenticate backend: ' }, function(choice)
if not choice then
return
end
for _, b in ipairs(auth_backends) do
if b.name == choice then
b.mod.auth()
break
end
end
end)
else
log.warn('No sync backends with auth support found.')
end
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 == 'done' then
M.done(rest:match('^(%S+)'))
elseif cmd == 'edit' then
local id_str, edit_rest = rest:match('^(%S+)%s*(.*)')
M.edit(id_str, edit_rest)
elseif cmd == 'auth' then
M.auth(rest)
elseif select(2, discover_backends())[cmd] then
local action = rest:match('^(%S+)')
run_sync(cmd, action)
elseif cmd == 'archive' then
M.archive(rest ~= '' and rest or nil)
elseif cmd == 'due' then
M.due()
elseif cmd == 'filter' then
M.filter(rest)
elseif cmd == 'undo' then
M.undo_write()
else
log.error('Unknown subcommand: ' .. cmd)
end
end
---@return string[]
function M.sync_backends()
return (discover_backends())
end
---@return table<string, true>
function M.sync_backend_set()
local _, set = discover_backends()
return set
end
return M