* fix(diff): preserve due/rec when absent from buffer line Problem: `diff.apply` overwrites `task.due` and `task.recur` with `nil` whenever those fields aren't present as inline tokens in the buffer line. Because metadata is rendered as virtual text (never in the line text), every description edit silently clears due dates and recurrence rules. Solution: Only update `due`, `recur`, and `recur_mode` in the existing- task branch when the parsed entry actually contains them (non-nil). Users can still set/change these inline by typing `due:<date>` or `rec:<rule>`; clearing them requires `:Pending edit <id> -due`. * refactor: remove project-local store discovery Problem: `store.resolve_path()` searched upward for `.pending.json`, silently splitting task data across multiple files depending on CWD. Solution: `resolve_path()` now always returns `config.get().data_path`. Remove `M.init()` and the `:Pending init` command and tab-completion entry. Remove the project-local health message. * refactor: extract log.lua, standardise [pending.nvim]: prefix Problem: Notifications were scattered across files using bare `vim.notify` with inconsistent `pending.nvim: ` prefixes, and the `debug` guard in `textobj.lua` and `init.lua` was duplicated inline. Solution: Add `lua/pending/log.lua` with `info`, `warn`, `error`, and `debug` functions (prefix `[pending.nvim]: `). `log.debug` only fires when `config.debug = true` or the optional `override` param is `true`. Replace all `vim.notify` callsites and remove inline debug guards. * feat(parse): configurable input date formats Problem: `due:` only accepted ISO `YYYY-MM-DD` and built-in keywords; users expecting locale-style dates like `03/15/2026` or `15-Mar-2026` had no way to configure alternative input formats. Solution: Add `input_date_formats` config field (string[]). Each entry is a strftime-like format string supporting `%Y`, `%y`, `%m`, `%d`, `%e`, `%b`, `%B`. Formats are tried in order after built-in keywords fail. When no year specifier is present the current or next year is inferred. Update vimdoc and add 8 parse_spec tests.
871 lines
21 KiB
Lua
871 lines
21 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 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 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()
|
|
if not vim.bo[bufnr].modified then
|
|
get_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<string, fun()>
|
|
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,
|
|
filter = function()
|
|
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()
|
|
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
|
|
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
|
|
|
|
---@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 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
|
|
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('Pending 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('Pending 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) ~= '_' then
|
|
table.insert(actions, k)
|
|
end
|
|
end
|
|
table.sort(actions)
|
|
log.info(backend_name .. ' actions: ' .. table.concat(actions, ', '))
|
|
return
|
|
end
|
|
if type(backend[action]) ~= 'function' then
|
|
log.error(backend_name .. " backend has no '" .. action .. "' action")
|
|
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 s = get_store()
|
|
local tasks = s: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, sec = 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(sec) --[[@as integer]],
|
|
})
|
|
if t < cutoff then
|
|
archived = archived + 1
|
|
goto skip
|
|
end
|
|
end
|
|
end
|
|
table.insert(kept, task)
|
|
::skip::
|
|
end
|
|
s:replace_tasks(kept)
|
|
_save_and_notify()
|
|
log.info('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 = 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.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 SYNC_BACKEND_SET[cmd] then
|
|
local action = rest:match('^(%S+)')
|
|
run_sync(cmd, action)
|
|
elseif cmd == 'archive' then
|
|
local d = rest ~= '' and tonumber(rest) or nil
|
|
M.archive(d)
|
|
elseif cmd == 'due' then
|
|
M.due()
|
|
elseif cmd == 'filter' then
|
|
M.filter(rest)
|
|
elseif cmd == 'undo' then
|
|
M.undo_write()
|
|
else
|
|
log.error('Unknown Pending 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
|