From aa63b9bd6c5cb43a440e3858fc55680b6aa5934a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:28:06 -0400 Subject: [PATCH] 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. --- doc/pending.txt | 21 ++++-- lua/pending/init.lua | 64 ++++++++++++----- lua/pending/parse.lua | 25 +++++++ plugin/pending.lua | 3 + spec/archive_spec.lua | 157 ++++++++++++++++++++++++++++++++++++++++-- spec/parse_spec.lua | 42 +++++++++++ 6 files changed, 287 insertions(+), 25 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 0cf6000..59da208 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -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* diff --git a/lua/pending/init.lua b/lua/pending/init.lua index f01f162..77959c9 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -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 diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index a0160f1..e8fdfab 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -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 diff --git a/plugin/pending.lua b/plugin/pending.lua index be8bc38..2d0d2be 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -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()) diff --git a/spec/archive_spec.lua b/spec/archive_spec.lua index e7046fa..94331b6 100644 --- a/spec/archive_spec.lua +++ b/spec/archive_spec.lua @@ -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) diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua index 0e6ac19..0820356 100644 --- a/spec/parse_spec.lua +++ b/spec/parse_spec.lua @@ -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()