Compare commits

...

2 commits

Author SHA1 Message Date
8fb3554c43 feat: add \:Pending done <id>\ command
Toggles a task's done/pending status by ID from the command line,
matching the buffer \`<CR>\` behaviour including recurrence spawning.
Tab-completes active task IDs.
2026-03-05 23:52:56 -05:00
fc58e22bfa fix(sync): auth and health UX improvements
Problem: Failed token exchange left credential files on disk, trapping
users in a broken auth loop with no way back to setup. The `auth`
prompt used raw backend names and a terse prompt string. The `health`
action appeared in `:Pending gcal health` tab completion but silently
no-oped outside `:checkhealth`. gcal health omitted the token check
that gtasks had.

Solution: `_exchange_code` now calls `_wipe()` on both failure paths,
clearing the token and credentials files so the next `:Pending auth`
routes back through `setup()`. Prompt uses full service names and
"Authenticate with:". `health` is filtered from sync subcommand
completion and dispatch — its home is `:checkhealth pending`. gcal
health now checks for tokens.
2026-03-05 23:15:43 -05:00
4 changed files with 75 additions and 7 deletions

View file

@ -430,6 +430,48 @@ function M.toggle_complete()
end
end
---@param id_str string
---@return nil
function M.done(id_str)
local id = tonumber(id_str)
if not id then
log.error('Invalid task ID: ' .. tostring(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
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()
@ -550,7 +592,7 @@ local function run_sync(backend_name, action)
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
if type(v) == 'function' and k:sub(1, 1) ~= '_' and k ~= 'health' then
table.insert(actions, k)
end
end
@ -558,7 +600,7 @@ local function run_sync(backend_name, action)
log.info(backend_name .. ' actions: ' .. table.concat(actions, ', '))
return
end
if type(backend[action]) ~= 'function' then
if action == 'health' or type(backend[action]) ~= 'function' then
log.error(backend_name .. " backend has no '" .. action .. "' action")
return
end
@ -831,8 +873,8 @@ end
---@return nil
function M.auth()
local oauth = require('pending.sync.oauth')
vim.ui.select({ 'gtasks', 'gcal', 'both' }, {
prompt = 'Authenticate:',
vim.ui.select({ 'Google Tasks', 'Google Calendar', 'Google Tasks and Google Calendar' }, {
prompt = 'Authenticate with:',
}, function(choice)
if not choice then
return
@ -856,6 +898,8 @@ function M.command(args)
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)

View file

@ -234,6 +234,12 @@ end
---@return nil
function M.health()
oauth.health(M.name)
local tokens = oauth.google_client:load_tokens()
if tokens and tokens.refresh_token then
vim.health.ok('gcal tokens found')
else
vim.health.info('no gcal tokens — run :Pending auth')
end
end
return M

View file

@ -350,7 +350,7 @@ end
function OAuthClient:auth(on_complete)
local creds = self:resolve_credentials()
if creds.client_id == BUNDLED_CLIENT_ID then
log.error(self.name .. ': no credentials configured — run :Pending ' .. self.name .. ' setup')
log.error(self.name .. ': no credentials configured — run :Pending auth')
return
end
local port = self.port
@ -470,12 +470,14 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet
}, { text = true })
if result.code ~= 0 then
self:_wipe()
log.error('Token exchange failed.')
return
end
local ok, decoded = pcall(vim.json.decode, result.stdout or '')
if not ok or not decoded.access_token then
self:_wipe()
log.error('Invalid token response.')
return
end
@ -488,6 +490,12 @@ function OAuthClient:_exchange_code(creds, code, code_verifier, port, on_complet
end
end
---@return nil
function OAuthClient:_wipe()
os.remove(self:token_path())
os.remove(vim.fn.stdpath('data') .. '/pending/google_credentials.json')
end
---@param opts { name: string, scope: string, port: integer, config_key: string }
---@return pending.OAuthClient
function M.new(opts)

View file

@ -167,7 +167,7 @@ end, {
nargs = '*',
complete = function(arg_lead, cmd_line)
local pending = require('pending')
local subcmds = { 'add', 'archive', 'auth', 'due', 'edit', 'filter', 'undo' }
local subcmds = { 'add', 'archive', 'auth', 'done', 'due', 'edit', 'filter', 'undo' }
for _, b in ipairs(pending.sync_backends()) do
table.insert(subcmds, b)
end
@ -200,6 +200,16 @@ end, {
end
return filtered
end
if cmd_line:match('^Pending%s+done%s') then
local store = require('pending.store')
local s = store.new(store.resolve_path())
s:load()
local ids = {}
for _, task in ipairs(s:active_tasks()) do
table.insert(ids, tostring(task.id))
end
return filter_candidates(arg_lead, ids)
end
if cmd_line:match('^Pending%s+edit') then
return complete_edit(arg_lead, cmd_line)
end
@ -216,7 +226,7 @@ end, {
end
local actions = {}
for k, v in pairs(mod) do
if type(v) == 'function' and k:sub(1, 1) ~= '_' then
if type(v) == 'function' and k:sub(1, 1) ~= '_' and k ~= 'health' then
table.insert(actions, k)
end
end