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 == '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() 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 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 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) 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({ 'TextChanged', 'TextChangedI' }, { group = group, buffer = bufnr, callback = function() if vim.bo[bufnr].modified then buffer.clear_marks(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, nowait = true } ---@type table 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, 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 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] 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 diff.apply(lines, s, hidden) M._recompute_counts() buffer.render(bufnr) 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 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 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) 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 ---@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 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 task = s:get(id) if not task then return end local new_priority = task.priority > 0 and 0 or 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, 0 }) break end end 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 text string ---@return nil function M.add(text) if not text or text == '' then log.error('Usage: :Pending add ') 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.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 log.info('Task added: ' .. description) end ---@type string[] local SYNC_BACKENDS = { 'gcal', 'gtasks' } ---@type table local SYNC_BACKEND_SET = {} for _, b in ipairs(SYNC_BACKENDS) do SYNC_BACKEND_SET[b] = true 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' 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 days? integer ---@return nil function M.archive(days) if days == nil then 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 archived = 0 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 archived = archived + 1 goto skip end end table.insert(kept, task) ::skip:: end s:replace_tasks(kept) _save_and_notify() log.info('Archived ' .. archived .. ' task' .. (archived == 1 and '' or 's') .. '.') 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 = 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 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 log.error( 'Usage: :Pending edit [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]' ) return end local id = tonumber(id_str) if not id then log.error('Invalid task ID: ' .. id_str) return 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 [due:] [cat:] [rec:] [+!] [-!] [-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 table.insert(feedback, value == 1 and 'priority added' or 'priority removed') 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 oauth = require('pending.sync.oauth') local parts = {} for w in (args or ''):gmatch('%S+') do table.insert(parts, w) end local action = parts[#parts] if action == parts[1] and (action == 'gtasks' or action == 'gcal') then action = nil end if action == 'clear' then oauth.google_client:clear_tokens() log.info('OAuth tokens cleared — run :Pending auth to re-authenticate.') elseif action == 'reset' then oauth.google_client:_wipe() log.info('OAuth tokens and credentials cleared — run :Pending auth to set up from scratch.') else local creds = oauth.google_client:resolve_credentials() if creds.client_id == oauth.BUNDLED_CLIENT_ID then oauth.google_client:setup() else oauth.google_client:auth() end 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 SYNC_BACKEND_SET[cmd] then local action = rest:match('^(%S+)') run_sync(cmd, action) elseif cmd == 'archive' then M.archive(tonumber(rest)) 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 SYNC_BACKENDS end ---@return table function M.sync_backend_set() return SYNC_BACKEND_SET end return M