Problem: folded category headers are lost when Neovim exits because `_fold_state` only lives in memory. Users must re-fold categories every session. Solution: store folded category names in the JSON data file as a top-level `folded_categories` field. On first render, `restore_folds` seeds from the store instead of the empty in-memory state. Folds are persisted on `M.close()` and `VimLeavePre`.
1027 lines
25 KiB
Lua
1027 lines
25 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 == '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<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
|
|
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,
|
|
})
|
|
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,
|
|
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
|
|
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 <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.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<string, true>
|
|
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
|
|
.. ':<date>, cat:<name>, '
|
|
.. rk
|
|
.. ':<pattern>, +!, -!, -'
|
|
.. 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 <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-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 <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
|
|
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<string, true>
|
|
function M.sync_backend_set()
|
|
return SYNC_BACKEND_SET
|
|
end
|
|
|
|
return M
|