feat(archive): duration syntax and confirmation prompt (#113)
* feat(s3): create bucket interactively during auth when unconfigured Problem: when a user runs `:Pending s3 auth` with no bucket configured, auth succeeds but offers no way to create the bucket. The user must manually run `aws s3api create-bucket` and update their config. Solution: add `util.input()` coroutine-aware prompt wrapper and a `create_bucket()` flow in `s3.lua` that prompts for bucket name and region, handles the `us-east-1` LocationConstraint quirk, and logs a config snippet on success. Called automatically from `auth()` when `sync.s3.bucket` is absent. * ci: typing * feat(parse): add `parse_duration_to_days` for duration string conversion Problem: The archive command accepted only a bare integer for days, inconsistent with the `+Nd`/`+Nw`/`+Nm` duration syntax used elsewhere. Solution: Add `parse_duration_to_days()` supporting `Nd`, `Nw`, `Nm`, and bare integers. Returns nil on invalid input for caller error handling. * feat(archive): duration syntax and confirmation prompt Problem: `:Pending archive` accepted only a bare integer for days and silently deleted tasks with no confirmation, risking accidental data loss. Solution: Accept duration strings (`7d`, `3w`, `2m`) via `parse.parse_duration_to_days()`, show a `vim.ui.input` confirmation prompt before removing tasks, and skip the prompt when zero tasks match.
This commit is contained in:
parent
7640241cf2
commit
dc365e266b
6 changed files with 287 additions and 25 deletions
|
|
@ -141,11 +141,24 @@ COMMANDS *pending-commands*
|
|||
after the add.
|
||||
|
||||
*:Pending-archive*
|
||||
:Pending archive [{days}]
|
||||
:Pending archive [{duration}]
|
||||
Permanently remove done and deleted tasks whose completion timestamp is
|
||||
older than {days} days. {days} defaults to 30 if not provided. >vim
|
||||
:Pending archive " remove tasks completed more than 30 days ago
|
||||
:Pending archive 7 " remove tasks completed more than 7 days ago
|
||||
older than {duration}. {duration} defaults to 30 days if not provided.
|
||||
|
||||
Supported duration formats:
|
||||
`Nd` N days (e.g. `7d`)
|
||||
`Nw` N weeks (e.g. `3w` → 21 days)
|
||||
`Nm` N months (e.g. `2m` → 60 days, approximated as N×30)
|
||||
`N` bare integer, treated as days (backwards-compatible)
|
||||
|
||||
A confirmation prompt is shown before any tasks are removed. If no
|
||||
tasks match the cutoff, a message is displayed and no prompt appears.
|
||||
>vim
|
||||
:Pending archive " 30-day default, with confirmation
|
||||
:Pending archive 7d " tasks completed more than 7 days ago
|
||||
:Pending archive 3w " tasks completed more than 21 days ago
|
||||
:Pending archive 2m " tasks completed more than 60 days ago
|
||||
:Pending archive 30 " bare integer, same as 30d
|
||||
<
|
||||
|
||||
*:Pending-due*
|
||||
|
|
|
|||
|
|
@ -992,35 +992,67 @@ local function run_sync(backend_name, action)
|
|||
backend[action]()
|
||||
end
|
||||
|
||||
---@param days? integer
|
||||
---@param msg string
|
||||
---@param callback fun()
|
||||
local function confirm(msg, callback)
|
||||
vim.ui.input({ prompt = msg .. ' [y/N]: ' }, function(input)
|
||||
if input and input:lower() == 'y' then
|
||||
callback()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
---@param arg? string
|
||||
---@return nil
|
||||
function M.archive(days)
|
||||
if days == nil then
|
||||
function M.archive(arg)
|
||||
local days
|
||||
if arg and arg ~= '' then
|
||||
days = parse.parse_duration_to_days(arg)
|
||||
if not days then
|
||||
log.error('Invalid duration: ' .. arg .. '. Use e.g. 7d, 2w, 3m, or a bare number.')
|
||||
return
|
||||
end
|
||||
else
|
||||
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 = {}
|
||||
local count = 0
|
||||
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
|
||||
count = count + 1
|
||||
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)
|
||||
if count == 0 then
|
||||
log.info('No tasks to archive.')
|
||||
return
|
||||
end
|
||||
confirm(
|
||||
'Archive ' .. count .. ' task' .. (count == 1 and '' or 's') .. ' completed/deleted more than ' .. days .. 'd ago?',
|
||||
function()
|
||||
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
|
||||
goto skip
|
||||
end
|
||||
end
|
||||
table.insert(kept, task)
|
||||
::skip::
|
||||
end
|
||||
s:replace_tasks(kept)
|
||||
_save_and_notify()
|
||||
log.info('Archived ' .. count .. ' task' .. (count == 1 and '' or 's') .. '.')
|
||||
local bufnr = buffer.bufnr()
|
||||
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
|
||||
buffer.render(bufnr)
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
---@return nil
|
||||
|
|
@ -1342,7 +1374,7 @@ function M.command(args)
|
|||
local action = rest:match('^(%S+)')
|
||||
run_sync(cmd, action)
|
||||
elseif cmd == 'archive' then
|
||||
M.archive(tonumber(rest))
|
||||
M.archive(rest ~= '' and rest or nil)
|
||||
elseif cmd == 'due' then
|
||||
M.due()
|
||||
elseif cmd == 'filter' then
|
||||
|
|
|
|||
|
|
@ -667,4 +667,29 @@ function M.is_today(due)
|
|||
return time_part >= current_time
|
||||
end
|
||||
|
||||
---@param s? string
|
||||
---@return integer?
|
||||
function M.parse_duration_to_days(s)
|
||||
if s == nil or s == '' then
|
||||
return nil
|
||||
end
|
||||
local n = s:match('^(%d+)d$')
|
||||
if n then
|
||||
return tonumber(n) --[[@as integer]]
|
||||
end
|
||||
n = s:match('^(%d+)w$')
|
||||
if n then
|
||||
return tonumber(n) --[[@as integer]] * 7
|
||||
end
|
||||
n = s:match('^(%d+)m$')
|
||||
if n then
|
||||
return tonumber(n) --[[@as integer]] * 30
|
||||
end
|
||||
n = s:match('^(%d+)$')
|
||||
if n then
|
||||
return tonumber(n) --[[@as integer]]
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
|
|||
|
|
@ -203,6 +203,9 @@ end, {
|
|||
end
|
||||
return filtered
|
||||
end
|
||||
if cmd_line:match('^Pending%s+archive%s') then
|
||||
return filter_candidates(arg_lead, { '7d', '2w', '30d', '3m', '6m', '1y' })
|
||||
end
|
||||
if cmd_line:match('^Pending%s+done%s') then
|
||||
local store = require('pending.store')
|
||||
local s = store.new(store.resolve_path())
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ local config = require('pending.config')
|
|||
describe('archive', function()
|
||||
local tmpdir
|
||||
local pending
|
||||
local ui_input_orig
|
||||
|
||||
before_each(function()
|
||||
tmpdir = vim.fn.tempname()
|
||||
|
|
@ -14,16 +15,31 @@ describe('archive', function()
|
|||
package.loaded['pending'] = nil
|
||||
pending = require('pending')
|
||||
pending.store():load()
|
||||
ui_input_orig = vim.ui.input
|
||||
end)
|
||||
|
||||
after_each(function()
|
||||
vim.ui.input = ui_input_orig
|
||||
vim.fn.delete(tmpdir, 'rf')
|
||||
vim.g.pending = nil
|
||||
config.reset()
|
||||
package.loaded['pending'] = nil
|
||||
end)
|
||||
|
||||
local function auto_confirm_y()
|
||||
vim.ui.input = function(opts, on_confirm)
|
||||
on_confirm('y')
|
||||
end
|
||||
end
|
||||
|
||||
local function auto_confirm_n()
|
||||
vim.ui.input = function(opts, on_confirm)
|
||||
on_confirm('n')
|
||||
end
|
||||
end
|
||||
|
||||
it('removes done tasks completed more than 30 days ago', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local t = s:add({ description = 'Old done task' })
|
||||
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
|
|
@ -32,6 +48,7 @@ describe('archive', function()
|
|||
end)
|
||||
|
||||
it('keeps done tasks completed fewer than 30 days ago', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
|
||||
local t = s:add({ description = 'Recent done task' })
|
||||
|
|
@ -42,26 +59,84 @@ describe('archive', function()
|
|||
assert.are.equal('Recent done task', active[1].description)
|
||||
end)
|
||||
|
||||
it('respects a custom day count', function()
|
||||
it('respects duration string 7d', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400))
|
||||
local t = s:add({ description = 'Old for 7 days' })
|
||||
s:update(t.id, { status = 'done', ['end'] = eight_days_ago })
|
||||
pending.archive(7)
|
||||
pending.archive('7d')
|
||||
assert.are.equal(0, #s:active_tasks())
|
||||
end)
|
||||
|
||||
it('keeps tasks within the custom day cutoff', function()
|
||||
it('respects duration string 2w', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local fifteen_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (15 * 86400))
|
||||
local t = s:add({ description = 'Old for 2 weeks' })
|
||||
s:update(t.id, { status = 'done', ['end'] = fifteen_days_ago })
|
||||
pending.archive('2w')
|
||||
assert.are.equal(0, #s:active_tasks())
|
||||
end)
|
||||
|
||||
it('respects duration string 2m', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local t = s:add({ description = 'Old for 2 months' })
|
||||
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
pending.archive('2m')
|
||||
assert.are.equal(0, #s:active_tasks())
|
||||
end)
|
||||
|
||||
it('respects bare integer as days (backwards compat)', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local eight_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (8 * 86400))
|
||||
local t = s:add({ description = 'Old for 7 days' })
|
||||
s:update(t.id, { status = 'done', ['end'] = eight_days_ago })
|
||||
pending.archive('7')
|
||||
assert.are.equal(0, #s:active_tasks())
|
||||
end)
|
||||
|
||||
it('keeps tasks within the custom duration cutoff', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local five_days_ago = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
|
||||
local t = s:add({ description = 'Recent for 7 days' })
|
||||
s:update(t.id, { status = 'done', ['end'] = five_days_ago })
|
||||
pending.archive(7)
|
||||
pending.archive('7d')
|
||||
local active = s:active_tasks()
|
||||
assert.are.equal(1, #active)
|
||||
end)
|
||||
|
||||
it('errors on invalid duration input', function()
|
||||
local messages = {}
|
||||
local orig_notify = vim.notify
|
||||
vim.notify = function(msg, ...)
|
||||
table.insert(messages, msg)
|
||||
return orig_notify(msg, ...)
|
||||
end
|
||||
|
||||
local s = pending.store()
|
||||
local t = s:add({ description = 'Task' })
|
||||
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
pending.archive('xyz')
|
||||
|
||||
vim.notify = orig_notify
|
||||
assert.are.equal(1, #s:tasks())
|
||||
|
||||
local found = false
|
||||
for _, msg in ipairs(messages) do
|
||||
if msg:find('Invalid duration') then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
assert.is_true(found)
|
||||
end)
|
||||
|
||||
it('never archives pending tasks regardless of age', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
s:add({ description = 'Still pending' })
|
||||
pending.archive()
|
||||
|
|
@ -71,6 +146,7 @@ describe('archive', function()
|
|||
end)
|
||||
|
||||
it('removes deleted tasks past the cutoff', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local t = s:add({ description = 'Old deleted task' })
|
||||
s:update(t.id, { status = 'deleted', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
|
|
@ -80,6 +156,7 @@ describe('archive', function()
|
|||
end)
|
||||
|
||||
it('keeps deleted tasks within the cutoff', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local recent_end = os.date('!%Y-%m-%dT%H:%M:%SZ', os.time() - (5 * 86400))
|
||||
local t = s:add({ description = 'Recent deleted' })
|
||||
|
|
@ -89,7 +166,58 @@ describe('archive', function()
|
|||
assert.are.equal(1, #all)
|
||||
end)
|
||||
|
||||
it('skips confirmation and reports when no tasks match', function()
|
||||
local input_called = false
|
||||
vim.ui.input = function()
|
||||
input_called = true
|
||||
end
|
||||
|
||||
local messages = {}
|
||||
local orig_notify = vim.notify
|
||||
vim.notify = function(msg, ...)
|
||||
table.insert(messages, msg)
|
||||
return orig_notify(msg, ...)
|
||||
end
|
||||
|
||||
local s = pending.store()
|
||||
s:add({ description = 'Still pending' })
|
||||
pending.archive()
|
||||
|
||||
vim.notify = orig_notify
|
||||
assert.is_false(input_called)
|
||||
|
||||
local found = false
|
||||
for _, msg in ipairs(messages) do
|
||||
if msg:find('No tasks to archive') then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
assert.is_true(found)
|
||||
end)
|
||||
|
||||
it('does not archive when user declines confirmation', function()
|
||||
auto_confirm_n()
|
||||
local s = pending.store()
|
||||
local t = s:add({ description = 'Old done task' })
|
||||
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
pending.archive()
|
||||
assert.are.equal(1, #s:tasks())
|
||||
end)
|
||||
|
||||
it('does not archive when user cancels confirmation (nil)', function()
|
||||
vim.ui.input = function(opts, on_confirm)
|
||||
on_confirm(nil)
|
||||
end
|
||||
local s = pending.store()
|
||||
local t = s:add({ description = 'Old done task' })
|
||||
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
pending.archive()
|
||||
assert.are.equal(1, #s:tasks())
|
||||
end)
|
||||
|
||||
it('reports the correct count in vim.notify', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local messages = {}
|
||||
local orig_notify = vim.notify
|
||||
|
|
@ -118,7 +246,8 @@ describe('archive', function()
|
|||
assert.is_true(found)
|
||||
end)
|
||||
|
||||
it('leaves only kept tasks in store.active_tasks after archive', function()
|
||||
it('leaves only kept tasks in store after archive', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local t1 = s:add({ description = 'Old done' })
|
||||
s:add({ description = 'Keep pending' })
|
||||
|
|
@ -140,6 +269,7 @@ describe('archive', function()
|
|||
end)
|
||||
|
||||
it('persists archived tasks to disk after unload/reload', function()
|
||||
auto_confirm_y()
|
||||
local s = pending.store()
|
||||
local t = s:add({ description = 'Archived task' })
|
||||
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
|
|
@ -147,4 +277,21 @@ describe('archive', function()
|
|||
s:load()
|
||||
assert.are.equal(0, #s:active_tasks())
|
||||
end)
|
||||
|
||||
it('includes the duration in the confirmation prompt', function()
|
||||
local prompt_text
|
||||
vim.ui.input = function(opts, on_confirm)
|
||||
prompt_text = opts.prompt
|
||||
on_confirm('n')
|
||||
end
|
||||
|
||||
local s = pending.store()
|
||||
local t = s:add({ description = 'Old' })
|
||||
s:update(t.id, { status = 'done', ['end'] = '2020-01-01T00:00:00Z' })
|
||||
pending.archive('3w')
|
||||
|
||||
assert.is_not_nil(prompt_text)
|
||||
assert.truthy(prompt_text:find('21d'))
|
||||
assert.truthy(prompt_text:find('1 task'))
|
||||
end)
|
||||
end)
|
||||
|
|
|
|||
|
|
@ -416,6 +416,48 @@ describe('parse', function()
|
|||
end)
|
||||
end)
|
||||
|
||||
describe('parse_duration_to_days', function()
|
||||
it('parses days suffix', function()
|
||||
assert.are.equal(7, parse.parse_duration_to_days('7d'))
|
||||
end)
|
||||
|
||||
it('parses weeks suffix', function()
|
||||
assert.are.equal(21, parse.parse_duration_to_days('3w'))
|
||||
end)
|
||||
|
||||
it('parses months suffix (approximated as 30 days)', function()
|
||||
assert.are.equal(60, parse.parse_duration_to_days('2m'))
|
||||
end)
|
||||
|
||||
it('parses bare integer as days', function()
|
||||
assert.are.equal(30, parse.parse_duration_to_days('30'))
|
||||
end)
|
||||
|
||||
it('returns nil for nil input', function()
|
||||
assert.is_nil(parse.parse_duration_to_days(nil))
|
||||
end)
|
||||
|
||||
it('returns nil for empty string', function()
|
||||
assert.is_nil(parse.parse_duration_to_days(''))
|
||||
end)
|
||||
|
||||
it('returns nil for unrecognized input', function()
|
||||
assert.is_nil(parse.parse_duration_to_days('xyz'))
|
||||
end)
|
||||
|
||||
it('returns nil for negative numbers', function()
|
||||
assert.is_nil(parse.parse_duration_to_days('-7d'))
|
||||
end)
|
||||
|
||||
it('handles single digit', function()
|
||||
assert.are.equal(1, parse.parse_duration_to_days('1d'))
|
||||
end)
|
||||
|
||||
it('handles large numbers', function()
|
||||
assert.are.equal(365, parse.parse_duration_to_days('365d'))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('input_date_formats', function()
|
||||
before_each(function()
|
||||
config.reset()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue