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 = {} ---@type pending.Task[][] local _undo_states = {} 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 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, }) end ---@param bufnr integer function M._setup_buf_mappings(bufnr) local opts = { buffer = bufnr, silent = true } vim.keymap.set('n', '', function() M.toggle_complete() end, opts) vim.keymap.set('n', '', function() buffer.toggle_view() end, opts) vim.keymap.set('n', 'g?', function() M.show_help() end, opts) vim.keymap.set('n', '!', function() M.toggle_priority() end, opts) vim.keymap.set('n', 'D', function() M.prompt_date() end, opts) vim.keymap.set('n', 'U', function() M.undo_write() end, opts) end ---@param bufnr integer function M._on_write(bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local snapshot = store.snapshot() table.insert(_undo_states, snapshot) if #_undo_states > UNDO_MAX then table.remove(_undo_states, 1) end diff.apply(lines) buffer.render(bufnr) end function M.undo_write() if #_undo_states == 0 then vim.notify('Nothing to undo.', vim.log.levels.WARN) return end local state = table.remove(_undo_states) store.replace_tasks(state) store.save() buffer.render(buffer.bufnr()) end 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 store.update(id, { status = 'done' }) end store.save() buffer.render(bufnr) end 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 == 1 and 0 or 1 store.update(id, { priority = new_priority }) store.save() buffer.render(bufnr) end 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 (YYYY-MM-DD): ' }, 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$') then vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR) return end end store.update(id, { due = due }) store.save() buffer.render(bufnr) end) end ---@param text string 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, }) 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 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 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 function M.due() local today = os.date('%Y-%m-%d') --[[@as string]] 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 m.raw_due <= today then local task = store.get(m.id or 0) local label = m.raw_due < today 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 task.due <= today then local label = task.due < today 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 function M.show_help() local cfg = require('pending.config').get() local dk = cfg.date_syntax or 'due' local lines = { 'pending.nvim keybindings', '', ' Toggle complete/uncomplete', ' Switch category/priority view', '! Toggle priority', 'D Set due date', 'U Undo last write', 'o / O Add new task line', 'dd Delete task line (on :w)', 'p / P Paste (duplicates get new IDs)', 'zc / zo Fold/unfold category (category view)', ':w Save all changes', '', ':Pending add Quick-add task', ':Pending add Cat: Quick-add with category', ':Pending due Show overdue/due qflist', ':Pending sync Push to Google Calendar', ':Pending archive [days] Purge old done tasks', ':Pending undo Undo last write', '', 'Inline metadata (on new lines before :w):', ' ' .. dk .. ':YYYY-MM-DD Set due date', ' cat:Name Set category', '', 'Due date input:', ' today, tomorrow, +Nd, mon-sun', ' Empty input clears due date', '', 'Highlights:', ' PendingOverdue overdue tasks (red)', '', 'Press q or to close', } local buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) vim.bo[buf].modifiable = false vim.bo[buf].bufhidden = 'wipe' local width = 54 local height = #lines local win = vim.api.nvim_open_win(buf, true, { relative = 'editor', width = width, height = height, col = math.floor((vim.o.columns - width) / 2), row = math.floor((vim.o.lines - height) / 2), style = 'minimal', border = 'rounded', }) vim.keymap.set('n', 'q', function() vim.api.nvim_win_close(win, true) end, { buffer = buf, silent = true }) vim.keymap.set('n', '', function() vim.api.nvim_win_close(win, true) end, { buffer = buf, silent = true }) end ---@param args string 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