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 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 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 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 ', 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 ---@param backend_name string ---@param action? string ---@return nil function M.sync(backend_name, action) if not backend_name or backend_name == '' then vim.notify('Usage: :Pending sync [action]', vim.log.levels.ERROR) return end action = (action and action ~= '') and action or 'sync' local ok, backend = pcall(require, 'pending.sync.' .. backend_name) if not ok then vim.notify('Unknown sync backend: ' .. backend_name, vim.log.levels.ERROR) return end if type(backend[action]) ~= 'function' then vim.notify(backend_name .. " backend has no '" .. action .. "' action", vim.log.levels.ERROR) return end backend[action]() 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 .. ':, cat:, ' .. rk .. ':, +!, -!, -' .. 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 [due:] [cat:] [rec:] [+!] [-!] [-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 [due:] [cat:] [rec:] [+!] [-!] [-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 local backend, action = rest:match('^(%S+)%s*(.*)') M.sync(backend, action) 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