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 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) 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 ', 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